From 0eca60300f84c0c4cf21db47738da7f83ea7d717 Mon Sep 17 00:00:00 2001 From: Zichao Lin Date: Sun, 5 Jul 2026 16:06:03 +0800 Subject: [PATCH] feat: initial postcard manager - FastAPI + Jinja2 + SQLite --- .gitignore | 7 + activate.bat | 1 + activate.ps1 | 1 + app/__init__.py | 0 app/auth.py | 86 +++++++ app/config.py | 7 + app/database.py | 25 ++ app/main.py | 23 ++ app/models.py | 74 ++++++ app/routers/__init__.py | 0 app/routers/api.py | 209 +++++++++++++++++ app/routers/web.py | 359 +++++++++++++++++++++++++++++ app/schemas.py | 82 +++++++ app/static/style.css | 118 ++++++++++ app/templates/api_keys.html | 34 +++ app/templates/base.html | 24 ++ app/templates/dashboard.html | 27 +++ app/templates/login.html | 18 ++ app/templates/postcard_detail.html | 55 +++++ app/templates/postcard_form.html | 49 ++++ app/templates/postcards.html | 44 ++++ app/templates/profiles.html | 33 +++ app/templates/register.html | 20 ++ requirements.txt | 8 + 24 files changed, 1304 insertions(+) create mode 100644 .gitignore create mode 100644 activate.bat create mode 100644 activate.ps1 create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/api.py create mode 100644 app/routers/web.py create mode 100644 app/schemas.py create mode 100644 app/static/style.css create mode 100644 app/templates/api_keys.html create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/login.html create mode 100644 app/templates/postcard_detail.html create mode 100644 app/templates/postcard_form.html create mode 100644 app/templates/postcards.html create mode 100644 app/templates/profiles.html create mode 100644 app/templates/register.html create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24689d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +uploads/* +!uploads/.gitkeep +*.db +.env diff --git a/activate.bat b/activate.bat new file mode 100644 index 0000000..dc3e023 --- /dev/null +++ b/activate.bat @@ -0,0 +1 @@ +start "Python Venv" /D "%~dp0" cmd /k "%~dp0.venv\Scripts\activate.bat" diff --git a/activate.ps1 b/activate.ps1 new file mode 100644 index 0000000..f6839c6 --- /dev/null +++ b/activate.ps1 @@ -0,0 +1 @@ +Start-Process -WindowStyle Normal -FilePath "powershell" -ArgumentList "-NoExit", "-Command", "& '.\.venv\Scripts\Activate.ps1'" diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..8e0fd4d --- /dev/null +++ b/app/auth.py @@ -0,0 +1,86 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +import bcrypt +from fastapi import Cookie, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from app.config import SECRET_KEY, SESSION_COOKIE_NAME +from app.database import get_db +from app.models import ApiKey, User + +# ---------- Password ---------- + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(password: str, password_hash: str) -> bool: + return bcrypt.checkpw(password.encode(), password_hash.encode()) + + +# ---------- Session ---------- + +# In-memory session store: token -> user_id +_sessions: dict[str, int] = {} + + +def create_session(user_id: int) -> str: + token = uuid4().hex + _sessions[token] = user_id + return token + + +def destroy_session(token: str) -> None: + _sessions.pop(token, None) + + +def get_session_user_id(token: str | None) -> int | None: + if token is None: + return None + return _sessions.get(token) + + +# ---------- Web Dependencies ---------- + +def get_current_user_web( + request: Request, + db: Session = Depends(get_db), +) -> User: + token = request.cookies.get(SESSION_COOKIE_NAME) + uid = get_session_user_id(token) + if uid is None: + raise HTTPException(status_code=303, headers={"Location": "/login"}) + user = db.query(User).filter(User.id == uid).first() + if user is None: + raise HTTPException(status_code=303, headers={"Location": "/login"}) + return user + + +def redirect_if_not_logged_in(request: Request, db: Session) -> User | None: + """Return user if logged in, else None — caller decides redirect.""" + token = request.cookies.get(SESSION_COOKIE_NAME) + uid = get_session_user_id(token) + if uid is None: + return None + return db.query(User).filter(User.id == uid).first() + + +# ---------- API Dependencies ---------- + +def get_current_user_api( + request: Request, + db: Session = Depends(get_db), +) -> User: + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Missing or invalid Authorization header") + token = auth_header[7:] + api_key = db.query(ApiKey).filter(ApiKey.key == token).first() + if api_key is None: + raise HTTPException(status_code=401, detail="Invalid API key") + user = db.query(User).filter(User.id == api_key.user_id).first() + if user is None: + raise HTTPException(status_code=401, detail="User not found") + return user diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..f2dd8e5 --- /dev/null +++ b/app/config.py @@ -0,0 +1,7 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent +DATABASE_URL = f"sqlite:///{BASE_DIR / 'postcard.db'}" +UPLOAD_DIR = BASE_DIR / "uploads" +SECRET_KEY = "postcard-manager-secret-change-in-production" +SESSION_COOKIE_NAME = "pc_session" diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..11e316b --- /dev/null +++ b/app/database.py @@ -0,0 +1,25 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase + +from app.config import DATABASE_URL + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + from app import models # noqa: F401 + + Base.metadata.create_all(bind=engine) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..dce863f --- /dev/null +++ b/app/main.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.config import UPLOAD_DIR +from app.database import init_db +from app.routers import api, web + +app = FastAPI(title="Postcard Manager") + +UPLOAD_DIR.mkdir(exist_ok=True) +app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static") +app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") + +app.include_router(web.router) +app.include_router(api.router) + +init_db() + +if __name__ == "__main__": + import uvicorn + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..be4d276 --- /dev/null +++ b/app/models.py @@ -0,0 +1,74 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from app.database import Base + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(64), unique=True, nullable=False, index=True) + password_hash = Column(String(128), nullable=False) + created_at = Column(DateTime, default=_utcnow) + + api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan") + profiles = relationship("Profile", back_populates="user", cascade="all, delete-orphan") + + +class ApiKey(Base): + __tablename__ = "api_keys" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + key = Column(String(64), unique=True, nullable=False, index=True) + created_at = Column(DateTime, default=_utcnow) + + user = relationship("User", back_populates="api_keys") + + @staticmethod + def generate_key() -> str: + return uuid.uuid4().hex + + +class Profile(Base): + __tablename__ = "profiles" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + nickname = Column(String(64), nullable=False) + created_at = Column(DateTime, default=_utcnow) + + user = relationship("User", back_populates="profiles") + postcards = relationship("Postcard", back_populates="profile", cascade="all, delete-orphan") + + +class Postcard(Base): + __tablename__ = "postcards" + + id = Column(Integer, primary_key=True, index=True) + profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False) + card_number = Column(String(64), nullable=False) + status = Column(String(16), nullable=False) # sent / delivered / received + # sent & delivered + recipient_name = Column(String(64)) + country = Column(String(64)) + send_time = Column(DateTime) + arrival_time = Column(DateTime) + # received + sender_name = Column(String(64)) + receive_time = Column(DateTime) + # images + image_front = Column(String(256)) + image_back = Column(String(256)) + created_at = Column(DateTime, default=_utcnow) + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) + + profile = relationship("Profile", back_populates="postcards") diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/api.py b/app/routers/api.py new file mode 100644 index 0000000..f887030 --- /dev/null +++ b/app/routers/api.py @@ -0,0 +1,209 @@ +from pathlib import Path +from uuid import uuid4 + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from sqlalchemy.orm import Session + +from app.auth import get_current_user_api, hash_password, verify_password +from app.config import UPLOAD_DIR +from app.database import get_db +from app.models import ApiKey, Postcard, Profile, User +from app.schemas import ( + ApiKeyOut, + LoginRequest, + PostcardCreate, + PostcardOut, + PostcardUpdate, + ProfileCreate, + ProfileOut, + RegisterRequest, +) + +router = APIRouter(prefix="/api", tags=["api"]) + + +# ---------- Auth ---------- + +@router.post("/register", response_model=dict) +def api_register(body: RegisterRequest, db: Session = Depends(get_db)): + if db.query(User).filter(User.username == body.username).first(): + raise HTTPException(400, "Username already exists") + user = User(username=body.username, password_hash=hash_password(body.password)) + db.add(user) + db.commit() + return {"message": "ok"} + + +@router.post("/login", response_model=dict) +def api_login(body: LoginRequest, db: Session = Depends(get_db)): + user = db.query(User).filter(User.username == body.username).first() + if not user or not verify_password(body.password, user.password_hash): + raise HTTPException(401, "Invalid credentials") + api_key = ApiKey(user_id=user.id, key=ApiKey.generate_key()) + db.add(api_key) + db.commit() + return {"api_key": api_key.key} + + +# ---------- Profile ---------- + +def _get_profile(profile_id: int, user: User, db: Session) -> Profile: + p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first() + if not p: + raise HTTPException(404, "Profile not found") + return p + + +@router.get("/profiles", response_model=list[ProfileOut]) +def api_list_profiles(user: User = Depends(get_current_user_api), db: Session = Depends(get_db)): + return db.query(Profile).filter(Profile.user_id == user.id).all() + + +@router.post("/profiles", response_model=ProfileOut, status_code=201) +def api_create_profile(body: ProfileCreate, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)): + p = Profile(user_id=user.id, nickname=body.nickname) + db.add(p) + db.commit() + db.refresh(p) + return p + + +@router.put("/profiles/{profile_id}", response_model=ProfileOut) +def api_update_profile(profile_id: int, body: ProfileCreate, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)): + p = _get_profile(profile_id, user, db) + p.nickname = body.nickname + db.commit() + db.refresh(p) + return p + + +@router.delete("/profiles/{profile_id}") +def api_delete_profile(profile_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)): + p = _get_profile(profile_id, user, db) + db.delete(p) + db.commit() + return {"message": "ok"} + + +# ---------- Postcard ---------- + +def _get_postcard(postcard_id: int, user: User, db: Session) -> Postcard: + pc = ( + db.query(Postcard) + .join(Profile) + .filter(Postcard.id == postcard_id, Profile.user_id == user.id) + .first() + ) + if not pc: + raise HTTPException(404, "Postcard not found") + return pc + + +@router.get("/profiles/{profile_id}/postcards", response_model=list[PostcardOut]) +def api_list_postcards( + profile_id: int, + status: str | None = None, + country: str | None = None, + user: User = Depends(get_current_user_api), + db: Session = Depends(get_db), +): + _get_profile(profile_id, user, db) + q = db.query(Postcard).filter(Postcard.profile_id == profile_id) + if status: + q = q.filter(Postcard.status == status) + if country: + q = q.filter(Postcard.country.ilike(f"%{country}%")) + return q.order_by(Postcard.created_at.desc()).all() + + +@router.post("/profiles/{profile_id}/postcards", response_model=PostcardOut, status_code=201) +def api_create_postcard( + profile_id: int, + body: PostcardCreate, + user: User = Depends(get_current_user_api), + db: Session = Depends(get_db), +): + _get_profile(profile_id, user, db) + pc = Postcard(profile_id=profile_id, **body.model_dump()) + db.add(pc) + db.commit() + db.refresh(pc) + return pc + + +@router.get("/postcards/{postcard_id}", response_model=PostcardOut) +def api_get_postcard(postcard_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)): + return _get_postcard(postcard_id, user, db) + + +@router.put("/postcards/{postcard_id}", response_model=PostcardOut) +def api_update_postcard( + postcard_id: int, + body: PostcardUpdate, + user: User = Depends(get_current_user_api), + db: Session = Depends(get_db), +): + pc = _get_postcard(postcard_id, user, db) + for k, v in body.model_dump(exclude_unset=True).items(): + setattr(pc, k, v) + db.commit() + db.refresh(pc) + return pc + + +@router.delete("/postcards/{postcard_id}") +def api_delete_postcard(postcard_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)): + pc = _get_postcard(postcard_id, user, db) + db.delete(pc) + db.commit() + return {"message": "ok"} + + +# ---------- Image Upload ---------- + +@router.post("/postcards/{postcard_id}/image/{side}", response_model=PostcardOut) +async def api_upload_image( + postcard_id: int, + side: str, + file: UploadFile = File(...), + user: User = Depends(get_current_user_api), + db: Session = Depends(get_db), +): + if side not in ("front", "back"): + raise HTTPException(400, "side must be 'front' or 'back'") + pc = _get_postcard(postcard_id, user, db) + ext = Path(file.filename or "upload.jpg").suffix or ".jpg" + filename = f"{uuid4().hex}{ext}" + dest = UPLOAD_DIR / filename + content = await file.read() + dest.write_bytes(content) + setattr(pc, f"image_{side}", f"/uploads/{filename}") + db.commit() + db.refresh(pc) + return pc + + +# ---------- API Key ---------- + +@router.get("/api-keys", response_model=list[ApiKeyOut]) +def api_list_keys(user: User = Depends(get_current_user_api), db: Session = Depends(get_db)): + return db.query(ApiKey).filter(ApiKey.user_id == user.id).all() + + +@router.post("/api-keys", response_model=ApiKeyOut, status_code=201) +def api_create_key(user: User = Depends(get_current_user_api), db: Session = Depends(get_db)): + ak = ApiKey(user_id=user.id, key=ApiKey.generate_key()) + db.add(ak) + db.commit() + db.refresh(ak) + return ak + + +@router.delete("/api-keys/{key_id}") +def api_delete_key(key_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)): + ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first() + if not ak: + raise HTTPException(404, "API key not found") + db.delete(ak) + db.commit() + return {"message": "ok"} diff --git a/app/routers/web.py b/app/routers/web.py new file mode 100644 index 0000000..f410d41 --- /dev/null +++ b/app/routers/web.py @@ -0,0 +1,359 @@ +from datetime import datetime +from pathlib import Path +from uuid import uuid4 + +from fastapi import APIRouter, Depends, File, Form, Request, UploadFile +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from app.auth import ( + create_session, + destroy_session, + get_current_user_web, + hash_password, + redirect_if_not_logged_in, + verify_password, +) +from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR +from app.database import get_db +from app.models import ApiKey, Postcard, Profile, User + +router = APIRouter(tags=["web"]) +templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) + + +def _redirect(url: str) -> RedirectResponse: + return RedirectResponse(url, status_code=303) + + +# ---------- Auth ---------- + +@router.get("/login", response_class=HTMLResponse) +def login_page(request: Request, db: Session = Depends(get_db)): + user = redirect_if_not_logged_in(request, db) + if user: + return _redirect("/dashboard") + return templates.TemplateResponse("login.html", {"request": request}) + + +@router.post("/login") +def login_submit( + request: Request, + username: str = Form(...), + password: str = Form(...), + db: Session = Depends(get_db), +): + user = db.query(User).filter(User.username == username).first() + if not user or not verify_password(password, user.password_hash): + return templates.TemplateResponse( + "login.html", {"request": request, "error": "用户名或密码错误"}, status_code=401 + ) + resp = _redirect("/dashboard") + resp.set_cookie(SESSION_COOKIE_NAME, create_session(user.id), httponly=True) + return resp + + +@router.get("/register", response_class=HTMLResponse) +def register_page(request: Request): + return templates.TemplateResponse("register.html", {"request": request}) + + +@router.post("/register") +def register_submit( + request: Request, + username: str = Form(...), + password: str = Form(...), + password2: str = Form(...), + db: Session = Depends(get_db), +): + if password != password2: + return templates.TemplateResponse( + "register.html", {"request": request, "error": "两次密码不一致"}, status_code=400 + ) + if db.query(User).filter(User.username == username).first(): + return templates.TemplateResponse( + "register.html", {"request": request, "error": "用户名已存在"}, status_code=400 + ) + user = User(username=username, password_hash=hash_password(password)) + db.add(user) + db.commit() + resp = _redirect("/dashboard") + resp.set_cookie(SESSION_COOKIE_NAME, create_session(user.id), httponly=True) + return resp + + +@router.get("/logout") +def logout(request: Request): + token = request.cookies.get(SESSION_COOKIE_NAME) + destroy_session(token or "") + resp = _redirect("/login") + resp.delete_cookie(SESSION_COOKIE_NAME) + return resp + + +# ---------- Dashboard ---------- + +@router.get("/", response_class=HTMLResponse) +def root(user: User = Depends(get_current_user_web)): + return _redirect("/dashboard") + + +@router.get("/dashboard", response_class=HTMLResponse) +def dashboard(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): + profiles = db.query(Profile).filter(Profile.user_id == user.id).all() + # stats per profile + stats = {} + for p in profiles: + cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all() + stats[p.id] = { + "total": len(cards), + "sent": sum(1 for c in cards if c.status == "sent"), + "delivered": sum(1 for c in cards if c.status == "delivered"), + "received": sum(1 for c in cards if c.status == "received"), + } + return templates.TemplateResponse("dashboard.html", {"request": request, "user": user, "profiles": profiles, "stats": stats}) + + +# ---------- Profiles ---------- + +@router.get("/profiles", response_class=HTMLResponse) +def profile_list(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): + profiles = db.query(Profile).filter(Profile.user_id == user.id).all() + return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles}) + + +@router.post("/profiles") +def profile_create(request: Request, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): + db.add(Profile(user_id=user.id, nickname=nickname)) + db.commit() + return _redirect("/profiles") + + +@router.post("/profiles/{profile_id}/delete") +def profile_delete(profile_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): + p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first() + if p: + db.delete(p) + db.commit() + return _redirect("/profiles") + + +# ---------- Postcards ---------- + +@router.get("/profiles/{profile_id}/postcards", response_class=HTMLResponse) +def postcard_list( + request: Request, + profile_id: int, + status: str = "", + country: str = "", + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first() + if not profile: + return _redirect("/profiles") + q = db.query(Postcard).filter(Postcard.profile_id == profile_id) + if status: + q = q.filter(Postcard.status == status) + if country: + q = q.filter(Postcard.country.ilike(f"%{country}%")) + postcards = q.order_by(Postcard.created_at.desc()).all() + return templates.TemplateResponse( + "postcards.html", + {"request": request, "user": user, "profile": profile, "postcards": postcards, "status": status, "country": country}, + ) + + +@router.get("/profiles/{profile_id}/postcards/new", response_class=HTMLResponse) +def postcard_new(request: Request, profile_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): + profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first() + if not profile: + return _redirect("/profiles") + return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": profile, "postcard": None}) + + +@router.post("/profiles/{profile_id}/postcards") +def postcard_create( + request: Request, + profile_id: int, + card_number: str = Form(...), + status: str = Form(...), + recipient_name: str = Form(""), + country: str = Form(""), + send_time: str = Form(""), + arrival_time: str = Form(""), + sender_name: str = Form(""), + receive_time: str = Form(""), + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first() + if not profile: + return _redirect("/profiles") + + def _parse_dt(s: str) -> datetime | None: + s = s.strip() + if not s: + return None + try: + return datetime.fromisoformat(s) + except ValueError: + return None + + pc = Postcard( + profile_id=profile_id, + card_number=card_number, + status=status, + recipient_name=recipient_name or None, + country=country or None, + send_time=_parse_dt(send_time), + arrival_time=_parse_dt(arrival_time), + sender_name=sender_name or None, + receive_time=_parse_dt(receive_time), + ) + db.add(pc) + db.commit() + return _redirect(f"/profiles/{profile_id}/postcards") + + +@router.get("/postcards/{postcard_id}", response_class=HTMLResponse) +def postcard_detail(request: Request, postcard_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): + pc = ( + db.query(Postcard) + .join(Profile) + .filter(Postcard.id == postcard_id, Profile.user_id == user.id) + .first() + ) + if not pc: + return _redirect("/profiles") + return templates.TemplateResponse("postcard_detail.html", {"request": request, "user": user, "postcard": pc}) + + +@router.get("/postcards/{postcard_id}/edit", response_class=HTMLResponse) +def postcard_edit(request: Request, postcard_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): + pc = ( + db.query(Postcard) + .join(Profile) + .filter(Postcard.id == postcard_id, Profile.user_id == user.id) + .first() + ) + if not pc: + return _redirect("/profiles") + return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": pc.profile, "postcard": pc}) + + +@router.post("/postcards/{postcard_id}/edit") +def postcard_update( + request: Request, + postcard_id: int, + card_number: str = Form(...), + status: str = Form(...), + recipient_name: str = Form(""), + country: str = Form(""), + send_time: str = Form(""), + arrival_time: str = Form(""), + sender_name: str = Form(""), + receive_time: str = Form(""), + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + pc = ( + db.query(Postcard) + .join(Profile) + .filter(Postcard.id == postcard_id, Profile.user_id == user.id) + .first() + ) + if not pc: + return _redirect("/profiles") + + def _parse_dt(s: str) -> datetime | None: + s = s.strip() + if not s: + return None + try: + return datetime.fromisoformat(s) + except ValueError: + return None + + pc.card_number = card_number + pc.status = status + pc.recipient_name = recipient_name or None + pc.country = country or None + pc.send_time = _parse_dt(send_time) + pc.arrival_time = _parse_dt(arrival_time) + pc.sender_name = sender_name or None + pc.receive_time = _parse_dt(receive_time) + db.commit() + return _redirect(f"/postcards/{postcard_id}") + + +@router.post("/postcards/{postcard_id}/delete") +def postcard_delete(postcard_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): + pc = ( + db.query(Postcard) + .join(Profile) + .filter(Postcard.id == postcard_id, Profile.user_id == user.id) + .first() + ) + if not pc: + return _redirect("/profiles") + pid = pc.profile_id + db.delete(pc) + db.commit() + return _redirect(f"/profiles/{pid}/postcards") + + +# ---------- Image Upload ---------- + +@router.post("/postcards/{postcard_id}/image/{side}") +async def upload_image( + postcard_id: int, + side: str, + file: UploadFile = File(...), + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + if side not in ("front", "back"): + return _redirect("/profiles") + pc = ( + db.query(Postcard) + .join(Profile) + .filter(Postcard.id == postcard_id, Profile.user_id == user.id) + .first() + ) + if not pc: + return _redirect("/profiles") + ext = Path(file.filename or "upload.jpg").suffix or ".jpg" + filename = f"{uuid4().hex}{ext}" + dest = UPLOAD_DIR / filename + content = await file.read() + dest.write_bytes(content) + setattr(pc, f"image_{side}", f"/uploads/{filename}") + db.commit() + return _redirect(f"/postcards/{postcard_id}") + + +# ---------- API Keys ---------- + +@router.get("/api-keys", response_class=HTMLResponse) +def api_keys_page(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): + keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).all() + return templates.TemplateResponse("api_keys.html", {"request": request, "user": user, "keys": keys}) + + +@router.post("/api-keys") +def api_key_create(user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): + ak = ApiKey(user_id=user.id, key=ApiKey.generate_key()) + db.add(ak) + db.commit() + return _redirect("/api-keys") + + +@router.post("/api-keys/{key_id}/delete") +def api_key_delete(key_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): + ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first() + if ak: + db.delete(ak) + db.commit() + return _redirect("/api-keys") diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..c44d73c --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,82 @@ +from datetime import datetime + +from pydantic import BaseModel + + +# ---------- Auth ---------- + +class RegisterRequest(BaseModel): + username: str + password: str + + +class LoginRequest(BaseModel): + username: str + password: str + + +# ---------- Profile ---------- + +class ProfileCreate(BaseModel): + nickname: str + + +class ProfileOut(BaseModel): + id: int + nickname: str + created_at: datetime + + model_config = {"from_attributes": True} + + +# ---------- Postcard ---------- + +class PostcardCreate(BaseModel): + card_number: str + status: str # sent / delivered / received + recipient_name: str | None = None + country: str | None = None + send_time: datetime | None = None + arrival_time: datetime | None = None + sender_name: str | None = None + receive_time: datetime | None = None + + +class PostcardUpdate(BaseModel): + card_number: str | None = None + status: str | None = None + recipient_name: str | None = None + country: str | None = None + send_time: datetime | None = None + arrival_time: datetime | None = None + sender_name: str | None = None + receive_time: datetime | None = None + + +class PostcardOut(BaseModel): + id: int + profile_id: int + card_number: str + status: str + recipient_name: str | None + country: str | None + send_time: datetime | None + arrival_time: datetime | None + sender_name: str | None + receive_time: datetime | None + image_front: str | None + image_back: str | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +# ---------- ApiKey ---------- + +class ApiKeyOut(BaseModel): + id: int + key: str + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..fe118ae --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,118 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --primary: #2563eb; + --primary-hover: #1d4ed8; + --danger: #dc2626; + --danger-hover: #b91c1c; + --bg: #f8fafc; + --card-bg: #fff; + --border: #e2e8f0; + --text: #1e293b; + --text-muted: #64748b; + --sent: #f59e0b; + --delivered: #10b981; + --received: #8b5cf6; +} + +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; } + +a { color: var(--primary); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* Navbar */ +.navbar { display: flex; align-items: center; justify-content: space-between; padding: .75rem 1.5rem; background: #fff; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; } +.nav-brand { font-size: 1.15rem; font-weight: 700; color: var(--text); } +.nav-brand:hover { text-decoration: none; } +.nav-links { display: flex; gap: 1.25rem; align-items: center; } +.nav-links a { color: var(--text-muted); font-size: .9rem; } +.nav-links a:hover { color: var(--primary); text-decoration: none; } +.nav-logout { color: var(--danger) !important; } + +/* Container */ +.container { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; } + +/* Auth forms */ +.auth-form { max-width: 360px; margin: 4rem auto; background: var(--card-bg); padding: 2rem; border-radius: 8px; border: 1px solid var(--border); } +.auth-form h2 { margin-bottom: 1.25rem; } +.auth-link { margin-top: 1rem; text-align: center; font-size: .9rem; } + +/* Forms */ +label { display: block; margin-top: .75rem; font-size: .85rem; font-weight: 500; color: var(--text-muted); } +input[type="text"], input[type="password"], input[type="datetime-local"], select, textarea { + width: 100%; padding: .5rem .75rem; margin-top: .25rem; border: 1px solid var(--border); border-radius: 6px; font-size: .95rem; background: #fff; +} +input:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.1); } +.form-card { background: var(--card-bg); padding: 1.5rem; border-radius: 8px; border: 1px solid var(--border); } +.form-actions { display: flex; gap: .75rem; margin-top: 1.25rem; } +.form-row { display: flex; gap: .5rem; align-items: center; } +.inline-form { margin-bottom: 1.25rem; } + +/* Buttons */ +.btn { display: inline-block; padding: .5rem 1rem; border: 1px solid var(--border); border-radius: 6px; background: #fff; cursor: pointer; font-size: .9rem; text-align: center; } +.btn:hover { background: var(--bg); text-decoration: none; } +.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); } +.btn-primary:hover { background: var(--primary-hover); } +.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); } +.btn-danger:hover { background: var(--danger-hover); } +.btn-sm { padding: .25rem .6rem; font-size: .8rem; } + +/* Alert */ +.alert { padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .9rem; } +.alert-error { background: #fef2f2; color: var(--danger); border: 1px solid #fecaca; } + +/* Table */ +table { width: 100%; border-collapse: collapse; background: var(--card-bg); border-radius: 8px; overflow: hidden; border: 1px solid var(--border); } +th, td { padding: .65rem 1rem; text-align: left; border-bottom: 1px solid var(--border); font-size: .9rem; } +th { background: var(--bg); font-weight: 600; color: var(--text-muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; } +tr:last-child td { border-bottom: none; } +code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-size: .85rem; word-break: break-all; } +.inline { display: inline; } + +/* Section */ +.section { margin-bottom: 2rem; } +.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } + +/* Card grid */ +.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; } +.card { display: block; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; transition: box-shadow .15s; } +.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.06); text-decoration: none; } +.card h3 { margin-bottom: .5rem; } +.stats { display: flex; gap: .75rem; font-size: .85rem; color: var(--text-muted); flex-wrap: wrap; } +.stat { white-space: nowrap; } + +/* Postcard grid */ +.postcard-card { display: flex; gap: 1rem; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: .75rem; transition: box-shadow .15s; } +.postcard-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.06); text-decoration: none; } +.thumb { width: 80px; height: 60px; object-fit: cover; border-radius: 4px; flex-shrink: 0; } +.thumb.placeholder { display: flex; align-items: center; justify-content: center; background: var(--bg); font-size: 1.5rem; } +.postcard-info { display: flex; flex-direction: column; gap: .25rem; font-size: .9rem; } + +/* Badge */ +.badge { display: inline-block; padding: .2rem .6rem; border-radius: 999px; font-size: .75rem; font-weight: 600; color: #fff; } +.badge-sent { background: var(--sent); } +.badge-delivered { background: var(--delivered); } +.badge-received { background: var(--received); } + +/* Detail page */ +.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-top: 1.5rem; } +.detail-images { display: flex; flex-direction: column; gap: 1.5rem; } +.image-block h3 { margin-bottom: .5rem; font-size: .95rem; } +.detail-img { width: 100%; max-width: 400px; border-radius: 8px; border: 1px solid var(--border); } +.upload-form { margin-top: .5rem; display: flex; gap: .5rem; align-items: center; } +.upload-form input[type="file"] { flex: 1; font-size: .85rem; } +.detail-meta dl { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; } +.detail-meta dt { font-weight: 600; font-size: .85rem; color: var(--text-muted); } +.detail-meta dd { font-size: .95rem; } + +/* Filter */ +.filter-bar { margin-bottom: 1.25rem; } + +/* Empty state */ +.empty { color: var(--text-muted); padding: 2rem; text-align: center; } + +/* Responsive */ +@media (max-width: 640px) { + .detail-grid { grid-template-columns: 1fr; } + .card-grid { grid-template-columns: 1fr; } +} diff --git a/app/templates/api_keys.html b/app/templates/api_keys.html new file mode 100644 index 0000000..e169a14 --- /dev/null +++ b/app/templates/api_keys.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}API Keys - Postcard Manager{% endblock %} +{% block content %} +
+

API Keys

+
+ +
+
+{% if keys %} + + + + {% for k in keys %} + + + + + + {% endfor %} + +
Key创建时间操作
{{ k.key }}{{ k.created_at.strftime('%Y-%m-%d %H:%M') }} +
+ +
+
+{% else %} +

还没有 API Key,点击上方按钮创建一个。

+{% endif %} +
+

使用说明

+
curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:8000/api/profiles
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..04c1690 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,24 @@ + + + + + + {% block title %}Postcard Manager{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..ec85045 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Dashboard - Postcard Manager{% endblock %} +{% block content %} +

👋 欢迎,{{ user.username }}

+
+
+

Profiles

+
+ {% if profiles %} + + {% else %} +

还没有 Profile,去创建一个

+ {% endif %} +
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..de97472 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}登录 - Postcard Manager{% endblock %} +{% block content %} +
+

登录

+ {% if error %} +
{{ error }}
+ {% endif %} +
+ + + + + +
+ +
+{% endblock %} diff --git a/app/templates/postcard_detail.html b/app/templates/postcard_detail.html new file mode 100644 index 0000000..f2bb2b9 --- /dev/null +++ b/app/templates/postcard_detail.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}明信片详情 - Postcard Manager{% endblock %} +{% block content %} +
+

