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:
Russell Ballestrini 2026-01-11 08:52:38 -05:00
parent 7ced344cb0
commit 3dfbac6f3e
9 changed files with 342 additions and 5 deletions

View file

@ -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

View file

@ -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)

View file

@ -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")

View file

@ -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")

View file

@ -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 %}

View file

@ -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 />

View 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

View file

@ -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

View file

@ -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 "",