commit b3ce28b1d3ca0d1d8cddcdb3a23df0371541da0c Author: giles Date: Mon Feb 23 09:59:24 2026 +0000 Initial account microservice Account dashboard, newsletters, widget pages (tickets, bookings). OAuth SSO client via shared blueprint — per-app first-party cookies. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b509b5a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "shared"] + path = shared + url = https://git.rose-ash.com/coop/shared.git + branch = decoupling diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5898021 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 + +# ---------- Python application ---------- +FROM python:3.11-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + PIP_NO_CACHE_DIR=1 \ + APP_PORT=8000 \ + APP_MODULE=app:app + +WORKDIR /app + +# Install system deps + psql client +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY shared/requirements.txt ./requirements.txt +RUN pip install -r requirements.txt + +COPY . . + +# ---------- Runtime setup ---------- +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE ${APP_PORT} +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..fa0d025 --- /dev/null +++ b/app.py @@ -0,0 +1,48 @@ +from __future__ import annotations +import path_setup # noqa: F401 # adds shared/ to sys.path + +from quart import g + +from shared.infrastructure.factory import create_base_app +from shared.services.registry import services + +from bp import register_account_bp + + +async def account_context() -> dict: + """Account app context processor.""" + from shared.infrastructure.context import base_context + from shared.services.navigation import get_navigation_tree + from shared.infrastructure.cart_identity import current_cart_identity + + ctx = await base_context() + + ctx["menu_items"] = await get_navigation_tree(g.s) + + # Cart data (consistent with all other apps) + ident = current_cart_identity() + summary = await services.cart.cart_summary( + g.s, user_id=ident["user_id"], session_id=ident["session_id"], + ) + ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count + ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total) + + return ctx + + +def create_app() -> "Quart": + from services import register_domain_services + + app = create_base_app( + "account", + context_fn=account_context, + domain_services_fn=register_domain_services, + ) + + # --- blueprints --- + app.register_blueprint(register_account_bp()) + + return app + + +app = create_app() diff --git a/bp/__init__.py b/bp/__init__.py new file mode 100644 index 0000000..9fb7501 --- /dev/null +++ b/bp/__init__.py @@ -0,0 +1 @@ +from .account.routes import register as register_account_bp diff --git a/bp/account/__init__.py b/bp/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/account/routes.py b/bp/account/routes.py new file mode 100644 index 0000000..ed94c7a --- /dev/null +++ b/bp/account/routes.py @@ -0,0 +1,162 @@ +"""Account pages blueprint. + +Moved from federation/bp/auth — newsletters, widget pages (tickets, bookings). +Mounted at root /. +""" +from __future__ import annotations + +from quart import ( + Blueprint, + request, + render_template, + make_response, + redirect, + g, +) +from sqlalchemy import select + +from shared.models import UserNewsletter +from shared.models.ghost_membership_entities import GhostNewsletter +from shared.services.widget_registry import widgets +from shared.infrastructure.urls import login_url + +oob = { + "oob_extends": "oob_elements.html", + "extends": "_types/root/_index.html", + "parent_id": "root-header-child", + "child_id": "auth-header-child", + "header": "_types/auth/header/_header.html", + "parent_header": "_types/root/header/_header.html", + "nav": "_types/auth/_nav.html", + "main": "_types/auth/_main_panel.html", +} + + +def register(url_prefix="/"): + account_bp = Blueprint("account", __name__, url_prefix=url_prefix) + + @account_bp.context_processor + def context(): + return {"oob": oob, "account_nav_links": widgets.account_nav} + + @account_bp.get("/") + async def account(): + from shared.browser.app.utils.htmx import is_htmx_request + + if not g.get("user"): + return redirect(login_url("/")) + + if not is_htmx_request(): + html = await render_template("_types/auth/index.html") + else: + html = await render_template("_types/auth/_oob_elements.html") + + return await make_response(html) + + @account_bp.get("/newsletters/") + async def newsletters(): + from shared.browser.app.utils.htmx import is_htmx_request + + if not g.get("user"): + return redirect(login_url("/newsletters/")) + + result = await g.s.execute( + select(GhostNewsletter).order_by(GhostNewsletter.name) + ) + all_newsletters = result.scalars().all() + + sub_result = await g.s.execute( + select(UserNewsletter).where( + UserNewsletter.user_id == g.user.id, + ) + ) + user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()} + + newsletter_list = [] + for nl in all_newsletters: + un = user_subs.get(nl.id) + newsletter_list.append({ + "newsletter": nl, + "un": un, + "subscribed": un.subscribed if un else False, + }) + + nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"} + + if not is_htmx_request(): + html = await render_template( + "_types/auth/index.html", + oob=nl_oob, + newsletter_list=newsletter_list, + ) + else: + html = await render_template( + "_types/auth/_oob_elements.html", + oob=nl_oob, + newsletter_list=newsletter_list, + ) + + return await make_response(html) + + @account_bp.post("/newsletter//toggle/") + async def toggle_newsletter(newsletter_id: int): + if not g.get("user"): + return "", 401 + + result = await g.s.execute( + select(UserNewsletter).where( + UserNewsletter.user_id == g.user.id, + UserNewsletter.newsletter_id == newsletter_id, + ) + ) + un = result.scalar_one_or_none() + + if un: + un.subscribed = not un.subscribed + else: + un = UserNewsletter( + user_id=g.user.id, + newsletter_id=newsletter_id, + subscribed=True, + ) + g.s.add(un) + + await g.s.flush() + + return await render_template( + "_types/auth/_newsletter_toggle.html", + un=un, + ) + + # Catch-all for widget pages — must be last + @account_bp.get("//") + async def widget_page(slug): + from shared.browser.app.utils.htmx import is_htmx_request + from quart import abort + + widget = widgets.account_page_by_slug(slug) + if not widget: + abort(404) + + if not g.get("user"): + return redirect(login_url(f"/{slug}/")) + + ctx = await widget.context_fn(g.s, user_id=g.user.id) + w_oob = {**oob, "main": widget.template} + + if not is_htmx_request(): + html = await render_template( + "_types/auth/index.html", + oob=w_oob, + **ctx, + ) + else: + html = await render_template( + "_types/auth/_oob_elements.html", + oob=w_oob, + **ctx, + ) + + return await make_response(html) + + return account_bp diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..52b4f51 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Optional: wait for Postgres to be reachable +if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then + echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..." + for i in {1..60}; do + (echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true + sleep 1 + done +fi + +# Clear Redis page cache on deploy +if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then + echo "Flushing Redis cache..." + python3 -c " +import redis, os +r = redis.from_url(os.environ['REDIS_URL']) +r.flushall() +print('Redis cache cleared.') +" || echo "Redis flush failed (non-fatal), continuing..." +fi + +# Start the app +echo "Starting Hypercorn (${APP_MODULE:-app:app})..." +PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/path_setup.py b/path_setup.py new file mode 100644 index 0000000..c7166f7 --- /dev/null +++ b/path_setup.py @@ -0,0 +1,9 @@ +import sys +import os + +_app_dir = os.path.dirname(os.path.abspath(__file__)) +_project_root = os.path.dirname(_app_dir) + +for _p in (_project_root, _app_dir): + if _p not in sys.path: + sys.path.insert(0, _p) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..299f0ad --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,27 @@ +"""Account app service registration.""" +from __future__ import annotations + + +def register_domain_services() -> None: + """Register services for the account app. + + Account needs all domain services since widgets (tickets, bookings) + pull data from blog, calendar, market, cart, and federation. + """ + from shared.services.registry import services + from shared.services.federation_impl import SqlFederationService + from shared.services.blog_impl import SqlBlogService + from shared.services.calendar_impl import SqlCalendarService + from shared.services.market_impl import SqlMarketService + from shared.services.cart_impl import SqlCartService + + if not services.has("federation"): + services.federation = SqlFederationService() + if not services.has("blog"): + services.blog = SqlBlogService() + if not services.has("calendar"): + services.calendar = SqlCalendarService() + if not services.has("market"): + services.market = SqlMarketService() + if not services.has("cart"): + services.cart = SqlCartService() diff --git a/shared b/shared new file mode 160000 index 0000000..46f44f6 --- /dev/null +++ b/shared @@ -0,0 +1 @@ +Subproject commit 46f44f6171813179ebed1bcfc32b6d2ec10fdcbb