{{ postcard.card_number }}

+
+ 编辑 +
+ +
+
+
+{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) }} +
+
+
+

正面

+ {% if postcard.image_front %} + 正面 + {% endif %} +
+ + +
+
+
+

反面

+ {% if postcard.image_back %} + 反面 + {% endif %} +
+ + +
+
+
+
+
+
编号
{{ postcard.card_number }}
+
状态
{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) }}
+ {% if postcard.status in ('sent','delivered') %} +
收件人
{{ postcard.recipient_name or '-' }}
+
国家/地区
{{ postcard.country or '-' }}
+
寄出时间
{{ postcard.send_time.strftime('%Y-%m-%d %H:%M') if postcard.send_time else '-' }}
+
到达时间
{{ postcard.arrival_time.strftime('%Y-%m-%d %H:%M') if postcard.arrival_time else '-' }}
+ {% else %} +
发件人
{{ postcard.sender_name or '-' }}
+
收到时间
{{ postcard.receive_time.strftime('%Y-%m-%d %H:%M') if postcard.receive_time else '-' }}
+ {% endif %} +
所属 Profile
{{ postcard.profile.nickname }}
+
+ ← 返回列表 +
+
+{% endblock %} diff --git a/app/templates/postcard_form.html b/app/templates/postcard_form.html new file mode 100644 index 0000000..b99d999 --- /dev/null +++ b/app/templates/postcard_form.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}{{ '编辑' if postcard else '新建' }}明信片 - Postcard Manager{% endblock %} +{% block content %} +
+

