# Spark Backend Account Reviews Sync Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the new `spark-store-backend` FastAPI + MySQL service for Flarum-backed login, app reviews/ratings, and default user app-list sync. **Architecture:** The backend validates Flarum access tokens, maps Flarum users to local users, signs Spark Store JWTs, and persists Spark Store-specific data in MySQL. The API uses synchronous FastAPI routes, SQLAlchemy ORM, Alembic migrations, and pytest API tests with a SQLite test database. **Tech Stack:** Python 3.11+, FastAPI, SQLAlchemy 2.x, Alembic, PyMySQL, Pydantic Settings, python-jose, httpx, pytest. --- ## File Structure Create a new sibling repository at `/home/spark/Desktop/shenmo-spark-store/spark-store-backend`. Backend files: - Create: `README.md` - project overview, setup, test commands, API summary. - Create: `.gitignore` - Python, virtualenv, test, and secret ignores. - Create: `.env.example` - safe configuration template. - Create: `pyproject.toml` - dependencies, pytest config, tooling config. - Create: `alembic.ini` - Alembic config. - Create: `alembic/env.py` - migration environment wired to app metadata. - Create: `alembic/versions/0001_initial.py` - initial MySQL schema. - Create: `app/main.py` - FastAPI app factory and router registration. - Create: `app/core/config.py` - environment settings. - Create: `app/core/security.py` - JWT creation and decoding. - Create: `app/db/session.py` - SQLAlchemy engine/session dependency. - Create: `app/db/base.py` - declarative base and model imports. - Create: `app/models/user.py` - `User` ORM model. - Create: `app/models/store_app.py` - `StoreApp` ORM model. - Create: `app/models/review.py` - `Review` ORM model. - Create: `app/models/app_list.py` - app-list ORM models. - Create: `app/schemas/auth.py` - auth request/response schemas. - Create: `app/schemas/review.py` - review request/response schemas. - Create: `app/schemas/app_list.py` - app-list request/response schemas. - Create: `app/services/flarum.py` - Flarum profile validation client. - Create: `app/api/deps.py` - auth and DB dependencies. - Create: `app/api/router.py` - API router aggregator. - Create: `app/api/routes/auth.py` - auth endpoints. - Create: `app/api/routes/reviews.py` - review and rating endpoints. - Create: `app/api/routes/app_lists.py` - app-list endpoints. - Create: `tests/conftest.py` - test app and database fixtures. - Create: `tests/test_auth.py` - Flarum auth tests. - Create: `tests/test_reviews.py` - review behavior tests. - Create: `tests/test_app_lists.py` - app-list sync tests. ## Task 1: Initialize Backend Repository **Files:** - Create: `/home/spark/Desktop/shenmo-spark-store/spark-store-backend/README.md` - Create: `/home/spark/Desktop/shenmo-spark-store/spark-store-backend/.gitignore` - [ ] **Step 1: Verify parent directory exists** Run: `ls "/home/spark/Desktop/shenmo-spark-store"` Expected: output includes `spark-store`. - [ ] **Step 2: Create repository directory** Run: `mkdir "/home/spark/Desktop/shenmo-spark-store/spark-store-backend"` Expected: command exits 0. - [ ] **Step 3: Initialize Git repository** Run: `git init` Workdir: `/home/spark/Desktop/shenmo-spark-store/spark-store-backend` Expected: output says an empty Git repository was initialized. - [ ] **Step 4: Write initial README** Create `README.md` with: ```markdown # Spark Store Backend Python backend for Spark Store account login, app reviews, ratings, and user app-list sync. The service uses the Spark forum Flarum instance as the identity provider and stores Spark Store-specific data in MySQL. ## Development ```bash python -m venv .venv . .venv/bin/activate pip install -e ".[dev]" pytest ``` ``` - [ ] **Step 5: Write `.gitignore`** Create `.gitignore` with: ```gitignore .venv/ __pycache__/ *.py[cod] .pytest_cache/ .coverage htmlcov/ .env *.sqlite3 dist/ build/ *.egg-info/ ``` - [ ] **Step 6: Commit initial repository** Run: ```bash git add README.md .gitignore git commit -m "first commit" git remote add origin https://gitee.com/momen_official/spark-store-backend.git ``` Expected: commit succeeds and `git remote -v` shows the Gitee origin. ## Task 2: Add Backend Project Skeleton **Files:** - Create: `pyproject.toml` - Create: `.env.example` - Create: `app/__init__.py` - Create: `app/main.py` - Create: `app/core/config.py` - Create: `app/api/router.py` - Test: `tests/test_health.py` - [ ] **Step 1: Write failing health test** Create `tests/test_health.py` with: ```python from fastapi.testclient import TestClient from app.main import create_app def test_health_returns_ok(): client = TestClient(create_app()) response = client.get("/health") assert response.status_code == 200 assert response.json() == {"status": "ok"} ``` - [ ] **Step 2: Run test and verify failure** Run: `pytest tests/test_health.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'app'`. - [ ] **Step 3: Add package config** Create `pyproject.toml` with: ```toml [build-system] requires = ["setuptools>=69"] build-backend = "setuptools.build_meta" [project] name = "spark-store-backend" version = "0.1.0" requires-python = ">=3.11" dependencies = [ "fastapi>=0.115,<1.0", "uvicorn[standard]>=0.30,<1.0", "sqlalchemy>=2.0,<3.0", "alembic>=1.13,<2.0", "pymysql>=1.1,<2.0", "pydantic-settings>=2.4,<3.0", "python-jose[cryptography]>=3.3,<4.0", "httpx>=0.27,<1.0", ] [project.optional-dependencies] dev = [ "pytest>=8.3,<9.0", "pytest-cov>=5.0,<6.0", ] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] ``` - [ ] **Step 4: Add settings** Create `.env.example` with: ```env APP_NAME="Spark Store Backend" DATABASE_URL="mysql+pymysql://spark:spark_password@127.0.0.1:3306/spark_store" JWT_SECRET_KEY="change-me-in-production" JWT_ALGORITHM="HS256" JWT_EXPIRE_MINUTES="10080" FLARUM_BASE_URL="https://bbs.spark-app.store" ``` Create `app/core/config.py` with: ```python from functools import lru_cache from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): app_name: str = "Spark Store Backend" database_url: str = "sqlite:///./spark-store-dev.sqlite3" jwt_secret_key: str = "dev-secret" jwt_algorithm: str = "HS256" jwt_expire_minutes: int = 10080 flarum_base_url: str = "https://bbs.spark-app.store" model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") @lru_cache def get_settings() -> Settings: return Settings() ``` - [ ] **Step 5: Add FastAPI app** Create `app/__init__.py` as an empty file. Create `app/api/router.py` with: ```python from fastapi import APIRouter api_router = APIRouter() ``` Create `app/main.py` with: ```python from fastapi import FastAPI from app.api.router import api_router from app.core.config import get_settings def create_app() -> FastAPI: settings = get_settings() app = FastAPI(title=settings.app_name) @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} app.include_router(api_router) return app app = create_app() ``` - [ ] **Step 6: Run test and verify pass** Run: `pytest tests/test_health.py -v` Expected: PASS. - [ ] **Step 7: Commit skeleton** Run: ```bash git add . git commit -m "feat: add FastAPI backend skeleton" ``` Expected: commit succeeds. ## Task 3: Add Database Models And Migration **Files:** - Create: `app/db/session.py` - Create: `app/db/base.py` - Create: `app/models/user.py` - Create: `app/models/store_app.py` - Create: `app/models/review.py` - Create: `app/models/app_list.py` - Create: `alembic.ini` - Create: `alembic/env.py` - Create: `alembic/versions/0001_initial.py` - Test: `tests/test_models.py` - [ ] **Step 1: Write failing model test** Create `tests/test_models.py` with: ```python from sqlalchemy import create_engine from sqlalchemy.orm import Session from app.db.base import Base from app.models.user import User from app.models.store_app import StoreApp from app.models.review import Review def test_review_model_persists_tagged_rating(): engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) with Session(engine) as session: user = User(flarum_user_id="123", username="momen", display_name="Momen") app = StoreApp( app_key="apm:amd64-apm:office:wps", pkgname="wps", origin="apm", store_arch="amd64-apm", category="office", latest_seen_version="1.0.0", ) session.add_all([user, app]) session.flush() review = Review( user_id=user.id, app_id=app.id, rating=5, content="Works well.", version="1.0.0", package_arch="amd64", client_arch="amd64", distro="deepin 25", origin="apm", category="office", ) session.add(review) session.commit() stored = session.query(Review).one() assert stored.rating == 5 assert stored.version == "1.0.0" assert stored.distro == "deepin 25" ``` - [ ] **Step 2: Run test and verify failure** Run: `pytest tests/test_models.py -v` Expected: FAIL with missing `app.db.base` or models. - [ ] **Step 3: Add database session and base** Create `app/db/session.py` with: ```python from collections.abc import Generator from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from app.core.config import get_settings settings = get_settings() engine = create_engine(settings.database_url, pool_pre_ping=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) def get_db() -> Generator[Session, None, None]: db = SessionLocal() try: yield db finally: db.close() ``` Create `app/db/base.py` with: ```python from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): pass from app.models.app_list import UserAppList, UserAppListItem # noqa: E402,F401 from app.models.review import Review # noqa: E402,F401 from app.models.store_app import StoreApp # noqa: E402,F401 from app.models.user import User # noqa: E402,F401 ``` - [ ] **Step 4: Add ORM models** Create `app/models/user.py` with: ```python from datetime import datetime, timezone from sqlalchemy import DateTime, String from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base def utcnow() -> datetime: return datetime.now(timezone.utc) class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) flarum_user_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) username: Mapped[str] = mapped_column(String(128), default="") display_name: Mapped[str] = mapped_column(String(128), default="") avatar_url: Mapped[str] = mapped_column(String(1024), default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) reviews = relationship("Review", back_populates="user") app_lists = relationship("UserAppList", back_populates="user") ``` Create `app/models/store_app.py` with: ```python from datetime import datetime, timezone from sqlalchemy import DateTime, String from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base def utcnow() -> datetime: return datetime.now(timezone.utc) class StoreApp(Base): __tablename__ = "apps" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) app_key: Mapped[str] = mapped_column(String(512), unique=True, index=True) pkgname: Mapped[str] = mapped_column(String(255), index=True) origin: Mapped[str] = mapped_column(String(16), index=True) store_arch: Mapped[str] = mapped_column(String(64), index=True) category: Mapped[str] = mapped_column(String(128), index=True) latest_seen_version: Mapped[str] = mapped_column(String(255), default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) reviews = relationship("Review", back_populates="app") ``` Create `app/models/review.py` with: ```python from datetime import datetime, timezone from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base def utcnow() -> datetime: return datetime.now(timezone.utc) class Review(Base): __tablename__ = "reviews" __table_args__ = ( CheckConstraint("rating >= 1 AND rating <= 5", name="ck_reviews_rating_range"), UniqueConstraint( "user_id", "app_id", "version", "package_arch", "client_arch", "distro", "origin", "category", name="uq_reviews_user_app_tags", ), Index("ix_reviews_app_filters", "app_id", "version", "package_arch", "client_arch", "distro", "origin", "category"), ) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) app_id: Mapped[int] = mapped_column(ForeignKey("apps.id"), index=True) rating: Mapped[int] content: Mapped[str] = mapped_column(Text) version: Mapped[str] = mapped_column(String(255), default="unknown") package_arch: Mapped[str] = mapped_column(String(64), default="unknown") client_arch: Mapped[str] = mapped_column(String(64), default="unknown") distro: Mapped[str] = mapped_column(String(255), default="unknown") origin: Mapped[str] = mapped_column(String(16), default="") category: Mapped[str] = mapped_column(String(128), default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) user = relationship("User", back_populates="reviews") app = relationship("StoreApp", back_populates="reviews") ``` Create `app/models/app_list.py` with: ```python from datetime import datetime, timezone from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base def utcnow() -> datetime: return datetime.now(timezone.utc) class UserAppList(Base): __tablename__ = "user_app_lists" __table_args__ = (UniqueConstraint("user_id", "snapshot_name", name="uq_user_app_lists_default"),) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) snapshot_name: Mapped[str] = mapped_column(String(128), default="default") client_arch: Mapped[str] = mapped_column(String(64), default="unknown") distro: Mapped[str] = mapped_column(String(255), default="unknown") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) user = relationship("User", back_populates="app_lists") items = relationship("UserAppListItem", back_populates="app_list", cascade="all, delete-orphan") class UserAppListItem(Base): __tablename__ = "user_app_list_items" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) list_id: Mapped[int] = mapped_column(ForeignKey("user_app_lists.id"), index=True) pkgname: Mapped[str] = mapped_column(String(255), index=True) origin: Mapped[str] = mapped_column(String(16), index=True) category: Mapped[str] = mapped_column(String(128), index=True) version: Mapped[str] = mapped_column(String(255), default="") package_arch: Mapped[str] = mapped_column(String(64), default="unknown") app_name: Mapped[str] = mapped_column(String(255), default="") icon_url: Mapped[str] = mapped_column(String(1024), default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) app_list = relationship("UserAppList", back_populates="items") ``` - [ ] **Step 5: Run model test and verify pass** Run: `pytest tests/test_models.py -v` Expected: PASS. - [ ] **Step 6: Add Alembic migration** Create `alembic.ini` with: ```ini [alembic] script_location = alembic prepend_sys_path = . sqlalchemy.url = sqlite:///./spark-store-dev.sqlite3 [loggers] keys = root,sqlalchemy,alembic [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [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 datefmt = %H:%M:%S ``` Create `alembic/env.py` with: ```python from logging.config import fileConfig from alembic import context from sqlalchemy import engine_from_config, pool from app.core.config import get_settings from app.db.base import Base config = context.config config.set_main_option("sqlalchemy.url", get_settings().database_url) if config.config_file_name is not None: fileConfig(config.config_file_name) target_metadata = Base.metadata def run_migrations_offline() -> None: url = config.get_main_option("sqlalchemy.url") context.configure(url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True) with context.begin_transaction(): context.run_migrations() def run_migrations_online() -> None: connectable = engine_from_config( config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool, ) with connectable.connect() as connection: context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ``` Create `alembic/versions/0001_initial.py` with: ```python from collections.abc import Sequence import sqlalchemy as sa from alembic import op revision: str = "0001_initial" down_revision: str | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: op.create_table( "users", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("flarum_user_id", sa.String(length=64), nullable=False), sa.Column("username", sa.String(length=128), nullable=False), sa.Column("display_name", sa.String(length=128), nullable=False), sa.Column("avatar_url", sa.String(length=1024), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.UniqueConstraint("flarum_user_id"), ) op.create_index("ix_users_flarum_user_id", "users", ["flarum_user_id"]) op.create_table( "apps", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("app_key", sa.String(length=512), nullable=False), sa.Column("pkgname", sa.String(length=255), nullable=False), sa.Column("origin", sa.String(length=16), nullable=False), sa.Column("store_arch", sa.String(length=64), nullable=False), sa.Column("category", sa.String(length=128), nullable=False), sa.Column("latest_seen_version", sa.String(length=255), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.UniqueConstraint("app_key"), ) op.create_index("ix_apps_app_key", "apps", ["app_key"]) op.create_index("ix_apps_pkgname", "apps", ["pkgname"]) op.create_index("ix_apps_origin", "apps", ["origin"]) op.create_index("ix_apps_store_arch", "apps", ["store_arch"]) op.create_index("ix_apps_category", "apps", ["category"]) op.create_table( "reviews", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), sa.Column("app_id", sa.Integer(), sa.ForeignKey("apps.id"), nullable=False), sa.Column("rating", sa.Integer(), nullable=False), sa.Column("content", sa.Text(), nullable=False), sa.Column("version", sa.String(length=255), nullable=False), sa.Column("package_arch", sa.String(length=64), nullable=False), sa.Column("client_arch", sa.String(length=64), nullable=False), sa.Column("distro", sa.String(length=255), nullable=False), sa.Column("origin", sa.String(length=16), nullable=False), sa.Column("category", sa.String(length=128), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.CheckConstraint("rating >= 1 AND rating <= 5", name="ck_reviews_rating_range"), sa.UniqueConstraint("user_id", "app_id", "version", "package_arch", "client_arch", "distro", "origin", "category", name="uq_reviews_user_app_tags"), ) op.create_index("ix_reviews_user_id", "reviews", ["user_id"]) op.create_index("ix_reviews_app_id", "reviews", ["app_id"]) op.create_index("ix_reviews_app_filters", "reviews", ["app_id", "version", "package_arch", "client_arch", "distro", "origin", "category"]) op.create_table( "user_app_lists", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), sa.Column("snapshot_name", sa.String(length=128), nullable=False), sa.Column("client_arch", sa.String(length=64), nullable=False), sa.Column("distro", sa.String(length=255), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.UniqueConstraint("user_id", "snapshot_name", name="uq_user_app_lists_default"), ) op.create_index("ix_user_app_lists_user_id", "user_app_lists", ["user_id"]) op.create_table( "user_app_list_items", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("list_id", sa.Integer(), sa.ForeignKey("user_app_lists.id"), nullable=False), sa.Column("pkgname", sa.String(length=255), nullable=False), sa.Column("origin", sa.String(length=16), nullable=False), sa.Column("category", sa.String(length=128), nullable=False), sa.Column("version", sa.String(length=255), nullable=False), sa.Column("package_arch", sa.String(length=64), nullable=False), sa.Column("app_name", sa.String(length=255), nullable=False), sa.Column("icon_url", sa.String(length=1024), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), ) op.create_index("ix_user_app_list_items_list_id", "user_app_list_items", ["list_id"]) op.create_index("ix_user_app_list_items_pkgname", "user_app_list_items", ["pkgname"]) op.create_index("ix_user_app_list_items_origin", "user_app_list_items", ["origin"]) op.create_index("ix_user_app_list_items_category", "user_app_list_items", ["category"]) def downgrade() -> None: op.drop_table("user_app_list_items") op.drop_table("user_app_lists") op.drop_table("reviews") op.drop_table("apps") op.drop_table("users") ``` Run: `alembic upgrade head` Expected: local development database receives revision `0001_initial`. - [ ] **Step 7: Commit database layer** Run: ```bash git add app/db app/models alembic.ini alembic tests/test_models.py git commit -m "feat: add database schema" ``` Expected: commit succeeds. ## Task 4: Implement Flarum Auth And JWT **Files:** - Create: `app/core/security.py` - Create: `app/services/flarum.py` - Create: `app/schemas/auth.py` - Create: `app/api/deps.py` - Create: `app/api/routes/auth.py` - Modify: `app/api/router.py` - Test: `tests/conftest.py` - Test: `tests/test_auth.py` - [ ] **Step 1: Write failing auth tests** Create `tests/conftest.py` with a SQLite test database, dependency override for `get_db`, and `TestClient(create_app())` fixture. Create `tests/test_auth.py` with: ```python from app.models.user import User def test_auth_flarum_creates_user_and_returns_jwt(client, monkeypatch): async def fake_fetch_profile(token: str, user_id: str): assert token == "forum-token" assert user_id == "123" return { "flarum_user_id": "123", "username": "momen", "display_name": "Momen", "avatar_url": "https://bbs.spark-app.store/avatar.png", } monkeypatch.setattr("app.api.routes.auth.fetch_flarum_profile", fake_fetch_profile) response = client.post("/auth/flarum", json={"flarum_user_id": "123", "flarum_token": "forum-token"}) assert response.status_code == 200 data = response.json() assert data["token_type"] == "bearer" assert data["access_token"] assert data["user"]["display_name"] == "Momen" def test_me_requires_jwt(client): response = client.get("/me") assert response.status_code == 401 ``` - [ ] **Step 2: Run auth tests and verify failure** Run: `pytest tests/test_auth.py -v` Expected: FAIL with missing auth routes or schemas. - [ ] **Step 3: Add JWT helpers** Create `app/core/security.py` with: ```python from datetime import datetime, timedelta, timezone from jose import JWTError, jwt from app.core.config import get_settings def create_access_token(subject: str) -> str: settings = get_settings() expires = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) payload = {"sub": subject, "exp": expires} return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) def decode_access_token(token: str) -> str | None: settings = get_settings() try: payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]) except JWTError: return None subject = payload.get("sub") return subject if isinstance(subject, str) else None ``` - [ ] **Step 4: Add auth schemas and Flarum service** Create `app/schemas/auth.py` with: ```python from pydantic import BaseModel, Field class FlarumAuthRequest(BaseModel): flarum_user_id: str = Field(min_length=1, max_length=64) flarum_token: str = Field(min_length=1) class UserPublic(BaseModel): id: int flarum_user_id: str username: str display_name: str avatar_url: str model_config = {"from_attributes": True} class AuthResponse(BaseModel): access_token: str token_type: str = "bearer" user: UserPublic ``` Create `app/services/flarum.py` with: ```python import httpx from app.core.config import get_settings async def fetch_flarum_profile(token: str, user_id: str) -> dict[str, str]: settings = get_settings() url = f"{settings.flarum_base_url.rstrip('/')}/api/users/{user_id}" async with httpx.AsyncClient(timeout=10) as client: response = await client.get(url, headers={"Authorization": f"Token {token}"}) if response.status_code != 200: raise ValueError("invalid flarum token") data = response.json()["data"] attrs = data.get("attributes", {}) return { "flarum_user_id": str(data.get("id", user_id)), "username": attrs.get("username") or "", "display_name": attrs.get("displayName") or attrs.get("username") or "", "avatar_url": attrs.get("avatarUrl") or "", } ``` - [ ] **Step 5: Add auth dependency and routes** Create `app/api/deps.py` with: ```python from typing import Annotated from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session from app.core.security import decode_access_token from app.db.session import get_db from app.models.user import User bearer = HTTPBearer(auto_error=False) def get_current_user( credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], db: Annotated[Session, Depends(get_db)], ) -> User: if credentials is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing bearer token") subject = decode_access_token(credentials.credentials) if subject is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid bearer token") user = db.get(User, int(subject)) if subject.isdigit() else None if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") return user ``` Create `app/api/routes/auth.py` with: ```python from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session from app.api.deps import get_current_user from app.core.security import create_access_token from app.db.session import get_db from app.models.user import User from app.schemas.auth import AuthResponse, FlarumAuthRequest, UserPublic from app.services.flarum import fetch_flarum_profile router = APIRouter(tags=["auth"]) @router.post("/auth/flarum", response_model=AuthResponse) async def auth_flarum(payload: FlarumAuthRequest, db: Session = Depends(get_db)) -> AuthResponse: try: profile = await fetch_flarum_profile(payload.flarum_token, payload.flarum_user_id) except ValueError as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid flarum token") from exc user = db.scalar(select(User).where(User.flarum_user_id == profile["flarum_user_id"])) if user is None: user = User(**profile) db.add(user) else: user.username = profile["username"] user.display_name = profile["display_name"] user.avatar_url = profile["avatar_url"] db.commit() db.refresh(user) return AuthResponse(access_token=create_access_token(str(user.id)), user=UserPublic.model_validate(user)) @router.get("/me", response_model=UserPublic) def me(user: User = Depends(get_current_user)) -> User: return user ``` Modify `app/api/router.py` to include: ```python from fastapi import APIRouter from app.api.routes import auth api_router = APIRouter() api_router.include_router(auth.router) ``` - [ ] **Step 6: Run auth tests and verify pass** Run: `pytest tests/test_auth.py -v` Expected: PASS. - [ ] **Step 7: Commit auth** Run: ```bash git add app tests/test_auth.py tests/conftest.py git commit -m "feat: add Flarum auth" ``` Expected: commit succeeds. ## Task 5: Implement Reviews And Rating Summary **Files:** - Create: `app/schemas/review.py` - Create: `app/api/routes/reviews.py` - Modify: `app/api/router.py` - Test: `tests/test_reviews.py` - [ ] **Step 1: Write failing review tests** Create `tests/test_reviews.py` with: ```python from app.core.security import create_access_token from app.models.user import User def seed_user(db_session): user = User(flarum_user_id="123", username="momen", display_name="Momen", avatar_url="") db_session.add(user) db_session.commit() db_session.refresh(user) return user def auth_headers(user: User) -> dict[str, str]: return {"Authorization": f"Bearer {create_access_token(str(user.id))}"} def test_create_review_and_rating_summary(client, db_session): user = seed_user(db_session) response = client.post( "/apps/apm:amd64-apm:office:wps/reviews", headers=auth_headers(user), json={ "rating": 5, "content": "Works well.", "tags": { "origin": "apm", "category": "office", "pkgname": "wps", "version": "1.0.0", "package_arch": "amd64", "client_arch": "amd64", "distro": "deepin 25", }, }, ) assert response.status_code == 200 assert response.json()["rating"] == 5 summary = client.get("/apps/apm:amd64-apm:office:wps/rating-summary") assert summary.status_code == 200 assert summary.json()["average_rating"] == 5.0 assert summary.json()["review_count"] == 1 assert summary.json()["star_counts"]["5"] == 1 def test_reviews_filter_by_version(client, db_session): user = seed_user(db_session) headers = auth_headers(user) for version in ["1.0.0", "2.0.0"]: response = client.post( "/apps/apm:amd64-apm:office:wps/reviews", headers=headers, json={ "rating": 4, "content": f"Version {version}", "tags": { "origin": "apm", "category": "office", "pkgname": "wps", "version": version, "package_arch": "amd64", "client_arch": "amd64", "distro": "deepin 25", }, }, ) assert response.status_code == 200 response = client.get("/apps/apm:amd64-apm:office:wps/reviews", params={"version": "2.0.0"}) assert response.status_code == 200 assert len(response.json()) == 1 assert response.json()[0]["version"] == "2.0.0" ``` - [ ] **Step 2: Run review tests and verify failure** Run: `pytest tests/test_reviews.py -v` Expected: FAIL with `404 Not Found` for review endpoints. - [ ] **Step 3: Add review schemas** Create `app/schemas/review.py` with: ```python from datetime import datetime from pydantic import BaseModel, Field class ReviewTags(BaseModel): origin: str = Field(min_length=1, max_length=16) category: str = Field(min_length=1, max_length=128) pkgname: str = Field(min_length=1, max_length=255) version: str = Field(default="unknown", max_length=255) package_arch: str = Field(default="unknown", max_length=64) client_arch: str = Field(default="unknown", max_length=64) distro: str = Field(default="unknown", max_length=255) class ReviewCreate(BaseModel): rating: int = Field(ge=1, le=5) content: str = Field(min_length=1, max_length=5000) tags: ReviewTags class ReviewPublic(BaseModel): id: int rating: int content: str version: str package_arch: str client_arch: str distro: str origin: str category: str created_at: datetime updated_at: datetime user_display_name: str user_avatar_url: str class RatingSummary(BaseModel): average_rating: float review_count: int star_counts: dict[int, int] ``` - [ ] **Step 4: Add review routes** Create `app/api/routes/reviews.py` with: ```python from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.orm import Session from app.api.deps import get_current_user from app.db.session import get_db from app.models.review import Review from app.models.store_app import StoreApp from app.models.user import User from app.schemas.review import RatingSummary, ReviewCreate, ReviewPublic router = APIRouter(tags=["reviews"]) def parse_app_key(app_key: str) -> dict[str, str]: parts = app_key.split(":", 3) if len(parts) != 4: raise HTTPException(status_code=422, detail="invalid app key") origin, store_arch, category, pkgname = parts return {"origin": origin, "store_arch": store_arch, "category": category, "pkgname": pkgname} def get_or_create_app(db: Session, app_key: str) -> StoreApp: parsed = parse_app_key(app_key) app = db.scalar(select(StoreApp).where(StoreApp.app_key == app_key)) if app is not None: return app app = StoreApp(app_key=app_key, latest_seen_version="", **parsed) db.add(app) db.flush() return app def to_public(review: Review) -> ReviewPublic: return ReviewPublic( id=review.id, rating=review.rating, content=review.content, version=review.version, package_arch=review.package_arch, client_arch=review.client_arch, distro=review.distro, origin=review.origin, category=review.category, created_at=review.created_at, updated_at=review.updated_at, user_display_name=review.user.display_name, user_avatar_url=review.user.avatar_url, ) @router.post("/apps/{app_key}/reviews", response_model=ReviewPublic) def upsert_review( app_key: str, payload: ReviewCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> ReviewPublic: app = get_or_create_app(db, app_key) tags = payload.tags review = db.scalar( select(Review).where( Review.user_id == user.id, Review.app_id == app.id, Review.version == tags.version, Review.package_arch == tags.package_arch, Review.client_arch == tags.client_arch, Review.distro == tags.distro, Review.origin == tags.origin, Review.category == tags.category, ) ) if review is None: review = Review(user_id=user.id, app_id=app.id) db.add(review) review.rating = payload.rating review.content = payload.content.strip() review.version = tags.version or "unknown" review.package_arch = tags.package_arch or "unknown" review.client_arch = tags.client_arch or "unknown" review.distro = tags.distro or "unknown" review.origin = tags.origin review.category = tags.category app.latest_seen_version = review.version db.commit() db.refresh(review) return to_public(review) @router.get("/apps/{app_key}/reviews", response_model=list[ReviewPublic]) def list_reviews( app_key: str, version: str | None = None, package_arch: str | None = None, client_arch: str | None = None, distro: str | None = None, origin: str | None = None, category: str | None = None, rating: int | None = Query(default=None, ge=1, le=5), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), db: Session = Depends(get_db), ) -> list[ReviewPublic]: app = db.scalar(select(StoreApp).where(StoreApp.app_key == app_key)) if app is None: return [] stmt = select(Review).where(Review.app_id == app.id).order_by(Review.updated_at.desc()) filters = { Review.version: version, Review.package_arch: package_arch, Review.client_arch: client_arch, Review.distro: distro, Review.origin: origin, Review.category: category, Review.rating: rating, } for column, value in filters.items(): if value is not None: stmt = stmt.where(column == value) reviews = db.scalars(stmt.offset((page - 1) * page_size).limit(page_size)).all() return [to_public(review) for review in reviews] @router.get("/apps/{app_key}/rating-summary", response_model=RatingSummary) def rating_summary(app_key: str, db: Session = Depends(get_db)) -> RatingSummary: app = db.scalar(select(StoreApp).where(StoreApp.app_key == app_key)) if app is None: return RatingSummary(average_rating=0.0, review_count=0, star_counts={star: 0 for star in range(1, 6)}) rows = db.execute(select(Review.rating, func.count(Review.id)).where(Review.app_id == app.id).group_by(Review.rating)).all() star_counts = {star: 0 for star in range(1, 6)} total = 0 count = 0 for rating, rating_count in rows: star_counts[int(rating)] = int(rating_count) total += int(rating) * int(rating_count) count += int(rating_count) average = round(total / count, 2) if count else 0.0 return RatingSummary(average_rating=average, review_count=count, star_counts=star_counts) ``` - [ ] **Step 5: Include routes** Modify `app/api/router.py` to include: ```python from app.api.routes import auth, reviews api_router = APIRouter() api_router.include_router(auth.router) api_router.include_router(reviews.router) ``` - [ ] **Step 6: Run review tests and verify pass** Run: `pytest tests/test_reviews.py -v` Expected: PASS. - [ ] **Step 7: Commit reviews** Run: ```bash git add app tests/test_reviews.py git commit -m "feat: add app reviews and ratings" ``` Expected: commit succeeds. ## Task 6: Implement App-List Sync **Files:** - Create: `app/schemas/app_list.py` - Create: `app/api/routes/app_lists.py` - Modify: `app/api/router.py` - Test: `tests/test_app_lists.py` - [ ] **Step 1: Write failing app-list tests** Create `tests/test_app_lists.py` with: ```python from app.core.security import create_access_token from app.models.user import User def seed_user(db_session): user = User(flarum_user_id="123", username="momen", display_name="Momen", avatar_url="") db_session.add(user) db_session.commit() db_session.refresh(user) return user def auth_headers(user: User) -> dict[str, str]: return {"Authorization": f"Bearer {create_access_token(str(user.id))}"} def test_put_and_get_default_app_list(client, db_session): user = seed_user(db_session) response = client.put( "/me/app-list", headers=auth_headers(user), json={ "client_arch": "amd64", "distro": "deepin 25", "items": [ { "pkgname": "spark-notes", "origin": "spark", "category": "office", "version": "1.0.0", "package_arch": "amd64", "app_name": "Spark Notes", "icon_url": "https://example.com/icon.png", }, { "pkgname": "wps", "origin": "apm", "category": "office", "version": "2.0.0", "package_arch": "amd64", "app_name": "WPS", "icon_url": "", }, ], }, ) assert response.status_code == 200 assert len(response.json()["items"]) == 2 fetched = client.get("/me/app-list", headers=auth_headers(user)) assert fetched.status_code == 200 assert fetched.json()["client_arch"] == "amd64" assert [item["pkgname"] for item in fetched.json()["items"]] == ["spark-notes", "wps"] def test_put_default_app_list_replaces_previous_items(client, db_session): user = seed_user(db_session) headers = auth_headers(user) first_payload = { "client_arch": "amd64", "distro": "deepin 25", "items": [ {"pkgname": "one", "origin": "spark", "category": "tools", "version": "1", "package_arch": "amd64", "app_name": "One", "icon_url": ""}, {"pkgname": "two", "origin": "apm", "category": "tools", "version": "1", "package_arch": "amd64", "app_name": "Two", "icon_url": ""}, ], } second_payload = { "client_arch": "amd64", "distro": "deepin 25", "items": [ {"pkgname": "three", "origin": "spark", "category": "tools", "version": "1", "package_arch": "amd64", "app_name": "Three", "icon_url": ""}, ], } assert client.put("/me/app-list", headers=headers, json=first_payload).status_code == 200 assert client.put("/me/app-list", headers=headers, json=second_payload).status_code == 200 fetched = client.get("/me/app-list", headers=headers) assert [item["pkgname"] for item in fetched.json()["items"]] == ["three"] ``` - [ ] **Step 2: Run app-list tests and verify failure** Run: `pytest tests/test_app_lists.py -v` Expected: FAIL with `404 Not Found` for `/me/app-list`. - [ ] **Step 3: Add app-list schemas** Create `app/schemas/app_list.py` with: ```python from datetime import datetime from pydantic import BaseModel, Field class AppListItemIn(BaseModel): pkgname: str = Field(min_length=1, max_length=255) origin: str = Field(min_length=1, max_length=16) category: str = Field(min_length=1, max_length=128) version: str = Field(default="", max_length=255) package_arch: str = Field(default="unknown", max_length=64) app_name: str = Field(default="", max_length=255) icon_url: str = Field(default="", max_length=1024) class AppListPut(BaseModel): client_arch: str = Field(default="unknown", max_length=64) distro: str = Field(default="unknown", max_length=255) items: list[AppListItemIn] class AppListItemOut(AppListItemIn): id: int model_config = {"from_attributes": True} class AppListOut(BaseModel): snapshot_name: str client_arch: str distro: str updated_at: datetime items: list[AppListItemOut] ``` - [ ] **Step 4: Add app-list routes** Create `app/api/routes/app_lists.py` with: ```python from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.orm import Session from app.api.deps import get_current_user from app.db.session import get_db from app.models.app_list import UserAppList, UserAppListItem from app.models.user import User from app.schemas.app_list import AppListOut, AppListPut router = APIRouter(tags=["app-lists"]) def to_out(app_list: UserAppList) -> AppListOut: return AppListOut( snapshot_name=app_list.snapshot_name, client_arch=app_list.client_arch, distro=app_list.distro, updated_at=app_list.updated_at, items=list(app_list.items), ) @router.get("/me/app-list", response_model=AppListOut | None) def get_app_list( db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> AppListOut | None: app_list = db.scalar( select(UserAppList).where( UserAppList.user_id == user.id, UserAppList.snapshot_name == "default", ) ) return to_out(app_list) if app_list else None @router.put("/me/app-list", response_model=AppListOut) def put_app_list( payload: AppListPut, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> AppListOut: app_list = db.scalar( select(UserAppList).where( UserAppList.user_id == user.id, UserAppList.snapshot_name == "default", ) ) if app_list is None: app_list = UserAppList(user_id=user.id, snapshot_name="default") db.add(app_list) db.flush() app_list.client_arch = payload.client_arch app_list.distro = payload.distro app_list.items.clear() for item in payload.items: app_list.items.append(UserAppListItem(**item.model_dump())) db.commit() db.refresh(app_list) return to_out(app_list) ``` - [ ] **Step 5: Include routes** Modify `app/api/router.py` to include: ```python from app.api.routes import app_lists, auth, reviews api_router = APIRouter() api_router.include_router(auth.router) api_router.include_router(reviews.router) api_router.include_router(app_lists.router) ``` - [ ] **Step 6: Run app-list tests and verify pass** Run: `pytest tests/test_app_lists.py -v` Expected: PASS. - [ ] **Step 7: Commit app-list sync** Run: ```bash git add app tests/test_app_lists.py git commit -m "feat: add app list sync api" ``` Expected: commit succeeds. ## Task 7: Final Backend Verification And Push **Files:** - Modify: `README.md` - [ ] **Step 1: Document run commands** Add this section to `README.md`: ```markdown ## Run ```bash uvicorn app.main:app --reload ``` ## Verify ```bash pytest alembic upgrade head ``` ## API Groups - `POST /auth/flarum` - `GET /me` - `GET /apps/{app_key}/rating-summary` - `GET /apps/{app_key}/reviews` - `POST /apps/{app_key}/reviews` - `GET /me/app-list` - `PUT /me/app-list` ``` - [ ] **Step 2: Run full backend tests** Run: `pytest -v` Expected: all tests PASS. - [ ] **Step 3: Verify migration command** Run: `alembic upgrade head` Expected: command exits 0. - [ ] **Step 4: Commit docs** Run: ```bash git add README.md git commit -m "docs: add backend run instructions" ``` Expected: commit succeeds if README changed. - [ ] **Step 5: Push backend repository** Run: `git push -u origin master` Expected: push succeeds if credentials and remote permissions are available. If authentication fails, report the exact Git error and leave the local repository intact. ## Self-Review Checklist Spec coverage: - Flarum token validation: Task 4. - Backend JWT: Task 4. - Review/rating storage and filtering foundation: Task 5. - Default app-list sync: Task 6. - MySQL schema and migrations: Task 3. - New Git repository and remote: Task 1. Placeholder scan: - The plan has no deferred implementation sections and no placeholder tasks. Type consistency: - User id is local integer `users.id` inside JWT `sub`. - Flarum user id remains string `flarum_user_id`. - App identity uses string `app_key` in all review routes.