Split cart into 4 microservices: relations, likes, orders, page-config→blog
Phase 1 - Relations service (internal): owns ContainerRelation, exposes get-children data + attach/detach-child actions. Retargeted events, blog, market callers from cart to relations. Phase 2 - Likes service (internal): unified Like model replaces ProductLike and PostLike with generic target_type/target_slug/target_id. Exposes is-liked, liked-slugs, liked-ids data + toggle action. Phase 3 - PageConfig → blog: moved ownership to blog with direct DB queries, removed proxy endpoints from cart. Phase 4 - Orders service (public): owns Order/OrderItem + SumUp checkout flow. Cart checkout now delegates to orders via create-order action. Webhook/return routes and reconciliation moved to orders. Phase 5 - Infrastructure: docker-compose, deploy.sh, Dockerfiles updated for all 3 new services. Added orders_url helper and factory model imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
53
likes/Dockerfile
Normal file
53
likes/Dockerfile
Normal file
@@ -0,0 +1,53 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# Shared code
|
||||
COPY shared/ ./shared/
|
||||
|
||||
# App code
|
||||
COPY likes/ ./
|
||||
|
||||
# Sibling models for cross-domain SQLAlchemy imports
|
||||
COPY blog/__init__.py ./blog/__init__.py
|
||||
COPY blog/models/ ./blog/models/
|
||||
COPY market/__init__.py ./market/__init__.py
|
||||
COPY market/models/ ./market/models/
|
||||
COPY cart/__init__.py ./cart/__init__.py
|
||||
COPY cart/models/ ./cart/models/
|
||||
COPY events/__init__.py ./events/__init__.py
|
||||
COPY events/models/ ./events/models/
|
||||
COPY federation/__init__.py ./federation/__init__.py
|
||||
COPY federation/models/ ./federation/models/
|
||||
COPY account/__init__.py ./account/__init__.py
|
||||
COPY account/models/ ./account/models/
|
||||
COPY relations/__init__.py ./relations/__init__.py
|
||||
COPY relations/models/ ./relations/models/
|
||||
COPY orders/__init__.py ./orders/__init__.py
|
||||
COPY orders/models/ ./orders/models/
|
||||
|
||||
COPY likes/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"]
|
||||
0
likes/__init__.py
Normal file
0
likes/__init__.py
Normal file
35
likes/alembic.ini
Normal file
35
likes/alembic.ini
Normal file
@@ -0,0 +1,35 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
12
likes/alembic/env.py
Normal file
12
likes/alembic/env.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from alembic import context
|
||||
from shared.db.alembic_env import run_alembic
|
||||
|
||||
MODELS = [
|
||||
"likes.models.like",
|
||||
]
|
||||
|
||||
TABLES = frozenset({
|
||||
"likes",
|
||||
})
|
||||
|
||||
run_alembic(context.config, MODELS, TABLES)
|
||||
47
likes/alembic/versions/0001_initial.py
Normal file
47
likes/alembic/versions/0001_initial.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Initial likes tables
|
||||
|
||||
Revision ID: likes_0001
|
||||
Revises: None
|
||||
Create Date: 2026-02-27
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "likes_0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, name):
|
||||
result = conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=:t"
|
||||
), {"t": name})
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def upgrade():
|
||||
if _table_exists(op.get_bind(), "likes"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"likes",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer, nullable=False, index=True),
|
||||
sa.Column("target_type", sa.String(32), nullable=False),
|
||||
sa.Column("target_slug", sa.String(255), nullable=True),
|
||||
sa.Column("target_id", sa.Integer, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint("user_id", "target_type", "target_slug",
|
||||
name="uq_likes_user_type_slug"),
|
||||
sa.UniqueConstraint("user_id", "target_type", "target_id",
|
||||
name="uq_likes_user_type_id"),
|
||||
)
|
||||
op.create_index("ix_likes_target", "likes", ["target_type", "target_slug"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("likes")
|
||||
22
likes/app.py
Normal file
22
likes/app.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
|
||||
from bp import register_actions, register_data
|
||||
from services import register_domain_services
|
||||
|
||||
|
||||
def create_app() -> "Quart":
|
||||
app = create_base_app(
|
||||
"likes",
|
||||
domain_services_fn=register_domain_services,
|
||||
)
|
||||
|
||||
app.register_blueprint(register_actions())
|
||||
app.register_blueprint(register_data())
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
2
likes/bp/__init__.py
Normal file
2
likes/bp/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .data.routes import register as register_data
|
||||
from .actions.routes import register as register_actions
|
||||
0
likes/bp/actions/__init__.py
Normal file
0
likes/bp/actions/__init__.py
Normal file
81
likes/bp/actions/routes.py
Normal file
81
likes/bp/actions/routes.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Likes app action endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- toggle ---
|
||||
async def _toggle():
|
||||
"""Toggle a like. Returns {"liked": bool}."""
|
||||
from sqlalchemy import select, update, func
|
||||
from likes.models.like import Like
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
user_id = data["user_id"]
|
||||
target_type = data["target_type"]
|
||||
target_slug = data.get("target_slug")
|
||||
target_id = data.get("target_id")
|
||||
|
||||
filters = [
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.deleted_at.is_(None),
|
||||
]
|
||||
if target_slug is not None:
|
||||
filters.append(Like.target_slug == target_slug)
|
||||
elif target_id is not None:
|
||||
filters.append(Like.target_id == target_id)
|
||||
else:
|
||||
return {"error": "target_slug or target_id required"}, 400
|
||||
|
||||
existing = await g.s.scalar(select(Like).where(*filters))
|
||||
|
||||
if existing:
|
||||
# Unlike: soft delete
|
||||
await g.s.execute(
|
||||
update(Like).where(Like.id == existing.id).values(deleted_at=func.now())
|
||||
)
|
||||
return {"liked": False}
|
||||
else:
|
||||
# Like: insert new
|
||||
new_like = Like(
|
||||
user_id=user_id,
|
||||
target_type=target_type,
|
||||
target_slug=target_slug,
|
||||
target_id=target_id,
|
||||
)
|
||||
g.s.add(new_like)
|
||||
await g.s.flush()
|
||||
return {"liked": True}
|
||||
|
||||
_handlers["toggle"] = _toggle
|
||||
|
||||
return bp
|
||||
0
likes/bp/data/__init__.py
Normal file
0
likes/bp/data/__init__.py
Normal file
109
likes/bp/data/routes.py
Normal file
109
likes/bp/data/routes.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Likes app data endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- is-liked ---
|
||||
async def _is_liked():
|
||||
"""Check if a user has liked a specific target."""
|
||||
from sqlalchemy import select
|
||||
from likes.models.like import Like
|
||||
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
target_type = request.args.get("target_type", "")
|
||||
target_slug = request.args.get("target_slug")
|
||||
target_id = request.args.get("target_id", type=int)
|
||||
|
||||
if not user_id or not target_type:
|
||||
return {"liked": False}
|
||||
|
||||
filters = [
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.deleted_at.is_(None),
|
||||
]
|
||||
if target_slug is not None:
|
||||
filters.append(Like.target_slug == target_slug)
|
||||
elif target_id is not None:
|
||||
filters.append(Like.target_id == target_id)
|
||||
else:
|
||||
return {"liked": False}
|
||||
|
||||
row = await g.s.scalar(select(Like.id).where(*filters))
|
||||
return {"liked": row is not None}
|
||||
|
||||
_handlers["is-liked"] = _is_liked
|
||||
|
||||
# --- liked-slugs ---
|
||||
async def _liked_slugs():
|
||||
"""Return all liked target_slugs for a user + target_type."""
|
||||
from sqlalchemy import select
|
||||
from likes.models.like import Like
|
||||
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
target_type = request.args.get("target_type", "")
|
||||
|
||||
if not user_id or not target_type:
|
||||
return []
|
||||
|
||||
result = await g.s.execute(
|
||||
select(Like.target_slug).where(
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.target_slug.isnot(None),
|
||||
Like.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
_handlers["liked-slugs"] = _liked_slugs
|
||||
|
||||
# --- liked-ids ---
|
||||
async def _liked_ids():
|
||||
"""Return all liked target_ids for a user + target_type."""
|
||||
from sqlalchemy import select
|
||||
from likes.models.like import Like
|
||||
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
target_type = request.args.get("target_type", "")
|
||||
|
||||
if not user_id or not target_type:
|
||||
return []
|
||||
|
||||
result = await g.s.execute(
|
||||
select(Like.target_id).where(
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.target_id.isnot(None),
|
||||
Like.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
_handlers["liked-ids"] = _liked_ids
|
||||
|
||||
return bp
|
||||
61
likes/entrypoint.sh
Normal file
61
likes/entrypoint.sh
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/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
|
||||
|
||||
# Create own database + run own migrations
|
||||
if [[ "${RUN_MIGRATIONS:-}" == "true" && -n "${ALEMBIC_DATABASE_URL:-}" ]]; then
|
||||
python3 -c "
|
||||
import os, re
|
||||
url = os.environ['ALEMBIC_DATABASE_URL']
|
||||
m = re.match(r'postgresql\+\w+://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)', url)
|
||||
if not m:
|
||||
print('Could not parse ALEMBIC_DATABASE_URL, skipping DB creation')
|
||||
exit(0)
|
||||
user, password, host, port, dbname = m.groups()
|
||||
|
||||
import psycopg
|
||||
conn = psycopg.connect(
|
||||
f'postgresql://{user}:{password}@{host}:{port}/postgres',
|
||||
autocommit=True,
|
||||
)
|
||||
cur = conn.execute('SELECT 1 FROM pg_database WHERE datname = %s', (dbname,))
|
||||
if not cur.fetchone():
|
||||
conn.execute(f'CREATE DATABASE {dbname}')
|
||||
print(f'Created database {dbname}')
|
||||
else:
|
||||
print(f'Database {dbname} already exists')
|
||||
conn.close()
|
||||
" || echo "DB creation failed (non-fatal), continuing..."
|
||||
|
||||
echo "Running likes Alembic migrations..."
|
||||
if [ -d likes ]; then (cd likes && alembic upgrade head); else alembic upgrade head; fi
|
||||
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.flushdb()
|
||||
print('Redis cache cleared.')
|
||||
" || echo "Redis flush failed (non-fatal), continuing..."
|
||||
fi
|
||||
|
||||
# Start the app
|
||||
RELOAD_FLAG=""
|
||||
if [[ "${RELOAD:-}" == "true" ]]; then
|
||||
RELOAD_FLAG="--reload"
|
||||
echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..."
|
||||
else
|
||||
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
|
||||
fi
|
||||
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000} --workers ${WORKERS:-2} --keep-alive 75 ${RELOAD_FLAG}
|
||||
1
likes/models/__init__.py
Normal file
1
likes/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .like import Like
|
||||
36
likes/models/like.py
Normal file
36
likes/models/like.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Integer, String, DateTime, Index, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from shared.db.base import Base
|
||||
|
||||
|
||||
class Like(Base):
|
||||
__tablename__ = "likes"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "target_type", "target_slug",
|
||||
name="uq_likes_user_type_slug"),
|
||||
UniqueConstraint("user_id", "target_type", "target_id",
|
||||
name="uq_likes_user_type_id"),
|
||||
Index("ix_likes_target", "target_type", "target_slug"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
target_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
target_slug: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
target_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False,
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
9
likes/path_setup.py
Normal file
9
likes/path_setup.py
Normal file
@@ -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)
|
||||
6
likes/services/__init__.py
Normal file
6
likes/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Likes app service registration."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the likes app."""
|
||||
Reference in New Issue
Block a user