From 24d38609521d9d0502b40c3877ecd1f994ed9144 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 10 Feb 2026 18:08:32 +0000 Subject: [PATCH] feat: add page admin market CRUD and update shared_lib - Market create/delete routes in post admin blueprint - markets.py service (create_market, soft_delete_market) - _markets_panel.html admin template with HTMX create/delete - Update shared_lib submodule (MarketPlace model, migration, URLs) Co-Authored-By: Claude Opus 4.6 --- bp/post/admin/routes.py | 94 +++++++++++++++++++ bp/post/services/markets.py | 88 +++++++++++++++++ shared_lib | 2 +- .../_types/post/admin/_markets_panel.html | 44 +++++++++ 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 bp/post/services/markets.py create mode 100644 templates/_types/post/admin/_markets_panel.html diff --git a/bp/post/admin/routes.py b/bp/post/admin/routes.py index 9bb396b..96a45a4 100644 --- a/bp/post/admin/routes.py +++ b/bp/post/admin/routes.py @@ -533,4 +533,98 @@ def register(): return redirect(redirect_url) + @bp.get("/markets/") + @require_admin + async def markets(slug: str): + """List markets for this page.""" + from models.market_place import MarketPlace + from sqlalchemy import select as sa_select + + post = (g.post_data or {}).get("post", {}) + post_id = post.get("id") + if not post_id: + return await make_response("Post not found", 404) + + page_markets = (await g.s.execute( + sa_select(MarketPlace).where( + MarketPlace.post_id == post_id, + MarketPlace.deleted_at.is_(None), + ).order_by(MarketPlace.name) + )).scalars().all() + + html = await render_template( + "_types/post/admin/_markets_panel.html", + markets=page_markets, + post=post, + ) + return await make_response(html) + + @bp.post("/markets/new/") + @require_admin + async def create_market(slug: str): + """Create a new market for this page.""" + from ..services.markets import create_market as _create_market, MarketError + from models.market_place import MarketPlace + from sqlalchemy import select as sa_select + from quart import jsonify + + post = (g.post_data or {}).get("post", {}) + post_id = post.get("id") + if not post_id: + return jsonify({"error": "Post not found"}), 404 + + form = await request.form + name = (form.get("name") or "").strip() + + try: + await _create_market(g.s, post_id, name) + except MarketError as e: + return jsonify({"error": str(e)}), 400 + + # Return updated markets list + page_markets = (await g.s.execute( + sa_select(MarketPlace).where( + MarketPlace.post_id == post_id, + MarketPlace.deleted_at.is_(None), + ).order_by(MarketPlace.name) + )).scalars().all() + + html = await render_template( + "_types/post/admin/_markets_panel.html", + markets=page_markets, + post=post, + ) + return await make_response(html) + + @bp.delete("/markets//") + @require_admin + async def delete_market(slug: str, market_slug: str): + """Soft-delete a market.""" + from ..services.markets import soft_delete_market + from models.market_place import MarketPlace + from sqlalchemy import select as sa_select + from quart import jsonify + + post = (g.post_data or {}).get("post", {}) + post_id = post.get("id") + + deleted = await soft_delete_market(g.s, slug, market_slug) + if not deleted: + return jsonify({"error": "Market not found"}), 404 + + # Return updated markets list + page_markets = (await g.s.execute( + sa_select(MarketPlace).where( + MarketPlace.post_id == post_id, + MarketPlace.deleted_at.is_(None), + ).order_by(MarketPlace.name) + )).scalars().all() + + html = await render_template( + "_types/post/admin/_markets_panel.html", + markets=page_markets, + post=post, + ) + return await make_response(html) + return bp diff --git a/bp/post/services/markets.py b/bp/post/services/markets.py new file mode 100644 index 0000000..6b6ca6f --- /dev/null +++ b/bp/post/services/markets.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import re +import unicodedata + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from models.market_place import MarketPlace +from models.ghost_content import Post +from models.page_config import PageConfig +from suma_browser.app.utils import utcnow + + +class MarketError(ValueError): + """Base error for market service operations.""" + + +def slugify(value: str, max_len: int = 255) -> str: + if value is None: + value = "" + value = unicodedata.normalize("NFKD", value) + value = value.encode("ascii", "ignore").decode("ascii") + value = value.lower() + value = value.replace("/", "-") + value = re.sub(r"[^a-z0-9]+", "-", value) + value = re.sub(r"-{2,}", "-", value) + value = value.strip("-")[:max_len].strip("-") + return value or "market" + + +async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlace: + name = (name or "").strip() + if not name: + raise MarketError("Market name must not be empty.") + slug = slugify(name) + + post = (await sess.execute(select(Post).where(Post.id == post_id))).scalar_one_or_none() + if not post: + raise MarketError(f"Post {post_id} does not exist.") + + if not post.is_page: + raise MarketError("Markets can only be created on pages, not posts.") + + pc = (await sess.execute( + select(PageConfig).where(PageConfig.post_id == post_id) + )).scalar_one_or_none() + if pc is None or not (pc.features or {}).get("market"): + raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.") + + # Look for existing (including soft-deleted) + existing = (await sess.execute( + select(MarketPlace).where(MarketPlace.post_id == post_id, MarketPlace.slug == slug) + )).scalar_one_or_none() + + if existing: + if existing.deleted_at is not None: + existing.deleted_at = None # revive + existing.name = name + await sess.flush() + return existing + raise MarketError(f'Market with slug "{slug}" already exists for this page.') + + market = MarketPlace(post_id=post_id, name=name, slug=slug) + sess.add(market) + await sess.flush() + return market + + +async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool: + market = ( + await sess.execute( + select(MarketPlace) + .join(Post, MarketPlace.post_id == Post.id) + .where( + Post.slug == post_slug, + MarketPlace.slug == market_slug, + MarketPlace.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() + + if not market: + return False + + market.deleted_at = utcnow() + await sess.flush() + return True diff --git a/shared_lib b/shared_lib index 3cc5730..7eb66fb 160000 --- a/shared_lib +++ b/shared_lib @@ -1 +1 @@ -Subproject commit 3cc57303774039125944f09e41921e2f5848062a +Subproject commit 7eb66fbf244a13c0cd97474a5ae85f863b35ee47 diff --git a/templates/_types/post/admin/_markets_panel.html b/templates/_types/post/admin/_markets_panel.html new file mode 100644 index 0000000..d40076a --- /dev/null +++ b/templates/_types/post/admin/_markets_panel.html @@ -0,0 +1,44 @@ +
+

Markets

+ + {% if markets %} +
    + {% for m in markets %} +
  • +
    + {{ m.name }} + /{{ m.slug }}/ +
    + +
  • + {% endfor %} +
+ {% else %} +

No markets yet.

+ {% endif %} + +
+ + +
+