diff --git a/CLAUDE.md b/CLAUDE.md index b8b1cb2..0d0c2a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index e7f0bc1..3851b10 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -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) diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 4fdb3cc..3c010d0 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -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") diff --git a/make_post_sell/scripts/alembic/versions/05be3044c2d2_add_google_site_verification_to_shop.py b/make_post_sell/scripts/alembic/versions/05be3044c2d2_add_google_site_verification_to_shop.py new file mode 100644 index 0000000..3093b13 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/05be3044c2d2_add_google_site_verification_to_shop.py @@ -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") diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2 index 3116dd1..f051a3f 100644 --- a/make_post_sell/templates/base.j2 +++ b/make_post_sell/templates/base.j2 @@ -67,6 +67,10 @@ {% endif %} + {% if request.path == '/' and request.shop and request.shop.google_site_verification %} + + {% endif %} + {%- block append_to_head_tag_section %} {{ request.domain }} {% endblock %} diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2 index 1508e1a..903e213 100644 --- a/make_post_sell/templates/shop_settings.j2 +++ b/make_post_sell/templates/shop_settings.j2 @@ -106,6 +106,21 @@

+ + +
+ Paste the verification code from Google Search Console (just the code, not the full meta tag) + +
+
+
diff --git a/make_post_sell/views/feeds.py b/make_post_sell/views/feeds.py new file mode 100644 index 0000000..0c310f5 --- /dev/null +++ b/make_post_sell/views/feeds.py @@ -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="") + 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"\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="") + 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"\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="") + 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"\n{xml}") + response.content_type = "application/atom+xml" + return response diff --git a/make_post_sell/views/misc.py b/make_post_sell/views/misc.py index 31fa0ab..49dab06 100644 --- a/make_post_sell/views/misc.py +++ b/make_post_sell/views/misc.py @@ -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 diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index 3fb9a1c..11a67cb 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -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 "",