Refactor: split serve/server.py (3220 lines) into focused modules
serve/server.py is now 69 lines — app factory, middleware, and router
registration only.
New modules:
deps.py (168 lines) — module-level globals + auth dependency functions
models.py (85 lines) — all Pydantic request/response models
tasks.py (136 lines) — background workers and job tracker
routers/ — one file per domain (10 routers, ~2750 lines total)
auth.py, me.py, admin.py, activities.py, uploads.py,
segments.py, strava.py, garmin.py, ideas.py, feed.py
cli.py updated to set globals on deps instead of server.
88 new regression tests in tests/serve/ cover auth guards and key
behaviours for every router; 294 total passing after the split.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
"""Ideas and feedback endpoints (/api/ideas/*, /api/feedback)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl as _fcntl
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Cookie, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps
|
||||
from bincio.serve.models import IdeaBody
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
|
||||
_FEEDBACK_MAX_IMAGES = 3
|
||||
_FEEDBACK_MAX_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB
|
||||
|
||||
|
||||
def _ideas_dir(data_dir: Path) -> Path:
|
||||
d = data_dir / "_ideas"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/api/ideas")
|
||||
async def list_ideas(
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
ideas = []
|
||||
for path in sorted(_ideas_dir(dd).glob("*.json")):
|
||||
try:
|
||||
idea = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
votes = idea.get("votes", [])
|
||||
idea["vote_count"] = len(votes)
|
||||
idea["my_vote"] = user.handle in votes
|
||||
ideas.append(idea)
|
||||
ideas.sort(key=lambda x: (x.get("status") == "done", -x["vote_count"], -x["created_at"]))
|
||||
return JSONResponse({"ideas": ideas})
|
||||
|
||||
|
||||
@router.post("/api/ideas")
|
||||
async def create_idea(
|
||||
data: IdeaBody,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
title = data.title.strip()[:200]
|
||||
body = data.body.strip()[:2000]
|
||||
if not title:
|
||||
raise HTTPException(400, "Title required")
|
||||
dd = deps._get_data_dir()
|
||||
idea_id = secrets.token_hex(8)
|
||||
idea = {
|
||||
"id": idea_id,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"author": user.handle,
|
||||
"created_at": int(time.time()),
|
||||
"votes": [],
|
||||
}
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
path.write_text(json.dumps(idea, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return JSONResponse({"id": idea_id})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/vote")
|
||||
async def toggle_idea_vote(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
votes: list = idea.get("votes", [])
|
||||
if user.handle in votes:
|
||||
votes.remove(user.handle)
|
||||
voted = False
|
||||
else:
|
||||
votes.append(user.handle)
|
||||
voted = True
|
||||
idea["votes"] = votes
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"voted": voted, "votes": len(votes)})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/status")
|
||||
async def toggle_idea_status(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
idea["status"] = "open" if idea.get("status") == "done" else "done"
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"status": idea["status"]})
|
||||
|
||||
|
||||
@router.patch("/api/ideas/{idea_id}")
|
||||
async def edit_idea(
|
||||
idea_id: str,
|
||||
data: IdeaBody,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
title = data.title.strip()[:200]
|
||||
body = data.body.strip()[:2000]
|
||||
if not title:
|
||||
raise HTTPException(400, "Title required")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
if not user.is_admin and idea.get("author") != user.handle:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
idea["title"] = title
|
||||
idea["body"] = body
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"ok": True, "title": title, "body": body})
|
||||
|
||||
|
||||
@router.delete("/api/ideas/{idea_id}")
|
||||
async def delete_idea(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
try:
|
||||
idea = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raise HTTPException(500, "Could not read idea")
|
||||
if not user.is_admin and idea.get("author") != user.handle:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
path.unlink()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ── Feedback ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/feedback")
|
||||
async def submit_feedback(
|
||||
text: str = Form(""),
|
||||
images: list[UploadFile] = File(default=[]),
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
|
||||
text = text.strip()
|
||||
if not text and not any(f.filename for f in images):
|
||||
raise HTTPException(400, "Feedback must include text or at least one image")
|
||||
if len(images) > _FEEDBACK_MAX_IMAGES:
|
||||
raise HTTPException(400, f"Maximum {_FEEDBACK_MAX_IMAGES} images per submission")
|
||||
|
||||
feedback_dir = deps._get_data_dir() / "_feedback"
|
||||
feedback_dir.mkdir(exist_ok=True)
|
||||
images_dir = feedback_dir / user.handle
|
||||
images_dir.mkdir(exist_ok=True)
|
||||
|
||||
now = int(time.time())
|
||||
submission_id = f"{now}_{secrets.token_hex(4)}"
|
||||
saved_images: list[str] = []
|
||||
|
||||
for img in images:
|
||||
if not img.filename:
|
||||
continue
|
||||
suffix = Path(img.filename).suffix.lower()
|
||||
if suffix not in _FEEDBACK_IMAGE_SUFFIXES:
|
||||
raise HTTPException(400, f"Unsupported image type '{suffix}'")
|
||||
contents = await img.read()
|
||||
if len(contents) > _FEEDBACK_MAX_IMAGE_BYTES:
|
||||
raise HTTPException(413, f"Image '{img.filename}' exceeds 2 MB limit")
|
||||
safe_name = f"{submission_id}_{Path(img.filename).name}"
|
||||
(images_dir / safe_name).write_bytes(contents)
|
||||
saved_images.append(safe_name)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
entry = {
|
||||
"id": submission_id,
|
||||
"handle": user.handle,
|
||||
"submitted_at": datetime.now(timezone.utc).isoformat(),
|
||||
"text": text,
|
||||
"images": saved_images,
|
||||
}
|
||||
|
||||
log_file = feedback_dir / f"{user.handle}.json"
|
||||
existing: list[dict] = []
|
||||
if log_file.exists():
|
||||
try:
|
||||
existing = json.loads(log_file.read_text())
|
||||
except Exception:
|
||||
existing = []
|
||||
existing.append(entry)
|
||||
log_file.write_text(json.dumps(existing, indent=2))
|
||||
|
||||
return JSONResponse({"ok": True, "id": submission_id})
|
||||
Reference in New Issue
Block a user