{{ '编辑' if postcard else '新建' }}明信片

+
+
+ + + + + + +
+ + + + + + + + +
+ + + +
+ + 取消 +
+
+ +{% endblock %} diff --git a/app/templates/postcards.html b/app/templates/postcards.html new file mode 100644 index 0000000..92a05db --- /dev/null +++ b/app/templates/postcards.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}{{ profile.nickname }} - Postcard Manager{% endblock %} +{% block content %} +
+

{{ profile.nickname }} 的明信片

+ + 新明信片 +
+
+
+ + + +
+
+{% if postcards %} +
+ {% for pc in postcards %} + + {% if pc.image_front %} + 正面 + {% else %} +
📷
+ {% endif %} +
+ {{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status, pc.status) }} + {{ pc.card_number }} + {% if pc.status in ('sent','delivered') %} + → {{ pc.recipient_name or '-' }} ({{ pc.country or '-' }}) + {% else %} + ← {{ pc.sender_name or '-' }} + {% endif %} +
+
+ {% endfor %} +
+{% else %} +

没有找到明信片。

+{% endif %} +{% endblock %} diff --git a/app/templates/profiles.html b/app/templates/profiles.html new file mode 100644 index 0000000..575a93c --- /dev/null +++ b/app/templates/profiles.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Profiles - Postcard Manager{% endblock %} +{% block content %} +
+

Profiles

+
+
+
+ + +
+
+{% if profiles %} + + + + {% for p in profiles %} + + + + + + {% endfor %} + +
昵称创建时间操作
{{ p.nickname }}{{ p.created_at.strftime('%Y-%m-%d %H:%M') }} +
+ +
+
+{% else %} +

还没有 Profile。

+{% endif %} +{% endblock %} diff --git a/app/templates/register.html b/app/templates/register.html new file mode 100644 index 0000000..5554fba --- /dev/null +++ b/app/templates/register.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}注册 - Postcard Manager{% endblock %} +{% block content %} +
+

注册

+ {% if error %} +
{{ error }}
+ {% endif %} +
+ + + + + + + +
+ +
+{% endblock %} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ae631c7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn[standard] +sqlalchemy +python-multipart +jinja2 +bcrypt +python-jose[cryptography] +aiofiles