From a071b3dde4f7f6f64f18bdc4163cff0c6daf98eb Mon Sep 17 00:00:00 2001 From: Zichao Lin Date: Tue, 7 Jul 2026 12:32:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(postcard):=20profile=20country,=20country?= =?UTF-8?q?=5Ffrom,=20per-user=20card=20number=20uniqueness,=20public=20pa?= =?UTF-8?q?ge=20overhaul=20=20-=20Profile:=20add=20country=20field=20for?= =?UTF-8?q?=20default=20origin=20country=20-=20Postcard:=20add=20country?= =?UTF-8?q?=5Ffrom=20field,=20auto-fill=20from=20profile.country=20on=20cr?= =?UTF-8?q?eate=20-=20card=5Fnumber=20uniqueness:=20change=20from=20global?= =?UTF-8?q?=20to=20per-user=20(profile=5Fid=20+=20card=5Fnumber)=20-=20Pub?= =?UTF-8?q?lic=20pages:=20remove=20card=5Fnumber=20display,=20only=20show?= =?UTF-8?q?=20cards=20with=20image=5Ffront=20-=20Public=20pages:=20show=20?= =?UTF-8?q?country=20as=20from=E2=86=92to=20format=20-=20Index=20page:=20n?= =?UTF-8?q?o=20time=20display,=20add=20register=20button,=20fix=20lightbox?= =?UTF-8?q?=20with=20prev/next=20-=20Infinite=20scroll:=20add=20/api/publi?= =?UTF-8?q?c/cards=20endpoints=20and=20IntersectionObserver=20-=20Internal?= =?UTF-8?q?=20pages:=20update=20postcards=20list/detail=20for=20country=5F?= =?UTF-8?q?from=20display=20-=20i18n:=20add=20profiles.country=5Fph,=20pc?= =?UTF-8?q?=5Fform.country=5Ffrom,=20pc=5Fdetail.route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/locales/en.json | 4 + app/locales/zh.json | 4 + app/models.py | 13 +- app/routers/api.py | 24 +++- app/routers/web.py | 119 +++++++++++++++++-- app/schemas.py | 10 ++ app/templates/index.html | 130 +++++++++++++++++--- app/templates/postcard_detail.html | 3 +- app/templates/postcard_form.html | 8 +- app/templates/postcards.html | 2 +- app/templates/profiles.html | 3 + app/templates/showcase.html | 184 ++++++++++++++++++++--------- 12 files changed, 414 insertions(+), 90 deletions(-) diff --git a/app/locales/en.json b/app/locales/en.json index 53daf3e..5ef955e 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -6,6 +6,7 @@ "index.title": "Mailova", "index.subtitle": "Postcards from around the world", "index.go_login": "Login", + "index.go_register": "Register", "index.empty": "No public postcards yet", "nav.profiles": "Profiles", "nav.settings": "Settings", @@ -64,6 +65,7 @@ "profiles.total": "Total", "profiles.countries": "Countries", "profiles.notes_ph": "Notes (private)", + "profiles.country_ph": "Country", "profiles.save": "Save", "profiles.created_at": "Created", "profiles.view": "View", @@ -96,6 +98,7 @@ "pc_detail.status": "Status", "pc_detail.recipient": "Recipient", "pc_detail.country": "Country", + "pc_detail.route": "Route", "pc_detail.send_time": "Sent date", "pc_detail.arrival_time": "Arrival date", "pc_detail.sender": "Sender", @@ -112,6 +115,7 @@ "pc_form.status": "Status", "pc_form.recipient": "Recipient", "pc_form.country": "Country", + "pc_form.country_from": "From", "pc_form.send_time": "Sent date", "pc_form.arrival_time": "Arrival date", "pc_form.sender": "Sender", diff --git a/app/locales/zh.json b/app/locales/zh.json index 12e7447..d5ac648 100644 --- a/app/locales/zh.json +++ b/app/locales/zh.json @@ -6,6 +6,7 @@ "index.title": "星笺", "index.subtitle": "来自世界各地的明信片", "index.go_login": "登录", + "index.go_register": "注册", "index.empty": "暂无公开明信片", "nav.profiles": "Profile", "nav.settings": "设置", @@ -64,6 +65,7 @@ "profiles.total": "总计", "profiles.countries": "国家", "profiles.notes_ph": "备注(仅自己可见)", + "profiles.country_ph": "国家", "profiles.save": "保存", "profiles.created_at": "创建于", "profiles.view": "查看", @@ -96,6 +98,7 @@ "pc_detail.status": "状态", "pc_detail.recipient": "收件人", "pc_detail.country": "国家/地区", + "pc_detail.route": "路线", "pc_detail.send_time": "寄出时间", "pc_detail.arrival_time": "到达时间", "pc_detail.sender": "发件人", @@ -112,6 +115,7 @@ "pc_form.status": "状态", "pc_form.recipient": "收件人", "pc_form.country": "国家/地区", + "pc_form.country_from": "寄出地", "pc_form.send_time": "寄出时间", "pc_form.arrival_time": "到达时间", "pc_form.sender": "发件人", diff --git a/app/models.py b/app/models.py index ac0e5f3..7dfe254 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import relationship, validates from app.database import Base @@ -61,6 +61,7 @@ class Profile(Base): 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) + country = Column(String(2), nullable=True) showcase_enabled = Column(default=False, nullable=False) showcase_visible = Column(String(16), nullable=False, default="both") notes = Column(Text, nullable=False, default="") @@ -72,14 +73,18 @@ class Profile(Base): class Postcard(Base): __tablename__ = "postcards" + __table_args__ = ( + UniqueConstraint("profile_id", "card_number", name="uq_postcards_profile_card_number"), + ) 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, unique=True) + card_number = Column(String(64), nullable=False) status = Column(String(16), nullable=False) # sent & delivered recipient_name = Column(String(64)) country = Column(String(64)) + country_from = Column(String(2)) send_time = Column(DateTime) arrival_time = Column(DateTime) # received @@ -100,6 +105,10 @@ class Postcard(Base): def _upper_country(self, key, value): return value.strip().upper() if value else None + @validates("country_from") + def _upper_country_from(self, key, value): + return value.strip().upper() if value else None + class ApiKey(Base): __tablename__ = "api_keys" diff --git a/app/routers/api.py b/app/routers/api.py index 6e8d5c8..a168e05 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -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: diff --git a/app/routers/web.py b/app/routers/web.py index 4669837..f33e14a 100644 --- a/app/routers/web.py +++ b/app/routers/web.py @@ -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"): diff --git a/app/schemas.py b/app/schemas.py index 4bd06f7..8bc54df 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -7,11 +7,18 @@ from pydantic import BaseModel 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 created_at: datetime model_config = {"from_attributes": True} @@ -24,6 +31,7 @@ class PostcardCreate(BaseModel): status: str recipient_name: str | None = None country: str | None = None + country_from: str | None = None send_time: datetime | None = None arrival_time: datetime | None = None sender_name: str | None = None @@ -35,6 +43,7 @@ class PostcardUpdate(BaseModel): status: str | None = None recipient_name: str | None = None country: str | None = None + country_from: str | None = None send_time: datetime | None = None arrival_time: datetime | None = None sender_name: str | None = None @@ -48,6 +57,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 diff --git a/app/templates/index.html b/app/templates/index.html index dd70485..7a5a8fd 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -12,7 +12,8 @@ {% endfor %} - {{ 'index.go_login'|t(lang) }} + {{ 'index.go_login'|t(lang) }} + {{ 'index.go_register'|t(lang) }} {% endblock %} @@ -22,25 +23,30 @@

{{ 'index.subtitle'|t(lang) }}

-{% if postcards %} -
- {% for pc in postcards %} -
- {% if pc.image_front %} +{% set visible = [] %} +{% for pc in postcards %} + {% if pc.image_front %} + {% if visible.append(pc) %}{% endif %} + {% endif %} +{% endfor %} + +{% if visible|length > 0 or has_more %} +
+ {% for pc in visible %} +
- {% else %} -
📷
- {% endif %}
- {{ pc.card_number }}
- {{ pc.country|flag }} {{ pc.country or '' }} - {% if pc.send_time %}{{ pc.send_time.strftime('%Y-%m-%d') }}{% endif %} + {% if pc.country_from and pc.country %}{{ pc.country_from|flag }}→{{ pc.country|flag }} + {% elif pc.country %}{{ pc.country|flag }} + {% endif %}
{% endfor %}
+
+ {% else %}

{{ 'index.empty'|t(lang) }}

@@ -49,26 +55,116 @@