feat(postcard): profile country, country_from, per-user card number uniqueness, public page overhaul - Profile: add country field for default origin country - Postcard: add country_from field, auto-fill from profile.country on create - card_number uniqueness: change from global to per-user (profile_id + card_number) - Public pages: remove card_number display, only show cards with image_front - Public pages: show country as from→to format - Index page: no time display, add register button, fix lightbox with prev/next - Infinite scroll: add /api/public/cards endpoints and IntersectionObserver - Internal pages: update postcards list/detail for country_from display - i18n: add profiles.country_ph, pc_form.country_from, pc_detail.route

This commit is contained in:
2026-07-07 12:32:24 +08:00
parent bd664d23be
commit a071b3dde4
12 changed files with 414 additions and 90 deletions

View File

@@ -81,13 +81,16 @@ def get_api_user_optional(
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
@@ -101,6 +104,7 @@ class PostcardCreate(BaseModel):
status: str
recipient_name: str | None = None
country: str | None = None
country_from: str | None = None
send_time: str | None = None
arrival_time: str | None = None
sender_name: str | None = None
@@ -112,6 +116,7 @@ class PostcardUpdate(BaseModel):
status: str | None = None
recipient_name: str | None = None
country: str | None = None
country_from: str | None = None
send_time: str | None = None
arrival_time: str | None = None
sender_name: str | None = None
@@ -125,6 +130,7 @@ class PostcardOut(BaseModel):
status: str
recipient_name: str | None
country: str | None
country_from: str | None
send_time: datetime | None
arrival_time: datetime | None
sender_name: str | None
@@ -205,7 +211,8 @@ def create_profile(
db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
):
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id)
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)
@@ -231,6 +238,8 @@ def update_profile(
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
@@ -275,7 +284,10 @@ def create_postcard(
):
_profile_or_404(ak.user, profile_id, db)
cn = body.card_number.strip()
if db.query(Postcard).filter(Postcard.card_number == cn).first():
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,
@@ -283,6 +295,7 @@ def create_postcard(
status=body.status,
recipient_name=body.recipient_name,
country=body.country.strip().upper() if body.country else None,
country_from=body.country_from.strip().upper() if body.country_from else None,
send_time=_parse_date(body.send_time),
arrival_time=_parse_date(body.arrival_time),
sender_name=body.sender_name,
@@ -320,12 +333,17 @@ def update_postcard(
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).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
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 is not None:
pc.country = body.country.strip().upper() if body.country 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:

View File

@@ -4,7 +4,7 @@ from random import shuffle
from uuid import uuid4
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
@@ -126,7 +126,8 @@ def index_page(request: Request, db: Session = Depends(get_db)):
).all()
for p in profiles:
cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all()
for c in cards:
visible = True
@@ -147,7 +148,92 @@ def index_page(request: Request, db: Session = Depends(get_db)):
lang = "zh"
return templates.TemplateResponse("index.html", {
"request": request, "postcards": all_postcards, "lang": lang,
"request": request, "postcards": all_postcards[:20], "lang": lang,
"has_more": len(all_postcards) > 20,
})
@router.get("/api/public/cards")
def api_public_cards(
request: Request,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
):
all_cards: list[Postcard] = []
users = db.query(User).filter(User.showcase_mode.isnot(None)).all()
for u in users:
profiles = db.query(Profile).filter(
Profile.user_id == u.id, Profile.showcase_enabled == True
).all()
for p in profiles:
cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all()
for c in cards:
vis = True
if c.status in ("sent", "delivered") and p.showcase_visible not in ("sent", "both"):
vis = False
if c.status == "received" and p.showcase_visible not in ("received", "both"):
vis = False
if vis:
all_cards.append(c)
shuffle(all_cards)
total = len(all_cards)
start = (page - 1) * limit
batch = all_cards[start:start + limit]
return JSONResponse({
"total": total,
"page": page,
"has_more": start + limit < total,
"cards": [{
"image_front": c.image_front,
"country_from": c.country_from,
"country": c.country,
"status": c.status,
} for c in batch],
})
@router.get("/api/public/cards/{username}")
def api_public_cards_user(
username: str,
request: Request,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
tab: str = Query("sent"),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.username == username).first()
if not user:
return JSONResponse({"error": "not found"}, status_code=404)
profiles = db.query(Profile).filter(
Profile.user_id == user.id, Profile.showcase_enabled == True
).all()
all_cards: list[Postcard] = []
for p in profiles:
cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all()
if tab == "sent" and p.showcase_visible in ("sent", "both"):
all_cards.extend([c for c in cards if c.status in ("sent", "delivered")])
elif tab == "received" and p.showcase_visible in ("received", "both"):
all_cards.extend([c for c in cards if c.status == "received"])
all_cards.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
total = len(all_cards)
start = (page - 1) * limit
batch = all_cards[start:start + limit]
return JSONResponse({
"total": total,
"page": page,
"has_more": start + limit < total,
"cards": [{
"image_front": c.image_front,
"country_from": c.country_from,
"country": c.country,
} for c in batch],
})
@@ -565,8 +651,9 @@ def profile_list(request: Request, user: User = Depends(get_current_user_web), d
@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))
def profile_create(request: Request, nickname: str = Form(...), country: str = Form(""), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
c = country.strip().upper() if country else None
db.add(Profile(user_id=user.id, nickname=nickname, country=c))
db.commit()
return _redirect("/profiles")
@@ -581,10 +668,11 @@ def profile_delete(profile_id: int, user: User = Depends(get_current_user_web),
@router.post("/profiles/{profile_id}/rename")
def profile_rename(profile_id: int, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
def profile_rename(profile_id: int, nickname: str = Form(...), country: str = Form(""), 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:
p.nickname = nickname
p.country = country.strip().upper() if country else None
db.commit()
return _redirect("/profiles")
@@ -649,6 +737,7 @@ def postcard_create(
status: str = Form(...),
recipient_name: str = Form(""),
country: str = Form(""),
country_from: str = Form(""),
send_time: str = Form(""),
arrival_time: str = Form(""),
sender_name: str = Form(""),
@@ -662,7 +751,10 @@ def postcard_create(
return _redirect("/profiles")
cn = card_number.strip()
if db.query(Postcard).filter(Postcard.card_number == cn).first():
dup = db.query(Postcard).join(Profile).filter(
Postcard.card_number == cn, Profile.user_id == user.id
).first()
if dup:
return templates.TemplateResponse("postcard_form.html", {
"request": request, "user": user, "profile": profile, "postcard": None,
"error": _t("pc_list.duplicate", user.language),
@@ -686,6 +778,7 @@ def postcard_create(
status=status,
recipient_name=recipient_name or None,
country=(country.strip().upper() if country else None),
country_from=(country_from.strip().upper() if country_from else profile.country),
send_time=_parse_dt(send_time),
arrival_time=_parse_dt(arrival_time),
sender_name=sender_name or None,
@@ -731,6 +824,7 @@ def postcard_update(
status: str = Form(...),
recipient_name: str = Form(""),
country: str = Form(""),
country_from: str = Form(""),
send_time: str = Form(""),
arrival_time: str = Form(""),
sender_name: str = Form(""),
@@ -749,7 +843,10 @@ def postcard_update(
return _redirect("/profiles")
cn = card_number.strip()
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
dup = db.query(Postcard).join(Profile).filter(
Postcard.card_number == cn, Postcard.id != postcard_id,
Profile.user_id == user.id
).first()
if dup:
return templates.TemplateResponse("postcard_form.html", {
"request": request, "user": user, "profile": pc.profile, "postcard": pc,
@@ -772,6 +869,7 @@ def postcard_update(
pc.status = status
pc.recipient_name = recipient_name or None
pc.country = country or None
pc.country_from = country_from.strip().upper() if country_from else pc.country_from
pc.send_time = _parse_dt(send_time)
pc.arrival_time = None if status == "sent" else _parse_dt(arrival_time)
pc.sender_name = sender_name or None
@@ -925,7 +1023,10 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d
profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all()
sent_all, received_all, profile_groups = [], [], []
for p in profiles:
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
cards = db.query(Postcard).filter(
Postcard.profile_id == p.id, Postcard.showcase_hidden == False,
Postcard.image_front.isnot(None), Postcard.image_front != ""
).all()
if p.showcase_visible in ("sent", "both"):
sent_all.extend([c for c in cards if c.status in ("sent", "delivered")])
if p.showcase_visible in ("received", "both"):