526 lines
19 KiB
Python
526 lines
19 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from secrets import token_hex
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, UploadFile, File
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session as DbSession
|
|
|
|
from app.database import get_db
|
|
from app.models import (
|
|
ApiKey, Profile, Postcard, User,
|
|
PERM_PROFILES_READ, PERM_PROFILES_WRITE,
|
|
PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE,
|
|
PERM_IMAGES_UPLOAD,
|
|
)
|
|
from app.config import UPLOAD_DIR
|
|
|
|
router = APIRouter(prefix="/api/v1", tags=["api"])
|
|
|
|
|
|
# ─── Auth Dependencies ────────────────────────────────────────────
|
|
|
|
def _parse_permissions(raw: str) -> list[str]:
|
|
"""Parse JSON permissions string from DB, return list of scope strings."""
|
|
import json
|
|
try:
|
|
val = json.loads(raw)
|
|
return val if isinstance(val, list) else []
|
|
except (json.JSONDecodeError, TypeError):
|
|
return []
|
|
|
|
|
|
def get_api_key(
|
|
x_api_key: str = Header(..., alias="X-API-Key"),
|
|
db: DbSession = Depends(get_db),
|
|
) -> ApiKey:
|
|
"""Validate X-API-Key header and return the ApiKey object (with permissions)."""
|
|
ak = db.query(ApiKey).filter(ApiKey.key == x_api_key).first()
|
|
if not ak or not ak.is_active:
|
|
raise HTTPException(status_code=401, detail="Invalid or inactive API key")
|
|
ak.last_used_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
return ak
|
|
|
|
|
|
def get_api_user(ak: ApiKey = Depends(get_api_key)) -> User:
|
|
"""Return the User associated with a valid API key (no permission check)."""
|
|
return ak.user
|
|
|
|
|
|
def require_scopes(*scopes: str):
|
|
"""Return a dependency that checks the API key has ALL given scopes.
|
|
|
|
Keys with empty permissions list (legacy) are treated as full-access.
|
|
"""
|
|
def _dep(ak: ApiKey = Depends(get_api_key)) -> ApiKey:
|
|
perms = _parse_permissions(ak.permissions)
|
|
if perms:
|
|
missing = [s for s in scopes if s not in perms]
|
|
if missing:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Missing required permissions: {', '.join(missing)}",
|
|
)
|
|
return ak
|
|
return _dep
|
|
|
|
|
|
def get_api_user_optional(
|
|
x_api_key: str | None = Header(None, alias="X-API-Key"),
|
|
db: DbSession = Depends(get_db),
|
|
) -> User | None:
|
|
"""Like get_api_user but returns None if no key provided (for public endpoints)."""
|
|
if not x_api_key:
|
|
return None
|
|
ak = get_api_key(x_api_key, db)
|
|
return ak.user
|
|
|
|
|
|
# ─── Pydantic Schemas ────────────────────────────────────────────
|
|
|
|
class ProfileCreate(BaseModel):
|
|
nickname: str
|
|
country: str | None = None
|
|
|
|
class ProfileUpdate(BaseModel):
|
|
nickname: str | None = None
|
|
country: str | None = None
|
|
|
|
class ProfileOut(BaseModel):
|
|
id: int
|
|
nickname: str
|
|
country: str | None
|
|
showcase_enabled: bool
|
|
showcase_visible: str
|
|
notes: str
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class PostcardCreate(BaseModel):
|
|
card_number: str
|
|
status: str
|
|
recipient_name: str | None = None
|
|
country_from: str | None = None
|
|
country_to: str | None = None
|
|
send_time: str | None = None
|
|
arrival_time: str | None = None
|
|
sender_name: str | None = None
|
|
receive_time: str | None = None
|
|
notes: str | None = None
|
|
|
|
class PostcardUpdate(BaseModel):
|
|
card_number: str | None = None
|
|
status: str | None = None
|
|
recipient_name: str | None = None
|
|
country_from: str | None = None
|
|
country_to: str | None = None
|
|
send_time: str | None = None
|
|
arrival_time: str | None = None
|
|
sender_name: str | None = None
|
|
receive_time: str | None = None
|
|
notes: str | None = None
|
|
|
|
class PostcardOut(BaseModel):
|
|
id: int
|
|
profile_id: int
|
|
card_number: str
|
|
status: str
|
|
recipient_name: str | None
|
|
country_from: str | None
|
|
country_to: str | None
|
|
send_time: datetime | None
|
|
arrival_time: datetime | None
|
|
sender_name: str | None
|
|
receive_time: datetime | None
|
|
notes: str
|
|
image_front: str | None
|
|
image_back: str | None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class ApiKeyCreate(BaseModel):
|
|
name: str = ""
|
|
permissions: list[str] = []
|
|
|
|
class ApiKeyOut(BaseModel):
|
|
id: int
|
|
key: str
|
|
name: str
|
|
is_active: bool
|
|
permissions: list[str]
|
|
created_at: datetime
|
|
last_used_at: datetime | None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ─── Helpers ──────────────────────────────────────────────────────
|
|
|
|
def _parse_date(s: str | None) -> datetime | None:
|
|
"""Parse a date or datetime string to datetime."""
|
|
if not s:
|
|
return None
|
|
s = s.strip()
|
|
for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
|
|
try:
|
|
return datetime.strptime(s, fmt)
|
|
except ValueError:
|
|
continue
|
|
raise HTTPException(status_code=400, detail=f"Invalid date format: {s}")
|
|
|
|
|
|
def _profile_or_404(user: User, profile_id: int, db: DbSession) -> Profile:
|
|
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
|
|
if not p:
|
|
raise HTTPException(status_code=404, detail="Profile not found")
|
|
return p
|
|
|
|
|
|
def _postcard_or_404(user: User, profile_id: int, postcard_id: int, db: DbSession) -> Postcard:
|
|
_profile_or_404(user, profile_id, db)
|
|
pc = db.query(Postcard).filter(
|
|
Postcard.id == postcard_id, Postcard.profile_id == profile_id
|
|
).first()
|
|
if not pc:
|
|
raise HTTPException(status_code=404, detail="Postcard not found")
|
|
return pc
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Profiles
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
@router.get("/profiles", response_model=list[ProfileOut])
|
|
def list_profiles(
|
|
db: DbSession = Depends(get_db),
|
|
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_READ)),
|
|
):
|
|
return db.query(Profile).filter(Profile.user_id == ak.user_id).order_by(Profile.id).all()
|
|
|
|
|
|
@router.post("/profiles", response_model=ProfileOut, status_code=201)
|
|
def create_profile(
|
|
body: ProfileCreate,
|
|
db: DbSession = Depends(get_db),
|
|
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
|
|
):
|
|
c = body.country.strip().upper() if body.country else None
|
|
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id, country=c)
|
|
db.add(p)
|
|
db.commit()
|
|
db.refresh(p)
|
|
return p
|
|
|
|
|
|
@router.get("/profiles/{profile_id}", response_model=ProfileOut)
|
|
def get_profile(
|
|
profile_id: int,
|
|
db: DbSession = Depends(get_db),
|
|
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_READ)),
|
|
):
|
|
return _profile_or_404(ak.user, profile_id, db)
|
|
|
|
|
|
@router.put("/profiles/{profile_id}", response_model=ProfileOut)
|
|
def update_profile(
|
|
profile_id: int,
|
|
body: ProfileUpdate,
|
|
db: DbSession = Depends(get_db),
|
|
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
|
|
):
|
|
p = _profile_or_404(ak.user, profile_id, db)
|
|
if body.nickname is not None:
|
|
p.nickname = body.nickname.strip()
|
|
if body.country is not None:
|
|
p.country = body.country.strip().upper() if body.country else None
|
|
db.commit()
|
|
db.refresh(p)
|
|
return p
|
|
|
|
|
|
@router.delete("/profiles/{profile_id}")
|
|
def delete_profile(
|
|
profile_id: int,
|
|
db: DbSession = Depends(get_db),
|
|
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
|
|
):
|
|
p = _profile_or_404(ak.user, profile_id, db)
|
|
db.delete(p)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Postcards
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
@router.get("/profiles/{profile_id}/postcards", response_model=list[PostcardOut])
|
|
def list_postcards(
|
|
profile_id: int,
|
|
status: str | None = Query(None),
|
|
db: DbSession = Depends(get_db),
|
|
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_READ)),
|
|
):
|
|
_profile_or_404(ak.user, profile_id, db)
|
|
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
|
|
if status:
|
|
q = q.filter(Postcard.status == status)
|
|
return q.order_by(Postcard.id.desc()).all()
|
|
|
|
|
|
@router.post("/profiles/{profile_id}/postcards", response_model=PostcardOut, status_code=201)
|
|
def create_postcard(
|
|
profile_id: int,
|
|
body: PostcardCreate,
|
|
db: DbSession = Depends(get_db),
|
|
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
|
|
):
|
|
_profile_or_404(ak.user, profile_id, db)
|
|
cn = body.card_number.strip()
|
|
dup = db.query(Postcard).join(Profile).filter(
|
|
Postcard.card_number == cn, Profile.user_id == ak.user.id
|
|
).first()
|
|
if dup:
|
|
raise HTTPException(status_code=409, detail="Card number already exists")
|
|
pc = Postcard(
|
|
profile_id=profile_id,
|
|
card_number=cn,
|
|
status=body.status,
|
|
recipient_name=body.recipient_name,
|
|
country_from=body.country_from.strip().upper() if body.country_from else None,
|
|
country_to=body.country_to.strip().upper() if body.country_to else None,
|
|
send_time=_parse_date(body.send_time),
|
|
arrival_time=_parse_date(body.arrival_time),
|
|
sender_name=body.sender_name,
|
|
receive_time=_parse_date(body.receive_time),
|
|
notes=body.notes or "",
|
|
)
|
|
db.add(pc)
|
|
db.commit()
|
|
db.refresh(pc)
|
|
return pc
|
|
|
|
|
|
@router.get("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut)
|
|
def get_postcard(
|
|
profile_id: int,
|
|
postcard_id: int,
|
|
db: DbSession = Depends(get_db),
|
|
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_READ)),
|
|
):
|
|
return _postcard_or_404(ak.user, profile_id, postcard_id, db)
|
|
|
|
|
|
@router.put("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut)
|
|
def update_postcard(
|
|
profile_id: int,
|
|
postcard_id: int,
|
|
body: PostcardUpdate,
|
|
db: DbSession = Depends(get_db),
|
|
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
|
|
):
|
|
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db)
|
|
for field in ("status", "recipient_name", "sender_name", "notes"):
|
|
val = getattr(body, field)
|
|
if val is not None:
|
|
setattr(pc, field, val.strip() if isinstance(val, str) else val)
|
|
if body.card_number is not None:
|
|
cn = body.card_number.strip()
|
|
dup = db.query(Postcard).join(Profile).filter(
|
|
Postcard.card_number == cn, Postcard.id != postcard_id,
|
|
Profile.user_id == ak.user.id
|
|
).first()
|
|
if dup:
|
|
raise HTTPException(status_code=409, detail="Card number already exists")
|
|
pc.card_number = cn
|
|
if body.country_to is not None:
|
|
pc.country_to = body.country_to.strip().upper() if body.country_to else None
|
|
if body.country_from is not None:
|
|
pc.country_from = body.country_from.strip().upper() if body.country_from else None
|
|
for field in ("send_time", "arrival_time", "receive_time"):
|
|
val = getattr(body, field)
|
|
if val is not None:
|
|
setattr(pc, field, _parse_date(val))
|
|
db.commit()
|
|
db.refresh(pc)
|
|
return pc
|
|
|
|
|
|
@router.delete("/profiles/{profile_id}/postcards/{postcard_id}")
|
|
def delete_postcard(
|
|
profile_id: int,
|
|
postcard_id: int,
|
|
db: DbSession = Depends(get_db),
|
|
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
|
|
):
|
|
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db)
|
|
db.delete(pc)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Image Upload
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
@router.post("/profiles/{profile_id}/postcards/{postcard_id}/upload", response_model=PostcardOut)
|
|
async def upload_image(
|
|
profile_id: int,
|
|
postcard_id: int,
|
|
side: str = Query("front", pattern="^(front|back)$"),
|
|
file: UploadFile = File(...),
|
|
db: DbSession = Depends(get_db),
|
|
ak: ApiKey = Depends(require_scopes(PERM_IMAGES_UPLOAD)),
|
|
):
|
|
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db)
|
|
ext = Path(file.filename).suffix if file.filename else ".jpg"
|
|
save_name = f"pc_{pc.id}_{side}{ext}"
|
|
save_path = UPLOAD_DIR / save_name
|
|
content = await file.read()
|
|
save_path.write_bytes(content)
|
|
if side == "front":
|
|
pc.image_front = f"/uploads/{save_name}"
|
|
else:
|
|
pc.image_back = f"/uploads/{save_name}"
|
|
db.commit()
|
|
db.refresh(pc)
|
|
return pc
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# API Key Management
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
@router.get("/keys", response_model=list[ApiKeyOut])
|
|
def list_api_keys(
|
|
db: DbSession = Depends(get_db),
|
|
user: User = Depends(get_api_user),
|
|
):
|
|
return db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
|
|
|
|
|
|
@router.post("/keys", response_model=ApiKeyOut, status_code=201)
|
|
def create_api_key(
|
|
body: ApiKeyCreate,
|
|
db: DbSession = Depends(get_db),
|
|
user: User = Depends(get_api_user),
|
|
):
|
|
key = f"mv_{token_hex(24)}"
|
|
import json
|
|
perms = json.dumps(body.permissions) if body.permissions else "[]"
|
|
new_ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id, permissions=perms)
|
|
db.add(new_ak)
|
|
db.commit()
|
|
db.refresh(new_ak)
|
|
return new_ak
|
|
|
|
|
|
class ApiKeyUpdate(BaseModel):
|
|
name: str | None = None
|
|
permissions: list[str] | None = None
|
|
|
|
|
|
@router.put("/keys/{key_id}", response_model=ApiKeyOut)
|
|
def update_api_key(
|
|
key_id: int,
|
|
body: ApiKeyUpdate,
|
|
db: DbSession = Depends(get_db),
|
|
user: User = Depends(get_api_user),
|
|
):
|
|
import json
|
|
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
|
if not ak:
|
|
raise HTTPException(status_code=404, detail="API key not found")
|
|
if body.name is not None:
|
|
ak.name = body.name.strip()
|
|
if body.permissions is not None:
|
|
valid = [p for p in body.permissions if p in ALL_PERMISSIONS]
|
|
ak.permissions = json.dumps(valid) if valid else "[]"
|
|
db.commit()
|
|
db.refresh(ak)
|
|
return ak
|
|
|
|
|
|
@router.delete("/keys/{key_id}")
|
|
def delete_api_key(
|
|
key_id: int,
|
|
db: DbSession = Depends(get_db),
|
|
user: User = Depends(get_api_user),
|
|
):
|
|
target = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
|
if not target:
|
|
raise HTTPException(status_code=404, detail="API key not found")
|
|
db.delete(target)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Showcase (public, no auth required)
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class ShowcaseProfileOut(BaseModel):
|
|
id: int
|
|
nickname: str
|
|
showcase_visible: str
|
|
sent: list[PostcardOut]
|
|
received: list[PostcardOut]
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class ShowcaseUserOut(BaseModel):
|
|
username: str
|
|
showcase_mode: str
|
|
profiles: list[ShowcaseProfileOut]
|
|
|
|
|
|
@router.get("/showcase/{username}", response_model=ShowcaseUserOut)
|
|
def showcase_user(username: str, db: DbSession = Depends(get_db)):
|
|
user = db.query(User).filter(User.username == username).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all()
|
|
result_profiles = []
|
|
for p in profiles:
|
|
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
|
|
if p.showcase_visible in ("sent", "both"):
|
|
sent = [c for c in cards if c.status in ("sent", "delivered")]
|
|
else:
|
|
sent = []
|
|
if p.showcase_visible in ("received", "both"):
|
|
received = [c for c in cards if c.status == "received"]
|
|
else:
|
|
received = []
|
|
if sent or received:
|
|
sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
|
|
received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True)
|
|
result_profiles.append(ShowcaseProfileOut(
|
|
id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible,
|
|
sent=sent, received=received,
|
|
))
|
|
return ShowcaseUserOut(username=user.username, showcase_mode=user.showcase_mode, profiles=result_profiles)
|
|
|
|
|
|
@router.get("/showcase/{username}/profile/{profile_id}", response_model=ShowcaseProfileOut)
|
|
def showcase_profile(username: str, profile_id: int, db: DbSession = Depends(get_db)):
|
|
user = db.query(User).filter(User.username == username).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id, Profile.showcase_enabled == True).first()
|
|
if not p:
|
|
raise HTTPException(status_code=404, detail="Profile not found or showcase disabled")
|
|
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
|
|
sent = [c for c in cards if c.status in ("sent", "delivered")] if p.showcase_visible in ("sent", "both") else []
|
|
received = [c for c in cards if c.status == "received"] if p.showcase_visible in ("received", "both") else []
|
|
sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
|
|
received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True)
|
|
return ShowcaseProfileOut(id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible, sent=sent, received=received)
|