Add sitemap, RSS/Atom feeds, and Google site verification for shops
- Add /sitemap.xml, /rss.xml, /atom.xml, /feed.xml routes - Include only public products and content (visibility=1) - Update robots.txt to include sitemap reference for shops - Add google_site_verification column to shop model - Add verification code input in shop integration settings - Render verification meta tag on shop homepage - Update CLAUDE.md with migration ID generation requirement
This commit is contained in:
parent
7ced344cb0
commit
3dfbac6f3e
9 changed files with 342 additions and 5 deletions
|
|
@ -89,17 +89,21 @@ SELECT * FROM mps_crypto_payment WHERE id = 'paymentuuidherewithoutdashes';
|
|||
When making changes to database models, always create Alembic migrations:
|
||||
|
||||
### Creating Migrations
|
||||
|
||||
**CRITICAL**: ALWAYS use `alembic revision` to generate migration files. NEVER manually create migration files or make up revision IDs. Alembic generates unique revision IDs that are required for proper migration tracking.
|
||||
|
||||
```bash
|
||||
# Activate environment first
|
||||
source env/bin/activate
|
||||
|
||||
# Create a new migration (manual)
|
||||
# Create a new migration (manual) - ALWAYS use this command
|
||||
alembic -c data/development.ini revision -m "description of change"
|
||||
|
||||
# OR: Create autogenerated migration (compares DB with models)
|
||||
alembic -c data/development.ini revision --autogenerate -m "description of change"
|
||||
|
||||
# Edit the generated migration file in make_post_sell/scripts/alembic/versions/
|
||||
# The file will have a proper unique ID like: 05be3044c2d2_description_of_change.py
|
||||
```
|
||||
|
||||
### Running Migrations
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ class Shop(RBase, Base):
|
|||
google_analytics_id = Column(Unicode(32), nullable=True, default="")
|
||||
plausible_domain_name = Column(Unicode(256), nullable=True, default="")
|
||||
|
||||
# Google Search Console site verification code
|
||||
google_site_verification = Column(Unicode(128), nullable=True, default="")
|
||||
|
||||
# example: "cus_12345678AbCdEF" but may be null.
|
||||
# this is how the shop pays for make_post_sell.
|
||||
stripe_id = Column(Unicode(32), unique=True, nullable=True)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@ def includeme(config):
|
|||
config.add_static_view("static", "static", cache_max_age=3600)
|
||||
config.add_route("favicon", "/favicon.ico")
|
||||
config.add_route("robots", "/robots.txt")
|
||||
|
||||
# Sitemap and feed routes
|
||||
config.add_route("sitemap", "/sitemap.xml")
|
||||
config.add_route("rss", "/rss.xml")
|
||||
config.add_route("atom", "/atom.xml")
|
||||
config.add_route("feed_xml", "/feed.xml")
|
||||
config.add_route("feed_rss", "/feed.rss")
|
||||
config.add_route("feed_atom", "/feed.atom")
|
||||
|
||||
config.add_route("markup-editor-preview", "/markup-editor-preview")
|
||||
|
||||
config.add_route("ask-for-on-demand-tls", "/ask-for-on-demand-tls")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
"""add google_site_verification to shop
|
||||
|
||||
Revision ID: 05be3044c2d2
|
||||
Revises: 3734955e7379
|
||||
Create Date: 2026-01-11 08:50:51.206970
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '05be3044c2d2'
|
||||
down_revision = '3734955e7379'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column(
|
||||
"google_site_verification",
|
||||
sa.Unicode(length=128),
|
||||
nullable=True,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("mps_shop", "google_site_verification")
|
||||
|
|
@ -67,6 +67,10 @@
|
|||
<link rel="icon" href="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-favicon?ts={{ request.shop.updated_timestamp }}" />
|
||||
{% endif %}
|
||||
|
||||
{% if request.path == '/' and request.shop and request.shop.google_site_verification %}
|
||||
<meta name="google-site-verification" content="{{ request.shop.google_site_verification }}" />
|
||||
{% endif %}
|
||||
|
||||
{%- block append_to_head_tag_section %}
|
||||
<title>{{ request.domain }}</title>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,21 @@
|
|||
<br />
|
||||
<br />
|
||||
|
||||
<label for="google_site_verification_input">Google Site Verification Code (optional)</label>
|
||||
<input
|
||||
name = "google_site_verification"
|
||||
type = "text"
|
||||
id = "google_site_verification_input"
|
||||
class = "full-width-input"
|
||||
value = "{% if google_site_verification %}{{ google_site_verification }}{% endif %}"
|
||||
placeholder = "ex: abc123xyz..."
|
||||
/>
|
||||
<br />
|
||||
<small>Paste the verification code from Google Search Console (just the code, not the full meta tag)</small>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
|
||||
<br />
|
||||
|
|
|
|||
250
make_post_sell/views/feeds.py
Normal file
250
make_post_sell/views/feeds.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""
|
||||
Sitemap and feed views for shops.
|
||||
|
||||
Provides XML sitemap and RSS/Atom feeds for public products and content.
|
||||
Only includes public items (visibility=1), excludes unlisted, private,
|
||||
coupons, carts, and user profile pages.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||
|
||||
from pyramid.response import Response
|
||||
from pyramid.view import view_config
|
||||
|
||||
from ..models.product import Product
|
||||
|
||||
|
||||
def timestamp_to_iso8601(timestamp):
|
||||
"""Convert millisecond timestamp to ISO 8601 format."""
|
||||
if timestamp:
|
||||
dt = datetime.fromtimestamp(timestamp / 1000.0)
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
||||
|
||||
|
||||
def timestamp_to_rfc822(timestamp):
|
||||
"""Convert millisecond timestamp to RFC 822 format for RSS."""
|
||||
if timestamp:
|
||||
dt = datetime.fromtimestamp(timestamp / 1000.0)
|
||||
return dt.strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
return datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
|
||||
|
||||
def get_public_items(shop):
|
||||
"""Get all public products and content from a shop."""
|
||||
if not shop:
|
||||
return [], []
|
||||
|
||||
products = (
|
||||
shop.products.filter(Product.visibility == 1, Product.is_sellable == True)
|
||||
.order_by(Product.updated_timestamp.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
content = (
|
||||
shop.products.filter(Product.visibility == 1, Product.is_sellable == False)
|
||||
.order_by(Product.updated_timestamp.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return products, content
|
||||
|
||||
|
||||
def build_sitemap_xml(request, shop, products, content):
|
||||
"""Build XML sitemap for the shop."""
|
||||
urlset = Element("urlset")
|
||||
urlset.set("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9")
|
||||
|
||||
# Shop home page
|
||||
url = SubElement(urlset, "url")
|
||||
SubElement(url, "loc").text = shop.absolute_url(request)
|
||||
SubElement(url, "lastmod").text = timestamp_to_iso8601(shop.updated_timestamp)
|
||||
SubElement(url, "changefreq").text = "weekly"
|
||||
SubElement(url, "priority").text = "1.0"
|
||||
|
||||
# Shop about page
|
||||
url = SubElement(urlset, "url")
|
||||
SubElement(url, "loc").text = shop.absolute_about_url(request)
|
||||
SubElement(url, "lastmod").text = timestamp_to_iso8601(shop.updated_timestamp)
|
||||
SubElement(url, "changefreq").text = "monthly"
|
||||
SubElement(url, "priority").text = "0.5"
|
||||
|
||||
# Terms of service (if exists)
|
||||
if shop.terms_of_service_raw:
|
||||
url = SubElement(urlset, "url")
|
||||
SubElement(url, "loc").text = shop.absolute_terms_url(request)
|
||||
SubElement(url, "lastmod").text = timestamp_to_iso8601(shop.updated_timestamp)
|
||||
SubElement(url, "changefreq").text = "yearly"
|
||||
SubElement(url, "priority").text = "0.3"
|
||||
|
||||
# Privacy policy (if exists)
|
||||
if shop.privacy_policy_raw:
|
||||
url = SubElement(urlset, "url")
|
||||
SubElement(url, "loc").text = shop.absolute_privacy_policy_url(request)
|
||||
SubElement(url, "lastmod").text = timestamp_to_iso8601(shop.updated_timestamp)
|
||||
SubElement(url, "changefreq").text = "yearly"
|
||||
SubElement(url, "priority").text = "0.3"
|
||||
|
||||
# Public products
|
||||
for product in products:
|
||||
url = SubElement(urlset, "url")
|
||||
SubElement(url, "loc").text = product.absolute_url(request)
|
||||
SubElement(url, "lastmod").text = timestamp_to_iso8601(product.updated_timestamp)
|
||||
SubElement(url, "changefreq").text = "weekly"
|
||||
SubElement(url, "priority").text = "0.8"
|
||||
|
||||
# Public content
|
||||
for item in content:
|
||||
url = SubElement(urlset, "url")
|
||||
SubElement(url, "loc").text = item.absolute_url(request)
|
||||
SubElement(url, "lastmod").text = timestamp_to_iso8601(item.updated_timestamp)
|
||||
SubElement(url, "changefreq").text = "weekly"
|
||||
SubElement(url, "priority").text = "0.7"
|
||||
|
||||
return tostring(urlset, encoding="unicode", method="xml")
|
||||
|
||||
|
||||
def build_rss_xml(request, shop, products, content):
|
||||
"""Build RSS 2.0 feed for the shop."""
|
||||
rss = Element("rss")
|
||||
rss.set("version", "2.0")
|
||||
rss.set("xmlns:atom", "http://www.w3.org/2005/Atom")
|
||||
|
||||
channel = SubElement(rss, "channel")
|
||||
SubElement(channel, "title").text = shop.name
|
||||
SubElement(channel, "link").text = shop.absolute_url(request)
|
||||
SubElement(channel, "description").text = shop.description or f"Products and content from {shop.name}"
|
||||
SubElement(channel, "language").text = "en-us"
|
||||
SubElement(channel, "lastBuildDate").text = timestamp_to_rfc822(shop.updated_timestamp)
|
||||
|
||||
# Atom self-link for feed validation
|
||||
atom_link = SubElement(channel, "atom:link")
|
||||
atom_link.set("href", f"{request.host_url}/rss.xml")
|
||||
atom_link.set("rel", "self")
|
||||
atom_link.set("type", "application/rss+xml")
|
||||
|
||||
# Combine and sort by updated timestamp
|
||||
all_items = [(p, p.updated_timestamp) for p in products] + [
|
||||
(c, c.updated_timestamp) for c in content
|
||||
]
|
||||
all_items.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
for item_obj, _ in all_items:
|
||||
item = SubElement(channel, "item")
|
||||
SubElement(item, "title").text = item_obj.title
|
||||
SubElement(item, "link").text = item_obj.absolute_url(request)
|
||||
SubElement(item, "guid").text = item_obj.absolute_url(request)
|
||||
SubElement(item, "pubDate").text = timestamp_to_rfc822(item_obj.created_timestamp)
|
||||
|
||||
# Use description_html stripped of tags, or fall back to raw
|
||||
if item_obj.description:
|
||||
SubElement(item, "description").text = item_obj.description[:500]
|
||||
|
||||
return tostring(rss, encoding="unicode", method="xml")
|
||||
|
||||
|
||||
def build_atom_xml(request, shop, products, content):
|
||||
"""Build Atom feed for the shop."""
|
||||
feed = Element("feed")
|
||||
feed.set("xmlns", "http://www.w3.org/2005/Atom")
|
||||
|
||||
SubElement(feed, "title").text = shop.name
|
||||
SubElement(feed, "id").text = shop.absolute_url(request)
|
||||
SubElement(feed, "updated").text = timestamp_to_iso8601(shop.updated_timestamp)
|
||||
|
||||
# Self link
|
||||
self_link = SubElement(feed, "link")
|
||||
self_link.set("href", f"{request.host_url}/atom.xml")
|
||||
self_link.set("rel", "self")
|
||||
self_link.set("type", "application/atom+xml")
|
||||
|
||||
# Alternate link
|
||||
alt_link = SubElement(feed, "link")
|
||||
alt_link.set("href", shop.absolute_url(request))
|
||||
alt_link.set("rel", "alternate")
|
||||
alt_link.set("type", "text/html")
|
||||
|
||||
if shop.description:
|
||||
SubElement(feed, "subtitle").text = shop.description
|
||||
|
||||
# Combine and sort by updated timestamp
|
||||
all_items = [(p, p.updated_timestamp) for p in products] + [
|
||||
(c, c.updated_timestamp) for c in content
|
||||
]
|
||||
all_items.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
for item_obj, _ in all_items:
|
||||
entry = SubElement(feed, "entry")
|
||||
SubElement(entry, "title").text = item_obj.title
|
||||
SubElement(entry, "id").text = item_obj.absolute_url(request)
|
||||
SubElement(entry, "updated").text = timestamp_to_iso8601(item_obj.updated_timestamp)
|
||||
SubElement(entry, "published").text = timestamp_to_iso8601(item_obj.created_timestamp)
|
||||
|
||||
link = SubElement(entry, "link")
|
||||
link.set("href", item_obj.absolute_url(request))
|
||||
link.set("rel", "alternate")
|
||||
link.set("type", "text/html")
|
||||
|
||||
if item_obj.description:
|
||||
summary = SubElement(entry, "summary")
|
||||
summary.set("type", "text")
|
||||
summary.text = item_obj.description[:500]
|
||||
|
||||
return tostring(feed, encoding="unicode", method="xml")
|
||||
|
||||
|
||||
@view_config(route_name="sitemap")
|
||||
def sitemap_view(request):
|
||||
"""Generate XML sitemap for the shop."""
|
||||
shop = request.shop
|
||||
|
||||
if not shop:
|
||||
response = Response(body="<?xml version='1.0' encoding='UTF-8'?><urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'></urlset>")
|
||||
response.content_type = "application/xml"
|
||||
return response
|
||||
|
||||
products, content = get_public_items(shop)
|
||||
xml = build_sitemap_xml(request, shop, products, content)
|
||||
|
||||
response = Response(body=f"<?xml version='1.0' encoding='UTF-8'?>\n{xml}")
|
||||
response.content_type = "application/xml"
|
||||
return response
|
||||
|
||||
|
||||
@view_config(route_name="rss")
|
||||
@view_config(route_name="feed_xml")
|
||||
@view_config(route_name="feed_rss")
|
||||
def rss_view(request):
|
||||
"""Generate RSS 2.0 feed for the shop."""
|
||||
shop = request.shop
|
||||
|
||||
if not shop:
|
||||
response = Response(body="<?xml version='1.0' encoding='UTF-8'?><rss version='2.0'><channel></channel></rss>")
|
||||
response.content_type = "application/rss+xml"
|
||||
return response
|
||||
|
||||
products, content = get_public_items(shop)
|
||||
xml = build_rss_xml(request, shop, products, content)
|
||||
|
||||
response = Response(body=f"<?xml version='1.0' encoding='UTF-8'?>\n{xml}")
|
||||
response.content_type = "application/rss+xml"
|
||||
return response
|
||||
|
||||
|
||||
@view_config(route_name="atom")
|
||||
@view_config(route_name="feed_atom")
|
||||
def atom_view(request):
|
||||
"""Generate Atom feed for the shop."""
|
||||
shop = request.shop
|
||||
|
||||
if not shop:
|
||||
response = Response(body="<?xml version='1.0' encoding='UTF-8'?><feed xmlns='http://www.w3.org/2005/Atom'></feed>")
|
||||
response.content_type = "application/atom+xml"
|
||||
return response
|
||||
|
||||
products, content = get_public_items(shop)
|
||||
xml = build_atom_xml(request, shop, products, content)
|
||||
|
||||
response = Response(body=f"<?xml version='1.0' encoding='UTF-8'?>\n{xml}")
|
||||
response.content_type = "application/atom+xml"
|
||||
return response
|
||||
|
|
@ -29,10 +29,17 @@ def favicon_view(request):
|
|||
|
||||
@view_config(route_name="robots")
|
||||
def robots_view(request):
|
||||
"""Load and return either default robots.txt or version from .ini"""
|
||||
response = Response(
|
||||
body=request.app.get("robots_dot_txt", DEFAULT_ROBOTS_DOT_TXT).lstrip()
|
||||
)
|
||||
"""Load and return robots.txt with sitemap reference for shops."""
|
||||
base_robots = request.app.get("robots_dot_txt", DEFAULT_ROBOTS_DOT_TXT).lstrip()
|
||||
|
||||
# Add sitemap reference for shops
|
||||
if request.shop:
|
||||
sitemap_url = f"{request.host_url}/sitemap.xml"
|
||||
body = f"{base_robots}\nSitemap: {sitemap_url}\n"
|
||||
else:
|
||||
body = base_robots
|
||||
|
||||
response = Response(body=body)
|
||||
response.content_type = "text/plain"
|
||||
return response
|
||||
|
||||
|
|
|
|||
|
|
@ -395,6 +395,10 @@ def shop_settings(request):
|
|||
"plausible_domain_name", shop.plausible_domain_name or ""
|
||||
).strip()
|
||||
|
||||
google_site_verification = request.params.get(
|
||||
"google_site_verification", shop.google_site_verification or ""
|
||||
).strip()
|
||||
|
||||
stripe_test_mode = request.app.get("stripe.test_mode", False)
|
||||
stripe_public_api_key = request.params.get(
|
||||
"stripe_public_api_key", shop.stripe_public_api_key
|
||||
|
|
@ -529,6 +533,14 @@ def shop_settings(request):
|
|||
msg = ("You set the shop's Plausible Analytics Domain Name.", "success")
|
||||
request.session.flash(msg)
|
||||
|
||||
if google_site_verification != (shop.google_site_verification or ""):
|
||||
shop.google_site_verification = google_site_verification
|
||||
if google_site_verification:
|
||||
msg = ("You set the shop's Google Site Verification code.", "success")
|
||||
else:
|
||||
msg = ("You cleared the shop's Google Site Verification code.", "success")
|
||||
request.session.flash(msg)
|
||||
|
||||
# Handle ribbon settings form
|
||||
if form_section == "ribbon-settings":
|
||||
if ribbon_text != (shop.ribbon_text or ""):
|
||||
|
|
@ -963,6 +975,7 @@ def shop_settings(request):
|
|||
"default_theme": shop.default_theme,
|
||||
"google_analytics_id": shop.google_analytics_id or "",
|
||||
"plausible_domain_name": shop.plausible_domain_name or "",
|
||||
"google_site_verification": shop.google_site_verification or "",
|
||||
"stripe_public_api_key": shop.stripe_public_api_key or "",
|
||||
"stripe_secret_api_key": shop.stripe_secret_api_key or "",
|
||||
"paypal_client_id": shop.paypal_client_id or "",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue