mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-23 06:33:49 +08:00
1669 lines
49 KiB
Markdown
1669 lines
49 KiB
Markdown
# 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.
|