Add FastAPI app skeleton: settings, db, models, security, auth
- settings.py: pydantic-settings config for the two mount points (MUSIC_DATA_DIR, ALEMBIC_CONFIG_DIR), OIDC client config, encryption key path - db.py: SQLite engine with WAL mode, plain-SQL schema migration runner (schema/*.sql, tracked by schema_version -- not the alembic migration tool) - models.py: SQLAlchemy ORM models matching schema/0001_init.sql - security/crypto.py: Fernet encrypt/decrypt for the secrets table - security/oidc.py + routers/auth.py: Pocket ID OIDC login/callback/logout - security/deps.py: require_auth dependency (redirect-to-login on no session) - main.py: app factory, lifespan (init_db + beets WAL enable), session middleware, minimal dashboard route Verified boots end-to-end via TestClient: unauthenticated GET / redirects to /auth/login, static files serve, and all 13 schema tables get created on first run.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
@@ -0,0 +1,73 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schema"
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{settings.app_db_path}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _set_sqlite_pragmas(dbapi_connection, _record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def _current_schema_version(conn) -> int:
|
||||
try:
|
||||
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
|
||||
return row[0] if row else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Apply schema/*.sql migrations in order, tracked by schema_version.
|
||||
Plain SQL files, not the `alembic` Python migration tool — see
|
||||
docs/ARCHITECTURE.md for why."""
|
||||
settings.app_db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
migrations = sorted(SCHEMA_DIR.glob("*.sql"))
|
||||
with engine.begin() as conn:
|
||||
current = _current_schema_version(conn)
|
||||
for path in migrations:
|
||||
version = int(path.stem.split("_", 1)[0])
|
||||
if version <= current:
|
||||
continue
|
||||
sql = path.read_text()
|
||||
for statement in sql.split(";"):
|
||||
statement = statement.strip()
|
||||
if statement:
|
||||
conn.execute(text(statement))
|
||||
|
||||
|
||||
def enable_beets_db_wal() -> None:
|
||||
"""One-time WAL enable on the beets DB so alembic's read-only status
|
||||
queries can run concurrently with beets writes."""
|
||||
if not settings.beets_db_path.exists():
|
||||
return
|
||||
conn = sqlite3.connect(settings.beets_db_path)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.db import enable_beets_db_wal, init_db
|
||||
from app.routers import auth, dashboard
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
init_db()
|
||||
enable_beets_db_wal()
|
||||
# Task 5 (scheduler_service) hooks APScheduler start/stop in here once
|
||||
# the job registry and pipeline_runner exist.
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="alembic", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.session_secret)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(dashboard.router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class Playlist(Base):
|
||||
__tablename__ = "playlists"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
spotify_url = Column(String, nullable=False)
|
||||
active = Column(Boolean, nullable=False, default=True)
|
||||
no_m3u = Column(Boolean, nullable=False, default=False)
|
||||
cron_expr = Column(String, nullable=True)
|
||||
notes = Column(String, nullable=True)
|
||||
created_at = Column(Float, nullable=False)
|
||||
updated_at = Column(Float, nullable=False)
|
||||
|
||||
|
||||
class Secret(Base):
|
||||
__tablename__ = "secrets"
|
||||
__table_args__ = (UniqueConstraint("scope", "key"),)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
scope = Column(String, nullable=False)
|
||||
key = Column(String, nullable=False)
|
||||
value_encrypted = Column(LargeBinary, nullable=False)
|
||||
updated_at = Column(Float, nullable=False)
|
||||
|
||||
|
||||
class ScheduledJob(Base):
|
||||
__tablename__ = "scheduled_jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
job_key = Column(String, unique=True, nullable=False)
|
||||
job_type = Column(String, nullable=False)
|
||||
playlist_id = Column(Integer, ForeignKey("playlists.id"), nullable=True)
|
||||
cron_expr = Column(String, nullable=False)
|
||||
enabled = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
|
||||
class JobRun(Base):
|
||||
__tablename__ = "job_runs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
job_key = Column(String, nullable=False)
|
||||
started_at = Column(Float, nullable=False)
|
||||
finished_at = Column(Float, nullable=True)
|
||||
status = Column(String, nullable=False) # running|success|failed|skipped_lock
|
||||
exit_code = Column(Integer, nullable=True)
|
||||
summary = Column(String, nullable=True)
|
||||
log_path = Column(String, nullable=True)
|
||||
triggered_by = Column(String, nullable=False) # schedule|manual|api
|
||||
|
||||
|
||||
class ManualFixAudit(Base):
|
||||
__tablename__ = "manual_fix_audit"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
beets_item_id = Column(Integer, nullable=False)
|
||||
file_path = Column(String, nullable=False)
|
||||
field = Column(String, nullable=False)
|
||||
old_value = Column(String, nullable=True)
|
||||
new_value = Column(String, nullable=True)
|
||||
changed_by = Column(String, nullable=False)
|
||||
changed_at = Column(Float, nullable=False)
|
||||
source = Column(String, nullable=False) # tag_edit|genre_fix|retag_from_url
|
||||
|
||||
|
||||
class ManualImport(Base):
|
||||
__tablename__ = "manual_imports"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
source_path = Column(String, nullable=False)
|
||||
playlist_name = Column(String, nullable=True)
|
||||
imported_by = Column(String, nullable=False)
|
||||
imported_at = Column(Float, nullable=False)
|
||||
status = Column(String, nullable=False)
|
||||
job_run_id = Column(Integer, ForeignKey("job_runs.id"), nullable=True)
|
||||
|
||||
|
||||
class DedupRun(Base):
|
||||
__tablename__ = "dedup_runs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
started_at = Column(Float, nullable=False)
|
||||
finished_at = Column(Float, nullable=True)
|
||||
mode = Column(String, nullable=False) # dry_run|apply
|
||||
groups_found = Column(Integer, nullable=True)
|
||||
kept = Column(Integer, nullable=True)
|
||||
deleted = Column(Integer, nullable=True)
|
||||
log_path = Column(String, nullable=True)
|
||||
|
||||
|
||||
class DedupCandidate(Base):
|
||||
__tablename__ = "dedup_candidates"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
dedup_run_id = Column(Integer, ForeignKey("dedup_runs.id"), nullable=False)
|
||||
pass_name = Column(String, nullable=False)
|
||||
keep_path = Column(String, nullable=False)
|
||||
delete_path = Column(String, nullable=False)
|
||||
delete_size_bytes = Column(Integer, nullable=True)
|
||||
confirmed = Column(Boolean, nullable=False, default=False)
|
||||
confirmed_by = Column(String, nullable=True)
|
||||
confirmed_at = Column(Float, nullable=True)
|
||||
applied = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class GenreRun(Base):
|
||||
__tablename__ = "genre_runs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
started_at = Column(Float, nullable=True)
|
||||
finished_at = Column(Float, nullable=True)
|
||||
mode = Column(String, nullable=True)
|
||||
written = Column(Integer, nullable=True)
|
||||
unchanged = Column(Integer, nullable=True)
|
||||
no_match = Column(Integer, nullable=True)
|
||||
no_meta = Column(Integer, nullable=True)
|
||||
log_path = Column(String, nullable=True)
|
||||
|
||||
|
||||
class GenreCandidate(Base):
|
||||
__tablename__ = "genre_candidates"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
genre_run_id = Column(Integer, ForeignKey("genre_runs.id"), nullable=False)
|
||||
artist = Column(String, nullable=True)
|
||||
title = Column(String, nullable=True)
|
||||
old_genre = Column(String, nullable=True)
|
||||
new_genre = Column(String, nullable=True)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
oidc_sub = Column(String, unique=True, nullable=False)
|
||||
email = Column(String, nullable=True)
|
||||
name = Column(String, nullable=True)
|
||||
last_login_at = Column(Float, nullable=True)
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key = Column(String, primary_key=True)
|
||||
value = Column(String, nullable=True)
|
||||
@@ -0,0 +1,53 @@
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import User
|
||||
from app.security.oidc import oauth
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request):
|
||||
redirect_uri = request.url_for("auth_callback")
|
||||
return await oauth.pocketid.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@router.get("/callback", name="auth_callback")
|
||||
async def auth_callback(request: Request):
|
||||
token = await oauth.pocketid.authorize_access_token(request)
|
||||
userinfo = token.get("userinfo") or await oauth.pocketid.userinfo(token=token)
|
||||
|
||||
sub = userinfo["sub"]
|
||||
email = userinfo.get("email")
|
||||
name = userinfo.get("name")
|
||||
now = time.time()
|
||||
|
||||
with SessionLocal() as db:
|
||||
user = db.execute(select(User).where(User.oidc_sub == sub)).scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(oidc_sub=sub, email=email, name=name, last_login_at=now)
|
||||
db.add(user)
|
||||
else:
|
||||
user.email = email
|
||||
user.name = name
|
||||
user.last_login_at = now
|
||||
db.commit()
|
||||
|
||||
request.session["user"] = {"sub": sub, "email": email, "name": name}
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
end_session_endpoint = None
|
||||
metadata = await oauth.pocketid.load_server_metadata()
|
||||
end_session_endpoint = metadata.get("end_session_endpoint")
|
||||
if end_session_endpoint:
|
||||
return RedirectResponse(url=end_session_endpoint)
|
||||
return RedirectResponse(url="/")
|
||||
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.security.deps import require_auth
|
||||
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def dashboard(request: Request, user: dict = Depends(require_auth)):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{"user": user},
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _fernet() -> Fernet:
|
||||
key_path = settings.encryption_key_file
|
||||
if not key_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Encryption key not found at {key_path}. Generate one with "
|
||||
f"`openssl rand -base64 32` and mount it as the alembic_master_key "
|
||||
f"Docker secret — see docker-compose.snippet.yml."
|
||||
)
|
||||
return Fernet(key_path.read_bytes().strip())
|
||||
|
||||
|
||||
def encrypt(value: str) -> bytes:
|
||||
return _fernet().encrypt(value.encode())
|
||||
|
||||
|
||||
def decrypt(value: bytes) -> str:
|
||||
return _fernet().decrypt(value).decode()
|
||||
@@ -0,0 +1,22 @@
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def get_session_user(request: Request) -> dict | None:
|
||||
return request.session.get("user")
|
||||
|
||||
|
||||
def require_auth(request: Request) -> dict:
|
||||
"""FastAPI dependency: redirect to /auth/login if no session user.
|
||||
|
||||
Raising a 3xx HTTPException with a Location header works as a browser
|
||||
redirect regardless of the response body FastAPI's default exception
|
||||
handler produces — the redirect is purely a status-code+header behavior.
|
||||
"""
|
||||
user = get_session_user(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=303, headers={"Location": "/auth/login"})
|
||||
if settings.allowed_email and user.get("email") != settings.allowed_email:
|
||||
raise HTTPException(status_code=403, detail="not authorized")
|
||||
return user
|
||||
@@ -0,0 +1,14 @@
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
oauth = OAuth()
|
||||
|
||||
if settings.pocketid_issuer:
|
||||
oauth.register(
|
||||
name="pocketid",
|
||||
server_metadata_url=f"{settings.pocketid_issuer}/.well-known/openid-configuration",
|
||||
client_id=settings.pocketid_client_id,
|
||||
client_secret=settings.pocketid_client_secret,
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="", extra="ignore")
|
||||
|
||||
# Mount points — see docker-compose.snippet.yml. Two volumes total:
|
||||
# everything audio-related under MUSIC_DATA_DIR, everything else
|
||||
# (app DB, beets config+DB, rendered pipeline credentials, job logs)
|
||||
# under ALEMBIC_CONFIG_DIR.
|
||||
music_data_dir: Path = Path("/data/music")
|
||||
alembic_config_dir: Path = Path("/config")
|
||||
pipeline_dir: Path = Path("/app/pipeline")
|
||||
|
||||
alembic_port: int = 8420
|
||||
|
||||
# Fernet key for the `secrets` table, read from a Docker secret file
|
||||
# (not a plain env var) — see docker-compose.snippet.yml's `secrets:` block.
|
||||
encryption_key_file: Path = Path("/run/secrets/alembic_master_key")
|
||||
|
||||
# Session cookie signing secret for Starlette's SessionMiddleware.
|
||||
session_secret: str = "changeme-dev-only-override-in-production"
|
||||
|
||||
# Pocket ID OIDC client, registered once in the Pocket ID admin UI
|
||||
# (redirect URI: https://<alembic-host>/auth/callback).
|
||||
pocketid_issuer: str = ""
|
||||
pocketid_client_id: str = ""
|
||||
pocketid_client_secret: str = ""
|
||||
|
||||
# Defense-in-depth: this is a single-user app. If set, only this email
|
||||
# may complete login even if Pocket ID ever grows a second account.
|
||||
allowed_email: str = ""
|
||||
|
||||
@property
|
||||
def beets_dir(self) -> Path:
|
||||
return self.alembic_config_dir / "beets"
|
||||
|
||||
@property
|
||||
def beets_db_path(self) -> Path:
|
||||
return self.beets_dir / "data" / "beets_library.db"
|
||||
|
||||
@property
|
||||
def pipeline_config_dir(self) -> Path:
|
||||
"""Rendered/instance pipeline config: per-playlist .conf files,
|
||||
credential env files, editable lists — NOT the static code in
|
||||
pipeline_dir, which is baked into the image."""
|
||||
return self.alembic_config_dir / "pipeline"
|
||||
|
||||
@property
|
||||
def app_db_path(self) -> Path:
|
||||
return self.alembic_config_dir / "alembic.db"
|
||||
|
||||
@property
|
||||
def logs_dir(self) -> Path:
|
||||
return self.alembic_config_dir / "logs"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,90 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #1a1a1a;
|
||||
--muted: #666;
|
||||
--border: #ddd;
|
||||
--accent: #2a6f97;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14161a;
|
||||
--fg: #e8e8e8;
|
||||
--muted: #999;
|
||||
--border: #333;
|
||||
--accent: #6fb3d8;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.topbar .brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topbar nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topbar a {
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topbar nav a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.logout {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 960px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
button, .btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}alembic{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">alembic</a>
|
||||
<nav>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/playlists">Playlists</a>
|
||||
<a href="/library">Library</a>
|
||||
<a href="/jobs">Jobs</a>
|
||||
<a href="/dedup">Dedup</a>
|
||||
<a href="/genres">Genres</a>
|
||||
<a href="/import">Import</a>
|
||||
<a href="/settings/credentials">Credentials</a>
|
||||
</nav>
|
||||
{% if user %}
|
||||
<a class="logout" href="/auth/logout">Log out ({{ user.email or user.name }})</a>
|
||||
{% endif %}
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — alembic{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Dashboard</h1>
|
||||
<p class="muted">
|
||||
Logged in as {{ user.name or user.email }}. Playlist status, job history,
|
||||
and pipeline health will render here once the corresponding services are
|
||||
built (status_service, scheduler_service).
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -2,6 +2,8 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
jinja2
|
||||
python-multipart
|
||||
pydantic-settings
|
||||
sqlalchemy
|
||||
apscheduler
|
||||
authlib
|
||||
|
||||
Reference in New Issue
Block a user