Compare commits

..
104 Commits
Author SHA1 Message Date
earthjasonlin 24532a4fc0 feat(showcase): add temporary language switcher for visitors with cookie persistence 2026-07-07 09:57:20 +08:00
earthjasonlin 44d8ed4f75 feat(ui): replace language dropdown with globe icon and expandable menu 2026-07-07 09:54:57 +08:00
earthjasonlin ca9b3cc8dc refactor(i18n): move translations to JSON files under app/locales, dynamic language switch 2026-07-07 09:46:50 +08:00
earthjasonlin 969d8a1b18 fix(api-docs): replace all hardcoded Chinese with i18n translation keys for English support 2026-07-07 09:33:01 +08:00
earthjasonlin 504eef819e fix(api-docs): remove inline-block override on settings link button for proper text centering 2026-07-07 09:28:58 +08:00
earthjasonlin 5c1e89290d feat(api): add public RESTful API with API key auth and documentation page 2026-07-07 09:22:11 +08:00
earthjasonlin 69a2ab3e7e fix(postcards): remove card number format restriction and forced uppercase 2026-07-06 23:14:12 +08:00
earthjasonlin 7e99ce9f08 feat(i18n): add English localization with per-user language preference 2026-07-06 22:41:09 +08:00
earthjasonlin 261d53b91f fix(dashboard): extract labels/data from tuple list for Chart.js 2026-07-06 22:13:46 +08:00
earthjasonlin 4c9c6f6e05 feat(i18n): add English localization with per-user language preference and navbar toggle 2026-07-06 22:07:05 +08:00
earthjasonlin e178b93d69 fix(detail): prevent dt labels from wrapping, allow dd values to wrap 2026-07-06 21:13:37 +08:00
earthjasonlin 4cd2a2c38c fix(postcards): move notes to right side before action buttons in list, use dl grid format in detail page 2026-07-06 21:13:01 +08:00
earthjasonlin e875e90161 style: set CJK+English fonts, reduce base font size to 14px, lighten text colors 2026-07-06 21:10:59 +08:00
earthjasonlin da7ebe45ed feat(postcards): add notes field to postcards with edit, detail, and truncated list display 2026-07-06 21:07:28 +08:00
earthjasonlin 1a6424ebce feat(dashboard): split country stats into sent/received bar charts side by side 2026-07-06 20:41:39 +08:00
earthjasonlin b3f1d07b7c revert(dashboard): restore Chart.js bar chart for country distribution 2026-07-06 20:35:43 +08:00
earthjasonlin 18da45240a feat(dashboard): replace Chart.js with pure HTML/CSS bar chart, flags render correctly 2026-07-06 20:34:52 +08:00
earthjasonlin 7620d453b7 feat(dashboard): add country distribution bar chart using Chart.js 2026-07-06 20:20:46 +08:00
earthjasonlin 0f01cc263d feat: auto-trim whitespace from text inputs on form submit globally 2026-07-06 20:10:54 +08:00
earthjasonlin 47a9c6a7ad fix(postcards): auto-fill today date on page load for new postcards 2026-07-06 20:09:28 +08:00
earthjasonlin e1e8199375 feat(postcards): auto-fill today date on status change, card_number always uppercase 2026-07-06 20:06:42 +08:00
earthjasonlin a62eb4cc58 fix(profiles): remove duplicate border, center-align textarea/button; notes read-only on postcards page 2026-07-06 19:41:21 +08:00
earthjasonlin 189c3718df feat(profiles): notes shown on postcards page, save stays on current page 2026-07-06 19:39:54 +08:00
earthjasonlin 2989837b7d feat(profiles): add private notes field per profile, visible only to owner 2026-07-06 19:36:29 +08:00
earthjasonlin a19f6ccac9 chore: startup scripts one-click launch uvicorn server 2026-07-06 19:31:55 +08:00
earthjasonlin 58386e836a fix(admin-invites): badge default bg color, registered users as plain text list 2026-07-06 08:43:07 +08:00
earthjasonlin c6b5b68f25 chore: remove test_error.py 2026-07-06 08:39:38 +08:00
earthjasonlin ef06cd29dc fix(models): resolve ambiguous foreign keys between User and InviteCode 2026-07-06 08:39:33 +08:00
earthjasonlin 48da00c8e7 feat(admin-invites): show registered usernames per invite code, track invited_by_code_id on User 2026-07-06 08:37:49 +08:00
earthjasonlin 46753a7c48 fix(admin-invites): left-align create card conditional fields, convert local datetime to UTC on save 2026-07-06 08:35:55 +08:00
earthjasonlin 5fdd0d5ed2 fix(admin-invites): align create card conditional fields styling with edit card 2026-07-06 08:33:56 +08:00
earthjasonlin 5ba89eee4f fix(admin-invites): create form conditional fields inline with nowrap labels, match input styling 2026-07-06 08:32:36 +08:00
earthjasonlin 028a4d3a05 fix(admin-invites): create form label margin-top:0, edit form inline conditional fields 2026-07-06 08:27:34 +08:00
earthjasonlin 202c1804af fix(invites): register strftime Jinja2 filter, fix 500 error on admin invites page 2026-07-06 08:24:20 +08:00
earthjasonlin a34b46b4b1 fix(admin-invites): create card uses form-card style, align save/delete with select via align-self flex-end 2026-07-06 08:21:54 +08:00
earthjasonlin 623549009e fix(admin-invites): display times in Asia/Shanghai (CST) instead of UTC 2026-07-06 08:20:14 +08:00
earthjasonlin 7481af6e4d fix(admin-invites): add margin-top to create button, align save/delete buttons bottom with select via invisible label 2026-07-06 08:18:51 +08:00
earthjasonlin e710ce020e fix(admin-invites): increase create form gap, align edit buttons bottom with select 2026-07-06 08:17:00 +08:00
earthjasonlin 65a7c38ca5 fix(admin-invites): unify button height/size, fit-content width for create button 2026-07-06 08:15:08 +08:00
earthjasonlin 2e0f15def2 fix(admin-invites): left-align all fields on mobile 2026-07-06 08:13:42 +08:00
earthjasonlin 9fbe5ce85f fix(admin-invites): left-align create button 2026-07-06 08:12:57 +08:00
earthjasonlin e77319da50 fix(admin-invites): stack create form fields vertically, unify row height and spacing 2026-07-06 08:12:25 +08:00
earthjasonlin 3287dae370 fix(admin-invites): unify form field styles, fix mobile layout for create and edit forms 2026-07-06 08:10:36 +08:00
earthjasonlin 2c0725b242 feat(invite): add invite code registration system 2026-07-06 01:13:07 +08:00
earthjasonlin a769ce7546 chore: rename app to 星笺 (Mailova) 2026-07-06 00:41:24 +08:00
earthjasonlin d8378e0acc feat(showcase): sort postcards by time descending 2026-07-06 00:27:39 +08:00
earthjasonlin 630772ae4b feat(showcase): lightbox, checkbox per-card toggle, lazyload, fix toggle Y-align, remove navbar on public page 2026-07-06 00:24:03 +08:00
earthjasonlin 185b743e05 fix(settings): use Query alias for tab param, fix showcase tab persistence 2026-07-06 00:10:30 +08:00
earthjasonlin d082a4b421 fix(showcase): tab stay, Y-center toggle, group postcards by sent/received, hide navbar on public page, remove profiles/xx/showcase 2026-07-06 00:05:15 +08:00
earthjasonlin a69c6c10c9 refactor(ui): merge showcase settings into settings page tab, fix profile showcase GET route, fix layout 2026-07-05 23:56:28 +08:00
earthjasonlin 9f4f57b80a feat(showcase): add public showcase page with per-profile/per-postcard visibility controls 2026-07-05 23:45:23 +08:00
earthjasonlin 2f770a3185 feat(models): add showcase fields to User, Profile, Postcard 2026-07-05 23:35:45 +08:00
earthjasonlin 8df3b30f55 fix(auth): set session expires_at to non-nullable with 1 year max 2026-07-05 23:21:54 +08:00
earthjasonlin 606662f022 feat(ui): move detail page badge next to card number title 2026-07-05 23:16:11 +08:00
earthjasonlin 16b6e1cb75 fix(ui): shorten mark-delivered button text to 已送达 2026-07-05 23:15:38 +08:00
earthjasonlin ab4df2d14c fix(ui): improve detail page mobile layout - smaller title, compact back button, action buttons in one row 2026-07-05 23:15:10 +08:00
earthjasonlin 4597e739a5 fix(ui): remove duplicate back link from detail page, keep top-left back button 2026-07-05 23:12:59 +08:00
earthjasonlin 7380ed2de4 feat(ui): add back button left of card number on detail page 2026-07-05 23:11:05 +08:00
earthjasonlin 97ab5deafe fix(ui): vertically center all badges 2026-07-05 23:09:47 +08:00
earthjasonlin 078d09bfbf feat(ui): stack country and recipient/sender vertically in postcard list 2026-07-05 23:07:41 +08:00
earthjasonlin 370df969f8 feat(ui): show arrival date below send date for delivered postcards 2026-07-05 23:06:56 +08:00
earthjasonlin dde441ac2f feat(ui): show send/receive date in postcard list 2026-07-05 23:06:18 +08:00
earthjasonlin 82b2442a1c feat(dashboard): mobile 2-col stats, add relative time to recent activity 2026-07-05 23:03:35 +08:00
earthjasonlin f310a30d33 i18n: change all page titles and nav text to Chinese 2026-07-05 22:40:49 +08:00
earthjasonlin 4fa72aabf8 fix(ui): improve dashboard and global mobile responsiveness 2026-07-05 22:34:25 +08:00
earthjasonlin 95bcc42b13 feat(ui): redesign dashboard as global overview with stats, progress bar, recent activity 2026-07-05 22:32:16 +08:00
earthjasonlin ee8d9fa528 fix(ui): add missing id to profile nickname for rename toggle 2026-07-05 22:28:49 +08:00
earthjasonlin b50691e624 feat(ui): redesign profiles page with card layout showing postcard stats 2026-07-05 22:27:13 +08:00
earthjasonlin b56c3bc6c5 fix: clear arrival_time and disable field when status is sent 2026-07-05 22:15:40 +08:00
earthjasonlin 000f39779c feat(ui): redesign postcards list with tabs, row layout, country dropdown filter 2026-07-05 22:12:33 +08:00
earthjasonlin 98bcb4c306 fix(ui): align settings cards with heading, full width in container 2026-07-05 22:07:36 +08:00
earthjasonlin 2bba519c8a fix(ui): wider settings cards with more padding 2026-07-05 22:06:46 +08:00
earthjasonlin ec6cf30e94 fix(ui): center settings cards to fix uneven left-right spacing 2026-07-05 22:05:14 +08:00
earthjasonlin f4b1c96148 fix(ui): username change no longer requires password confirmation 2026-07-05 22:03:59 +08:00
earthjasonlin c0130afe12 feat: add user settings page (change username/password) and remove public registration 2026-07-05 22:01:24 +08:00
earthjasonlin fae5a05be1 feat(ui): add mark-delivered button on sent postcards with auto today date 2026-07-05 21:45:32 +08:00
earthjasonlin 146e424d6c feat(ui): show country flag emoji next to country codes 2026-07-05 21:40:46 +08:00
earthjasonlin b975227458 feat(auth): add remember me option with persistent DB sessions 2026-07-05 21:28:58 +08:00
earthjasonlin 83cea694a1 feat: always display country code in uppercase (model validates + oninput) 2026-07-05 21:22:57 +08:00
earthjasonlin a46d9621be fix(ui): explicitly set text color on .btn to prevent white text on mobile 2026-07-05 21:11:14 +08:00
earthjasonlin 59865a9007 fix(ui): stack upload form vertically on mobile to prevent button text cutoff 2026-07-05 21:09:11 +08:00
earthjasonlin b5a89c2683 fix(ui): upload button text cut off on mobile, use flex shrink for file input 2026-07-05 21:08:10 +08:00
earthjasonlin 8aad025a74 fix(ui): toggle required on hidden fields to prevent browser validation error 2026-07-05 20:21:57 +08:00
earthjasonlin e9e0533f5d feat(ui): arrival time only required when status is delivered, not sent 2026-07-05 20:19:47 +08:00
earthjasonlin 8b2d6c98a3 fix(ui): toggle required attr on hidden fields when switching postcard status 2026-07-05 20:12:13 +08:00
earthjasonlin 2cfdee180d fix(ui): remove required from date inputs to unblock form submit 2026-07-05 20:11:04 +08:00
earthjasonlin 2f220906b5 fix(ui): remove overly strict pattern validation on postcard form 2026-07-05 19:35:55 +08:00
earthjasonlin ea326d90c6 fix(ui): increase spacing between welcome heading and cards 2026-07-05 19:23:53 +08:00
earthjasonlin 66451fe05a feat(ui): rich dashboard cards with countries and recent postcards 2026-07-05 19:22:10 +08:00
earthjasonlin 6822de37c2 feat(ui): polished dashboard profile cards, remove manage button 2026-07-05 19:12:27 +08:00
earthjasonlin eac6f2f095 fix(ui): unify btn line-height for a and button elements 2026-07-05 19:10:32 +08:00
earthjasonlin 5b9e50fcb7 fix(ui): unify btn height a vs button, use --reload for dev server 2026-07-05 19:09:01 +08:00
earthjasonlin 9ba4ab1021 feat: inline rename for profiles, dashboard stats grid UI 2026-07-05 19:03:48 +08:00
earthjasonlin 5121fb1f8f feat: add profile rename, all postcard fields required 2026-07-05 19:00:25 +08:00
earthjasonlin 2054d13825 fix(ui): badge no longer stretches in flex container 2026-07-05 18:55:18 +08:00
earthjasonlin bea6be20b0 fix(ui): remove extra margin on form submit button, align save/cancel 2026-07-05 18:53:39 +08:00
earthjasonlin 22d16174a5 fix(ui): align form-action buttons vertically 2026-07-05 18:51:09 +08:00
earthjasonlin 2ebec81137 fix(ui): form-actions button alignment, cancel no longer wraps 2026-07-05 18:48:39 +08:00
earthjasonlin bd500db365 fix(ui): restore auth form button spacing 2026-07-05 18:45:52 +08:00
earthjasonlin 53c8d6cf01 fix(ui): badge sizing, button alignment, country XX format, date-only fields 2026-07-05 16:37:36 +08:00
earthjasonlin 2faa8d6012 fix(ui): shrink badges, card_number format XX-1234567, date-only fields 2026-07-05 16:26:22 +08:00
earthjasonlin a4cd3fb66d fix(ui): fix button text wrapping, upload layout, and image placeholders 2026-07-05 16:23:31 +08:00
earthjasonlin cd5d706742 chore: remove server.log 2026-07-05 16:06:22 +08:00
earthjasonlin a03965796f feat: initial postcard manager - FastAPI + Jinja2 + SQLite 2026-07-05 16:06:03 +08:00
25 changed files with 1289 additions and 1776 deletions

No files matched your search

-6
View File
@@ -5,9 +5,3 @@ uploads/*
!uploads/.gitkeep !uploads/.gitkeep
*.db *.db
.env .env
server.log
s3_config.json
config.json
scripts/fix_*.py
scripts/migrate_*.py
scripts/test_*.py
+1 -50
View File
@@ -3,30 +3,11 @@
"name": "English" "name": "English"
}, },
"brand": "📮 Mailova", "brand": "📮 Mailova",
"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.profiles": "Profiles",
"nav.settings": "Settings", "nav.settings": "Settings",
"nav.invites": "Invites", "nav.invites": "Invites",
"nav.logout": "Logout", "nav.logout": "Logout",
"nav.api_docs": "API Docs", "nav.api_docs": "API Docs",
"nav.showcase": "Showcase",
"nav.admin": "Admin",
"admin.title": "System Admin",
"admin.tab_invites": "Invite Codes",
"admin.tab_config": "Settings",
"admin.config_title": "System Settings",
"admin.copyright_label": "Copyright Text",
"admin.copyright_hint": "Displayed at the bottom of all pages. Leave empty to hide.",
"admin.save_config": "Save",
"admin.invite_length_error": "Invite code must be 8 characters",
"admin.invite_exists_error": "Invite code already exists",
"admin.invite_created": "Invite code {code} created",
"admin.invite_updated": "Invite code {code} updated",
"admin.config_saved": "Settings saved",
"login.title": "Login", "login.title": "Login",
"login.username": "Username", "login.username": "Username",
"login.password": "Password", "login.password": "Password",
@@ -65,7 +46,6 @@
"profiles.total": "Total", "profiles.total": "Total",
"profiles.countries": "Countries", "profiles.countries": "Countries",
"profiles.notes_ph": "Notes (private)", "profiles.notes_ph": "Notes (private)",
"profiles.country_ph": "Country",
"profiles.save": "Save", "profiles.save": "Save",
"profiles.created_at": "Created", "profiles.created_at": "Created",
"profiles.view": "View", "profiles.view": "View",
@@ -73,23 +53,17 @@
"profiles.confirm_delete": "Delete this profile?", "profiles.confirm_delete": "Delete this profile?",
"pc_list.title": "'s postcards", "pc_list.title": "'s postcards",
"pc_list.new": "+ New postcard", "pc_list.new": "+ New postcard",
"pc_list.new_sent": "✉️ Sent",
"pc_list.new_received": "📬 Received",
"pc_list.duplicate": "Card number already exists", "pc_list.duplicate": "Card number already exists",
"pc_list.all_countries": "All countries", "pc_list.all_countries": "All countries",
"pc_list.filter": "Filter", "pc_list.filter": "Filter",
"pc_list.tab_sent": "Sent", "pc_list.tab_sent": "Sent",
"pc_list.tab_received": "Received", "pc_list.tab_received": "Received",
"pc_list.mark_sent": "Send",
"pc_list.confirm_sent": "Mark as sent?",
"pc_list.mark_delivered": "Delivered", "pc_list.mark_delivered": "Delivered",
"pc_list.confirm_delivered": "Mark as delivered?", "pc_list.confirm_delivered": "Mark as delivered?",
"pc_list.empty_sent": "No sent postcards.", "pc_list.empty_sent": "No sent postcards.",
"pc_list.empty_received": "No received postcards.", "pc_list.empty_received": "No received postcards.",
"pc_detail.title": "Postcard detail", "pc_detail.title": "Postcard detail",
"pc_detail.edit": "Edit", "pc_detail.edit": "Edit",
"pc_detail.mark_sent": "Send",
"pc_detail.confirm_sent": "Mark as sent?",
"pc_detail.mark_delivered": "Mark delivered", "pc_detail.mark_delivered": "Mark delivered",
"pc_detail.confirm_delivered": "Mark as delivered?", "pc_detail.confirm_delivered": "Mark as delivered?",
"pc_detail.delete": "Delete", "pc_detail.delete": "Delete",
@@ -104,7 +78,6 @@
"pc_detail.status": "Status", "pc_detail.status": "Status",
"pc_detail.recipient": "Recipient", "pc_detail.recipient": "Recipient",
"pc_detail.country": "Country", "pc_detail.country": "Country",
"pc_detail.route": "Route",
"pc_detail.send_time": "Sent date", "pc_detail.send_time": "Sent date",
"pc_detail.arrival_time": "Arrival date", "pc_detail.arrival_time": "Arrival date",
"pc_detail.sender": "Sender", "pc_detail.sender": "Sender",
@@ -112,19 +85,15 @@
"pc_detail.profile": "Profile", "pc_detail.profile": "Profile",
"pc_detail.notes": "Notes", "pc_detail.notes": "Notes",
"status.sent": "Sent", "status.sent": "Sent",
"status.pending": "Pending",
"status.delivered": "Delivered", "status.delivered": "Delivered",
"status.received": "Received", "status.received": "Received",
"pc_form.edit": "Edit postcard", "pc_form.edit": "Edit postcard",
"pc_form.new": "New postcard", "pc_form.new": "New postcard",
"pc_form.new_sent": "New sent",
"pc_form.new_received": "New received",
"pc_form.number": "Number", "pc_form.number": "Number",
"pc_form.number_hint": "Format: AB-1234567", "pc_form.number_hint": "Format: AB-1234567",
"pc_form.status": "Status", "pc_form.status": "Status",
"pc_form.recipient": "Recipient", "pc_form.recipient": "Recipient",
"pc_form.country_from": "From country", "pc_form.country": "Country",
"pc_form.country_to": "To country",
"pc_form.send_time": "Sent date", "pc_form.send_time": "Sent date",
"pc_form.arrival_time": "Arrival date", "pc_form.arrival_time": "Arrival date",
"pc_form.sender": "Sender", "pc_form.sender": "Sender",
@@ -276,23 +245,5 @@
"api_docs.ep.key_name_hint": "string (optional) — a label for the key", "api_docs.ep.key_name_hint": "string (optional) — a label for the key",
"api_docs.ep.country_hint": "two uppercase letters, e.g. JP", "api_docs.ep.country_hint": "two uppercase letters, e.g. JP",
"api_docs.ep.date_hint": "format: YYYY-MM-DD or YYYY-MM-DDTHH:MM", "api_docs.ep.date_hint": "format: YYYY-MM-DD or YYYY-MM-DDTHH:MM",
"apikey.permissions": "Permissions",
"apikey.perm.profiles_read": "Read Profiles",
"apikey.perm.profiles_write": "Write Profiles",
"apikey.perm.postcards_read": "Read Postcards",
"apikey.perm.postcards_write": "Write Postcards",
"apikey.perm.images_upload": "Upload Images",
"apikey.perm.hint": "No selection = full access (backward-compatible). Once checked, the key only has the selected permissions.",
"apikey.perm_full_access": "Full Access",
"apikey.edit": "Edit",
"apikey.save": "Save",
"apikey.cancel": "Cancel",
"api_docs.permissions": "Permission System",
"api_docs.permissions_desc": "Each API key can be assigned fine-grained permission scopes. Keys without any scope (legacy) have full access. Once scopes are set, the key can only access the corresponding endpoints.",
"api_docs.scope": "Scope",
"api_docs.scope_desc": "Description",
"api_docs.scope_endpoints": "Endpoints",
"api_docs.permissions_note": "A 403 response means the key is missing a required permission.",
"api_docs.permissions_keys_note": "API Key management endpoints (GET/POST/DELETE /keys) only require a valid API key for authentication, no specific permission scope needed.",
"misc.required": " *" "misc.required": " *"
} }
+1 -50
View File
@@ -3,30 +3,11 @@
"name": "中文" "name": "中文"
}, },
"brand": "📮 星笺", "brand": "📮 星笺",
"index.title": "星笺",
"index.subtitle": "来自世界各地的明信片",
"index.go_login": "登录",
"index.go_register": "注册",
"index.empty": "暂无公开明信片",
"nav.profiles": "Profile", "nav.profiles": "Profile",
"nav.settings": "设置", "nav.settings": "设置",
"nav.invites": "邀请码管理", "nav.invites": "邀请码管理",
"nav.logout": "退出", "nav.logout": "退出",
"nav.api_docs": "API 文档", "nav.api_docs": "API 文档",
"nav.showcase": "展示页",
"nav.admin": "系统管理",
"admin.title": "系统管理",
"admin.tab_invites": "邀请码管理",
"admin.tab_config": "系统设置",
"admin.config_title": "系统设置",
"admin.copyright_label": "Copyright 文本",
"admin.copyright_hint": "将显示在所有页面底部,留空则不显示。",
"admin.save_config": "保存设置",
"admin.invite_length_error": "邀请码必须为8个字符",
"admin.invite_exists_error": "邀请码已存在",
"admin.invite_created": "邀请码 {code} 已创建",
"admin.invite_updated": "邀请码 {code} 已更新",
"admin.config_saved": "设置已保存",
"login.title": "登录", "login.title": "登录",
"login.username": "用户名", "login.username": "用户名",
"login.password": "密码", "login.password": "密码",
@@ -65,7 +46,6 @@
"profiles.total": "总计", "profiles.total": "总计",
"profiles.countries": "国家", "profiles.countries": "国家",
"profiles.notes_ph": "备注(仅自己可见)", "profiles.notes_ph": "备注(仅自己可见)",
"profiles.country_ph": "国家",
"profiles.save": "保存", "profiles.save": "保存",
"profiles.created_at": "创建于", "profiles.created_at": "创建于",
"profiles.view": "查看", "profiles.view": "查看",
@@ -73,23 +53,17 @@
"profiles.confirm_delete": "确定删除此 Profile", "profiles.confirm_delete": "确定删除此 Profile",
"pc_list.title": " 的明信片", "pc_list.title": " 的明信片",
"pc_list.new": "+ 新明信片", "pc_list.new": "+ 新明信片",
"pc_list.new_sent": "✉️ 寄出",
"pc_list.new_received": "📬 收到",
"pc_list.duplicate": "编号已存在", "pc_list.duplicate": "编号已存在",
"pc_list.all_countries": "全部国家", "pc_list.all_countries": "全部国家",
"pc_list.filter": "筛选", "pc_list.filter": "筛选",
"pc_list.tab_sent": "寄出", "pc_list.tab_sent": "寄出",
"pc_list.tab_received": "收到", "pc_list.tab_received": "收到",
"pc_list.mark_sent": "寄出",
"pc_list.confirm_sent": "确认寄出?",
"pc_list.mark_delivered": "已送达", "pc_list.mark_delivered": "已送达",
"pc_list.confirm_delivered": "确认已送达?", "pc_list.confirm_delivered": "确认已送达?",
"pc_list.empty_sent": "没有寄出的明信片。", "pc_list.empty_sent": "没有寄出的明信片。",
"pc_list.empty_received": "没有收到的明信片。", "pc_list.empty_received": "没有收到的明信片。",
"pc_detail.title": "明信片详情", "pc_detail.title": "明信片详情",
"pc_detail.edit": "编辑", "pc_detail.edit": "编辑",
"pc_detail.mark_sent": "寄出",
"pc_detail.confirm_sent": "确认寄出?",
"pc_detail.mark_delivered": "已送达", "pc_detail.mark_delivered": "已送达",
"pc_detail.confirm_delivered": "确认已送达?", "pc_detail.confirm_delivered": "确认已送达?",
"pc_detail.delete": "删除", "pc_detail.delete": "删除",
@@ -104,7 +78,6 @@
"pc_detail.status": "状态", "pc_detail.status": "状态",
"pc_detail.recipient": "收件人", "pc_detail.recipient": "收件人",
"pc_detail.country": "国家/地区", "pc_detail.country": "国家/地区",
"pc_detail.route": "路线",
"pc_detail.send_time": "寄出时间", "pc_detail.send_time": "寄出时间",
"pc_detail.arrival_time": "到达时间", "pc_detail.arrival_time": "到达时间",
"pc_detail.sender": "发件人", "pc_detail.sender": "发件人",
@@ -112,19 +85,15 @@
"pc_detail.profile": "所属 Profile", "pc_detail.profile": "所属 Profile",
"pc_detail.notes": "备注", "pc_detail.notes": "备注",
"status.sent": "已寄出", "status.sent": "已寄出",
"status.pending": "待寄出",
"status.delivered": "已送达", "status.delivered": "已送达",
"status.received": "已收到", "status.received": "已收到",
"pc_form.edit": "编辑明信片", "pc_form.edit": "编辑明信片",
"pc_form.new": "新建明信片", "pc_form.new": "新建明信片",
"pc_form.new_sent": "新建寄出",
"pc_form.new_received": "新建收到",
"pc_form.number": "编号", "pc_form.number": "编号",
"pc_form.number_hint": "格式:AB-1234567", "pc_form.number_hint": "格式:AB-1234567",
"pc_form.status": "状态", "pc_form.status": "状态",
"pc_form.recipient": "收件人", "pc_form.recipient": "收件人",
"pc_form.country_from": "寄出国", "pc_form.country": "国家/地区",
"pc_form.country_to": "到达国",
"pc_form.send_time": "寄出时间", "pc_form.send_time": "寄出时间",
"pc_form.arrival_time": "到达时间", "pc_form.arrival_time": "到达时间",
"pc_form.sender": "发件人", "pc_form.sender": "发件人",
@@ -276,23 +245,5 @@
"api_docs.ep.key_name_hint": "string(可选)— Key 的备注名称", "api_docs.ep.key_name_hint": "string(可选)— Key 的备注名称",
"api_docs.ep.country_hint": "两个大写字母,如 JP", "api_docs.ep.country_hint": "两个大写字母,如 JP",
"api_docs.ep.date_hint": "格式 YYYY-MM-DD 或 YYYY-MM-DDTHH:MM", "api_docs.ep.date_hint": "格式 YYYY-MM-DD 或 YYYY-MM-DDTHH:MM",
"apikey.permissions": "权限范围",
"apikey.perm.profiles_read": "读取 Profile",
"apikey.perm.profiles_write": "写入 Profile",
"apikey.perm.postcards_read": "读取明信片",
"apikey.perm.postcards_write": "写入明信片",
"apikey.perm.images_upload": "上传图片",
"apikey.perm.hint": "不勾选任何权限 = 完全访问(兼容旧 Key)。勾选后该 Key 仅拥有所选权限。",
"apikey.perm_full_access": "完全访问",
"apikey.edit": "编辑",
"apikey.save": "保存",
"apikey.cancel": "取消",
"api_docs.permissions": "权限系统",
"api_docs.permissions_desc": "每个 API Key 可以配置细粒度的权限范围。不配置权限时(旧 Key),默认完全访问。配置后仅拥有所选权限。",
"api_docs.scope": "权限 Scope",
"api_docs.scope_desc": "说明",
"api_docs.scope_endpoints": "对应端点",
"api_docs.permissions_note": "403 响应表示缺少所需权限。",
"api_docs.permissions_keys_note": "API Key 管理端点(GET/POST/DELETE /keys)仅需有效 Key 鉴权,不需要特定权限。",
"misc.required": " *" "misc.required": " *"
} }
+1 -19
View File
@@ -1,7 +1,6 @@
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.config import UPLOAD_DIR from app.config import UPLOAD_DIR
@@ -11,24 +10,7 @@ from app.routers import web, api
app = FastAPI(title="Mailova 星笺") app = FastAPI(title="Mailova 星笺")
UPLOAD_DIR.mkdir(exist_ok=True) UPLOAD_DIR.mkdir(exist_ok=True)
STATIC_DIR = Path(__file__).resolve().parent / "static" app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
# Cache-Control for uploaded images (30 days, immutable — filenames contain UUID)
@app.middleware("http")
async def cache_control_middleware(request, call_next):
response = await call_next(request)
if request.url.path.startswith("/uploads/"):
response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
return response
@app.get("/favicon.ico")
async def favicon():
return FileResponse(STATIC_DIR / "favicon.ico", media_type="image/x-icon")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
app.include_router(web.router) app.include_router(web.router)
+5 -48
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship, validates from sqlalchemy.orm import relationship, validates
from app.database import Base from app.database import Base
@@ -61,7 +61,6 @@ class Profile(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
nickname = Column(String(64), nullable=False) nickname = Column(String(64), nullable=False)
country = Column(String(2), nullable=True)
showcase_enabled = Column(default=False, nullable=False) showcase_enabled = Column(default=False, nullable=False)
showcase_visible = Column(String(16), nullable=False, default="both") showcase_visible = Column(String(16), nullable=False, default="both")
notes = Column(Text, nullable=False, default="") notes = Column(Text, nullable=False, default="")
@@ -73,18 +72,14 @@ class Profile(Base):
class Postcard(Base): class Postcard(Base):
__tablename__ = "postcards" __tablename__ = "postcards"
__table_args__ = (
UniqueConstraint("profile_id", "card_number", name="uq_postcards_profile_card_number"),
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False) profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
card_number = Column(String(64), nullable=False) card_number = Column(String(64), nullable=False, unique=True)
status = Column(String(16), nullable=False) status = Column(String(16), nullable=False)
# sent & delivered # sent & delivered
recipient_name = Column(String(64)) recipient_name = Column(String(64))
country_from = Column(String(2)) country = Column(String(64))
country_to = Column(String(2))
send_time = Column(DateTime) send_time = Column(DateTime)
arrival_time = Column(DateTime) arrival_time = Column(DateTime)
# received # received
@@ -101,12 +96,8 @@ class Postcard(Base):
profile = relationship("Profile", back_populates="postcards") profile = relationship("Profile", back_populates="postcards")
@validates("country_from") @validates("country")
def _upper_country_from(self, key, value): def _upper_country(self, key, value):
return value.strip().upper() if value else None
@validates("country_to")
def _upper_country_to(self, key, value):
return value.strip().upper() if value else None return value.strip().upper() if value else None
@@ -118,41 +109,7 @@ class ApiKey(Base):
name = Column(String(64), nullable=False, default="") name = Column(String(64), nullable=False, default="")
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
is_active = Column(Boolean, nullable=False, default=True) is_active = Column(Boolean, nullable=False, default=True)
permissions = Column(Text, nullable=False, default="[]")
created_at = Column(DateTime, default=_utcnow) created_at = Column(DateTime, default=_utcnow)
last_used_at = Column(DateTime, nullable=True) last_used_at = Column(DateTime, nullable=True)
user = relationship("User", back_populates="api_keys") user = relationship("User", back_populates="api_keys")
class SystemConfig(Base):
__tablename__ = "system_config"
key = Column(String(64), primary_key=True)
value = Column(Text, nullable=False, default="")
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
# ─── API Key Permission System ───────────────────────────────────
# Scopes: "resource:action"
# Empty list [] means full access (backward compatible).
PERM_PROFILES_READ = "profiles:read"
PERM_PROFILES_WRITE = "profiles:write"
PERM_POSTCARDS_READ = "postcards:read"
PERM_POSTCARDS_WRITE = "postcards:write"
PERM_IMAGES_UPLOAD = "images:upload"
ALL_PERMISSIONS = [
PERM_PROFILES_READ,
PERM_PROFILES_WRITE,
PERM_POSTCARDS_READ,
PERM_POSTCARDS_WRITE,
PERM_IMAGES_UPLOAD,
]
PERMISSION_GROUPS: dict[str, list[str]] = {
"profiles": [PERM_PROFILES_READ, PERM_PROFILES_WRITE],
"postcards": [PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE],
"images": [PERM_IMAGES_UPLOAD],
}
+55 -174
View File
@@ -7,63 +7,26 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session as DbSession from sqlalchemy.orm import Session as DbSession
from app.database import get_db from app.database import get_db
from app.models import ( from app.models import ApiKey, Profile, Postcard, User
ApiKey, Profile, Postcard, User, from app.config import UPLOAD_DIR
PERM_PROFILES_READ, PERM_PROFILES_WRITE,
PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE,
PERM_IMAGES_UPLOAD,
)
# config removed: image storage now via app.storage
router = APIRouter(prefix="/api/v1", tags=["api"]) router = APIRouter(prefix="/api/v1", tags=["api"])
# ─── Auth Dependencies ──────────────────────────────────────────── # ─── Auth Dependency ──────────────────────────────────────────────
def _parse_permissions(raw: str) -> list[str]: def get_api_user(
"""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"), x_api_key: str = Header(..., alias="X-API-Key"),
db: DbSession = Depends(get_db), db: DbSession = Depends(get_db),
) -> ApiKey: ) -> User:
"""Validate X-API-Key header and return the ApiKey object (with permissions).""" """Validate X-API-Key header and return the associated user."""
ak = db.query(ApiKey).filter(ApiKey.key == x_api_key).first() api_key = db.query(ApiKey).filter(ApiKey.key == x_api_key).first()
if not ak or not ak.is_active: if not api_key or not api_key.is_active:
raise HTTPException(status_code=401, detail="Invalid or inactive API key") raise HTTPException(status_code=401, detail="Invalid or inactive API key")
ak.last_used_at = datetime.now(timezone.utc) # Update last_used_at
api_key.last_used_at = datetime.now(timezone.utc)
db.commit() db.commit()
return ak return api_key.user
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( def get_api_user_optional(
@@ -73,24 +36,20 @@ def get_api_user_optional(
"""Like get_api_user but returns None if no key provided (for public endpoints).""" """Like get_api_user but returns None if no key provided (for public endpoints)."""
if not x_api_key: if not x_api_key:
return None return None
ak = get_api_key(x_api_key, db) return get_api_user(x_api_key, db)
return ak.user
# ─── Pydantic Schemas ──────────────────────────────────────────── # ─── Pydantic Schemas ────────────────────────────────────────────
class ProfileCreate(BaseModel): class ProfileCreate(BaseModel):
nickname: str nickname: str
country: str | None = None
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
nickname: str | None = None nickname: str | None = None
country: str | None = None
class ProfileOut(BaseModel): class ProfileOut(BaseModel):
id: int id: int
nickname: str nickname: str
country: str | None
showcase_enabled: bool showcase_enabled: bool
showcase_visible: str showcase_visible: str
notes: str notes: str
@@ -103,8 +62,7 @@ class PostcardCreate(BaseModel):
card_number: str card_number: str
status: str status: str
recipient_name: str | None = None recipient_name: str | None = None
country_from: str | None = None country: str | None = None
country_to: str | None = None
send_time: str | None = None send_time: str | None = None
arrival_time: str | None = None arrival_time: str | None = None
sender_name: str | None = None sender_name: str | None = None
@@ -115,8 +73,7 @@ class PostcardUpdate(BaseModel):
card_number: str | None = None card_number: str | None = None
status: str | None = None status: str | None = None
recipient_name: str | None = None recipient_name: str | None = None
country_from: str | None = None country: str | None = None
country_to: str | None = None
send_time: str | None = None send_time: str | None = None
arrival_time: str | None = None arrival_time: str | None = None
sender_name: str | None = None sender_name: str | None = None
@@ -129,8 +86,7 @@ class PostcardOut(BaseModel):
card_number: str card_number: str
status: str status: str
recipient_name: str | None recipient_name: str | None
country_from: str | None country: str | None
country_to: str | None
send_time: datetime | None send_time: datetime | None
arrival_time: datetime | None arrival_time: datetime | None
sender_name: str | None sender_name: str | None
@@ -146,14 +102,12 @@ class PostcardOut(BaseModel):
class ApiKeyCreate(BaseModel): class ApiKeyCreate(BaseModel):
name: str = "" name: str = ""
permissions: list[str] = []
class ApiKeyOut(BaseModel): class ApiKeyOut(BaseModel):
id: int id: int
key: str key: str
name: str name: str
is_active: bool is_active: bool
permissions: list[str]
created_at: datetime created_at: datetime
last_used_at: datetime | None last_used_at: datetime | None
@@ -196,23 +150,14 @@ def _postcard_or_404(user: User, profile_id: int, postcard_id: int, db: DbSessio
# Profiles # Profiles
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
@router.get("/profiles", response_model=list[ProfileOut]) @router.get("/profiles", response_model=list[ProfileOut])
def list_profiles( def list_profiles(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
db: DbSession = Depends(get_db), return db.query(Profile).filter(Profile.user_id == user.id).order_by(Profile.id).all()
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) @router.post("/profiles", response_model=ProfileOut, status_code=201)
def create_profile( def create_profile(body: ProfileCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
body: ProfileCreate, p = Profile(nickname=body.nickname.strip(), user_id=user.id)
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.add(p)
db.commit() db.commit()
db.refresh(p) db.refresh(p)
@@ -220,38 +165,23 @@ def create_profile(
@router.get("/profiles/{profile_id}", response_model=ProfileOut) @router.get("/profiles/{profile_id}", response_model=ProfileOut)
def get_profile( def get_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
profile_id: int, return _profile_or_404(user, profile_id, db)
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) @router.put("/profiles/{profile_id}", response_model=ProfileOut)
def update_profile( def update_profile(profile_id: int, body: ProfileUpdate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
profile_id: int, p = _profile_or_404(user, profile_id, db)
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: if body.nickname is not None:
p.nickname = body.nickname.strip() 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.commit()
db.refresh(p) db.refresh(p)
return p return p
@router.delete("/profiles/{profile_id}") @router.delete("/profiles/{profile_id}")
def delete_profile( def delete_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
profile_id: int, p = _profile_or_404(user, profile_id, db)
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.delete(p)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -265,10 +195,10 @@ def delete_profile(
def list_postcards( def list_postcards(
profile_id: int, profile_id: int,
status: str | None = Query(None), status: str | None = Query(None),
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db), db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_READ)),
): ):
_profile_or_404(ak.user, profile_id, db) _profile_or_404(user, profile_id, db)
q = db.query(Postcard).filter(Postcard.profile_id == profile_id) q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
if status: if status:
q = q.filter(Postcard.status == status) q = q.filter(Postcard.status == status)
@@ -279,23 +209,19 @@ def list_postcards(
def create_postcard( def create_postcard(
profile_id: int, profile_id: int,
body: PostcardCreate, body: PostcardCreate,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db), db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
): ):
_profile_or_404(ak.user, profile_id, db) _profile_or_404(user, profile_id, db)
cn = body.card_number.strip() cn = body.card_number.strip()
dup = db.query(Postcard).join(Profile).filter( if db.query(Postcard).filter(Postcard.card_number == cn).first():
Postcard.card_number == cn, Profile.user_id == ak.user.id
).first()
if dup:
raise HTTPException(status_code=409, detail="Card number already exists") raise HTTPException(status_code=409, detail="Card number already exists")
pc = Postcard( pc = Postcard(
profile_id=profile_id, profile_id=profile_id,
card_number=cn, card_number=cn,
status=body.status, status=body.status,
recipient_name=body.recipient_name, recipient_name=body.recipient_name,
country_from=body.country_from.strip().upper() if body.country_from else None, country=body.country.strip().upper() if body.country else None,
country_to=body.country_to.strip().upper() if body.country_to else None,
send_time=_parse_date(body.send_time), send_time=_parse_date(body.send_time),
arrival_time=_parse_date(body.arrival_time), arrival_time=_parse_date(body.arrival_time),
sender_name=body.sender_name, sender_name=body.sender_name,
@@ -312,10 +238,10 @@ def create_postcard(
def get_postcard( def get_postcard(
profile_id: int, profile_id: int,
postcard_id: int, postcard_id: int,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db), db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_READ)),
): ):
return _postcard_or_404(ak.user, profile_id, postcard_id, db) return _postcard_or_404(user, profile_id, postcard_id, db)
@router.put("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut) @router.put("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut)
@@ -323,27 +249,22 @@ def update_postcard(
profile_id: int, profile_id: int,
postcard_id: int, postcard_id: int,
body: PostcardUpdate, body: PostcardUpdate,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db), db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
): ):
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db) pc = _postcard_or_404(user, profile_id, postcard_id, db)
for field in ("status", "recipient_name", "sender_name", "notes"): for field in ("status", "recipient_name", "sender_name", "notes"):
val = getattr(body, field) val = getattr(body, field)
if val is not None: if val is not None:
setattr(pc, field, val.strip() if isinstance(val, str) else val) setattr(pc, field, val.strip() if isinstance(val, str) else val)
if body.card_number is not None: if body.card_number is not None:
cn = body.card_number.strip() cn = body.card_number.strip()
dup = db.query(Postcard).join(Profile).filter( dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
Postcard.card_number == cn, Postcard.id != postcard_id,
Profile.user_id == ak.user.id
).first()
if dup: if dup:
raise HTTPException(status_code=409, detail="Card number already exists") raise HTTPException(status_code=409, detail="Card number already exists")
pc.card_number = cn pc.card_number = cn
if body.country_to is not None: if body.country is not None:
pc.country_to = body.country_to.strip().upper() if body.country_to else 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"): for field in ("send_time", "arrival_time", "receive_time"):
val = getattr(body, field) val = getattr(body, field)
if val is not None: if val is not None:
@@ -357,10 +278,10 @@ def update_postcard(
def delete_postcard( def delete_postcard(
profile_id: int, profile_id: int,
postcard_id: int, postcard_id: int,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db), db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
): ):
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db) pc = _postcard_or_404(user, profile_id, postcard_id, db)
db.delete(pc) db.delete(pc)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -376,20 +297,19 @@ async def upload_image(
postcard_id: int, postcard_id: int,
side: str = Query("front", pattern="^(front|back)$"), side: str = Query("front", pattern="^(front|back)$"),
file: UploadFile = File(...), file: UploadFile = File(...),
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db), db: DbSession = Depends(get_db),
ak: ApiKey = Depends(require_scopes(PERM_IMAGES_UPLOAD)),
): ):
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db) pc = _postcard_or_404(user, profile_id, postcard_id, db)
from app.storage import get_storage
storage = get_storage()
ext = Path(file.filename).suffix if file.filename else ".jpg" ext = Path(file.filename).suffix if file.filename else ".jpg"
key = f"pc_{pc.id}_{side}{ext}" save_name = f"pc_{pc.id}_{side}{ext}"
save_path = UPLOAD_DIR / save_name
content = await file.read() content = await file.read()
storage.put(key, content) save_path.write_bytes(content)
if side == "front": if side == "front":
pc.image_front = key pc.image_front = f"/uploads/{save_name}"
else: else:
pc.image_back = key pc.image_back = f"/uploads/{save_name}"
db.commit() db.commit()
db.refresh(pc) db.refresh(pc)
return pc return pc
@@ -400,65 +320,26 @@ async def upload_image(
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
@router.get("/keys", response_model=list[ApiKeyOut]) @router.get("/keys", response_model=list[ApiKeyOut])
def list_api_keys( def list_api_keys(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
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() 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) @router.post("/keys", response_model=ApiKeyOut, status_code=201)
def create_api_key( def create_api_key(body: ApiKeyCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
body: ApiKeyCreate,
db: DbSession = Depends(get_db),
user: User = Depends(get_api_user),
):
key = f"mv_{token_hex(24)}" key = f"mv_{token_hex(24)}"
import json ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id)
perms = json.dumps(body.permissions) if body.permissions else "[]" db.add(ak)
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.commit()
db.refresh(ak) db.refresh(ak)
return ak return ak
@router.delete("/keys/{key_id}") @router.delete("/keys/{key_id}")
def delete_api_key( def delete_api_key(key_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
key_id: int, ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
db: DbSession = Depends(get_db), if not ak:
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") raise HTTPException(status_code=404, detail="API key not found")
db.delete(target) db.delete(ak)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
+122 -368
View File
@@ -1,10 +1,9 @@
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from pathlib import Path from pathlib import Path
from random import shuffle
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -16,13 +15,14 @@ from app.auth import (
redirect_if_not_logged_in, redirect_if_not_logged_in,
verify_password, verify_password,
) )
from app.config import SECRET_KEY, SESSION_COOKIE_NAME from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR
from app.database import get_db from app.database import get_db
from datetime import datetime
from random import choice from random import choice
from string import ascii_letters, digits from string import ascii_letters, digits
from app.models import InviteCode, Postcard, Profile, User from app.models import InviteCode, Postcard, Profile, User
from app.i18n import t as _t, get_languages as _get_languages from app.i18n import t as _t
router = APIRouter(tags=["web"]) router = APIRouter(tags=["web"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
@@ -67,191 +67,20 @@ def _strftime(dt: datetime, fmt: str) -> str:
templates.env.filters["strftime"] = _strftime templates.env.filters["strftime"] = _strftime
import json as _json
def _json_loads(raw: str) -> list:
"""Parse JSON string for Jinja2 templates."""
try:
return _json.loads(raw) if raw else []
except (ValueError, TypeError):
return []
templates.env.filters["json_loads"] = _json_loads
def _translate_filter(key: str, lang: str = "zh") -> str: def _translate_filter(key: str, lang: str = "zh") -> str:
return _t(key, lang) return _t(key, lang)
templates.env.filters["t"] = _translate_filter templates.env.filters["t"] = _translate_filter
# image URL resolver: bare key → full URL (local /uploads/... or S3 public URL)
from app.storage import resolve_url as _resolve_image_url
templates.env.filters["image_url"] = lambda v: _resolve_image_url(v) if v else ""
from app.i18n import get_languages as _get_languages from app.i18n import get_languages as _get_languages
templates.env.globals["get_languages"] = _get_languages templates.env.globals["available_languages"] = _get_languages()
def _render(request: Request, name: str, ctx: dict) -> HTMLResponse:
"""Render a Jinja2 template and return HTMLResponse.
Bypasses Starlette TemplateResponse to avoid a Jinja2 cache bug where
the context dict is passed as ``globals`` and used as an unhashable
cache key (``TypeError: unhashable type: 'dict'``).
"""
ctx["request"] = request
html = templates.get_template(name).render(ctx)
return HTMLResponse(html)
def _get_copyright() -> str:
from app.models import SystemConfig as _SC
from app.database import SessionLocal
try:
db = SessionLocal()
cfg = db.query(_SC).filter(_SC.key == "copyright_text").first()
db.close()
return cfg.value if cfg else ""
except Exception:
return ""
templates.env.globals["get_copyright"] = _get_copyright
def _redirect(url: str) -> RedirectResponse: def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url, status_code=303) return RedirectResponse(url, status_code=303)
# ---------- Root / Index ----------
@router.get("/", response_class=HTMLResponse)
def index_page(request: Request, db: Session = Depends(get_db)):
user = redirect_if_not_logged_in(request, db)
if user:
return _redirect("/dashboard")
# Collect all public postcards from all users with showcase enabled
all_postcards: 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:
visible = True
if c.status in ("pending", "sent", "delivered") and p.showcase_visible not in ("sent", "both"):
visible = False
if c.status == "received" and p.showcase_visible not in ("received", "both"):
visible = False
if visible:
all_postcards.append(c)
shuffle(all_postcards)
lang = request.query_params.get("lang")
avail = _get_languages()
if not lang or lang not in avail:
lang = request.cookies.get("showcase_lang")
if not lang or lang not in avail:
lang = "zh"
return _render(request, "index.html", {"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 ("pending", "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": _resolve_image_url(c.image_front),
"country_from": c.country_from,
"country_to": c.country_to,
"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 ("pending", "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": _resolve_image_url(c.image_front),
"country_from": c.country_from,
"country_to": c.country_to,
} for c in batch],
})
# ---------- Auth ---------- # ---------- Auth ----------
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
@@ -259,7 +88,7 @@ def login_page(request: Request, db: Session = Depends(get_db)):
user = redirect_if_not_logged_in(request, db) user = redirect_if_not_logged_in(request, db)
if user: if user:
return _redirect("/dashboard") return _redirect("/dashboard")
return _render(request, "login.html", {"request": request}) return templates.TemplateResponse("login.html", {"request": request})
@router.post("/login") @router.post("/login")
@@ -274,7 +103,8 @@ def login_submit(
cleanup_expired_sessions(db) cleanup_expired_sessions(db)
user = db.query(User).filter(User.username == username).first() user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(password, user.password_hash): if not user or not verify_password(password, user.password_hash):
return _render(request, "login.html", {"error": "用户名或密码错误"}, status_code=401 return templates.TemplateResponse(
"login.html", {"request": request, "error": "用户名或密码错误"}, status_code=401
) )
is_remember = remember_me == "on" is_remember = remember_me == "on"
token = create_session(user.id, db, remember_me=is_remember) token = create_session(user.id, db, remember_me=is_remember)
@@ -293,7 +123,7 @@ def register_page(request: Request, db: Session = Depends(get_db)):
user = redirect_if_not_logged_in(request, db) user = redirect_if_not_logged_in(request, db)
if user: if user:
return _redirect("/dashboard") return _redirect("/dashboard")
return _render(request, "register.html", {"request": request}) return templates.TemplateResponse("register.html", {"request": request})
@router.post("/register") @router.post("/register")
@@ -308,24 +138,30 @@ def register_submit(
# Validate invite code # Validate invite code
code_obj = db.query(InviteCode).filter(InviteCode.code == invite_code).first() code_obj = db.query(InviteCode).filter(InviteCode.code == invite_code).first()
if not code_obj: if not code_obj:
return _render(request, "register.html", {"error": "邀请码无效"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "邀请码无效"}, status_code=400
) )
if code_obj.expires_at and code_obj.expires_at < datetime.utcnow(): if code_obj.expires_at and code_obj.expires_at < datetime.utcnow():
return _render(request, "register.html", {"error": "邀请码已过期"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "邀请码已过期"}, status_code=400
) )
if code_obj.max_uses is not None and code_obj.use_count >= code_obj.max_uses: if code_obj.max_uses is not None and code_obj.use_count >= code_obj.max_uses:
return _render(request, "register.html", {"error": "邀请码使用次数已用尽"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "邀请码使用次数已用尽"}, status_code=400
) )
# Validate passwords # Validate passwords
if password != confirm_password: if password != confirm_password:
return _render(request, "register.html", {"error": "两次输入的密码不一致"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "两次输入的密码不一致"}, status_code=400
) )
if len(password) < 6: if len(password) < 6:
return _render(request, "register.html", {"error": "密码至少6个字符"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "密码至少6个字符"}, status_code=400
) )
# Check username # Check username
if db.query(User).filter(User.username == username).first(): if db.query(User).filter(User.username == username).first():
return _render(request, "register.html", {"error": "用户名已存在"}, status_code=400 return templates.TemplateResponse(
"register.html", {"request": request, "error": "用户名已存在"}, status_code=400
) )
# Create user # Create user
new_user = User(username=username, password_hash=hash_password(password), invited_by_code_id=code_obj.id) new_user = User(username=username, password_hash=hash_password(password), invited_by_code_id=code_obj.id)
@@ -352,34 +188,6 @@ def _require_admin(user: User) -> bool:
return user.is_admin return user.is_admin
def _admin_context(user, db, active_tab="invites", **extra):
"""Build context for admin.html with invite codes and system config."""
from app.models import SystemConfig
codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
config = {r.key: r.value for r in db.query(SystemConfig).all()}
ctx = {"request": extra.get("request"), "user": user, "codes": codes, "active_tab": active_tab, "config": config}
ctx.update({k: v for k, v in extra.items() if k != "request"})
return ctx
def _admin_render(request, user, db, active_tab="invites", **extra):
ctx = _admin_context(user, db, active_tab, **extra)
ctx["request"] = request
return _render(request, "admin.html", ctx)
@router.get("/admin", response_class=HTMLResponse)
def admin_page(
request: Request,
active_tab: str = Query("invites", alias="tab"),
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
if not _require_admin(user):
return _redirect("/dashboard")
return _admin_render(request, user, db, active_tab)
@router.get("/admin/invites", response_class=HTMLResponse) @router.get("/admin/invites", response_class=HTMLResponse)
def admin_invites_page( def admin_invites_page(
request: Request, request: Request,
@@ -388,27 +196,10 @@ def admin_invites_page(
): ):
if not _require_admin(user): if not _require_admin(user):
return _redirect("/dashboard") return _redirect("/dashboard")
return _admin_render(request, user, db, "invites") codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
return templates.TemplateResponse("admin_invites.html", {
"request": request, "user": user, "codes": codes,
@router.post("/admin/config") })
def admin_save_config(
request: Request,
copyright_text: str = Form(""),
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
if not _require_admin(user):
return _redirect("/dashboard")
from app.models import SystemConfig
cfg = db.query(SystemConfig).filter(SystemConfig.key == "copyright_text").first()
if cfg:
cfg.value = copyright_text.strip()
else:
cfg = SystemConfig(key="copyright_text", value=copyright_text.strip())
db.add(cfg)
db.commit()
return _admin_render(request, user, db, "config", success=_t("admin.config_saved", user.language))
@router.post("/admin/invites/add") @router.post("/admin/invites/add")
@@ -429,9 +220,17 @@ def admin_add_invite(
else: else:
code = code.strip() code = code.strip()
if len(code) != 8: if len(code) != 8:
return _admin_render(request, user, db, "invites", error=_t("admin.invite_length_error", user.language)) codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
return templates.TemplateResponse("admin_invites.html", {
"request": request, "user": user, "codes": codes,
"error": "邀请码必须为8个字符",
})
if db.query(InviteCode).filter(InviteCode.code == code).first(): if db.query(InviteCode).filter(InviteCode.code == code).first():
return _admin_render(request, user, db, "invites", error=_t("admin.invite_exists_error", user.language)) codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
return templates.TemplateResponse("admin_invites.html", {
"request": request, "user": user, "codes": codes,
"error": "邀请码已存在",
})
# Parse limits # Parse limits
m_uses = None m_uses = None
exp_at = None exp_at = None
@@ -445,7 +244,11 @@ def admin_add_invite(
) )
db.add(new_code) db.add(new_code)
db.commit() db.commit()
return _admin_render(request, user, db, "invites", success=_t("admin.invite_created", user.language).format(code=code)) codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
return templates.TemplateResponse("admin_invites.html", {
"request": request, "user": user, "codes": codes,
"success": f"邀请码 {code} 已创建",
})
@router.post("/admin/invites/{code_id}/edit") @router.post("/admin/invites/{code_id}/edit")
@@ -473,7 +276,11 @@ def admin_edit_invite(
code_obj.max_uses = None code_obj.max_uses = None
code_obj.expires_at = None code_obj.expires_at = None
db.commit() db.commit()
return _admin_render(request, user, db, "invites", success=_t("admin.invite_updated", user.language).format(code=code_obj.code)) codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
return templates.TemplateResponse("admin_invites.html", {
"request": request, "user": user, "codes": codes,
"success": f"邀请码 {code_obj.code} 已更新",
})
@router.post("/admin/invites/{code_id}/delete") @router.post("/admin/invites/{code_id}/delete")
@@ -489,7 +296,7 @@ def admin_delete_invite(
if code_obj: if code_obj:
db.delete(code_obj) db.delete(code_obj)
db.commit() db.commit()
return _redirect("/admin?tab=invites") return _redirect("/admin/invites")
# ---------- Settings ---------- # ---------- Settings ----------
@@ -507,7 +314,8 @@ def settings_page(
for p in profiles: for p in profiles:
profile_cards[p.id] = db.query(Postcard).filter(Postcard.profile_id == p.id).all() profile_cards[p.id] = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
return _render(request, "settings.html", {"user": user, "profiles": profiles, return templates.TemplateResponse("settings.html", {
"request": request, "user": user, "profiles": profiles,
"profile_cards": profile_cards, "active_tab": active_tab, "profile_cards": profile_cards, "active_tab": active_tab,
"api_keys": api_keys, "api_keys": api_keys,
}) })
@@ -521,11 +329,13 @@ def change_username(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
if db.query(User).filter(User.username == new_username, User.id != user.id).first(): if db.query(User).filter(User.username == new_username, User.id != user.id).first():
return _render(request, "settings.html", {"user": user, "error": "用户名已存在"}, status_code=400 return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "error": "用户名已存在"}, status_code=400
) )
user.username = new_username user.username = new_username
db.commit() db.commit()
return _render(request, "settings.html", {"user": user, "success": "用户名修改成功"} return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "success": "用户名修改成功"}
) )
@@ -539,17 +349,21 @@ def change_password(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
if not verify_password(old_password, user.password_hash): if not verify_password(old_password, user.password_hash):
return _render(request, "settings.html", {"user": user, "error": "当前密码错误"}, status_code=400 return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "error": "当前密码错误"}, status_code=400
) )
if new_password != confirm_password: if new_password != confirm_password:
return _render(request, "settings.html", {"user": user, "error": "两次输入的新密码不一致"}, status_code=400 return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "error": "两次输入的新密码不一致"}, status_code=400
) )
if len(new_password) < 6: if len(new_password) < 6:
return _render(request, "settings.html", {"user": user, "error": "新密码长度至少6位"}, status_code=400 return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "error": "新密码长度至少6位"}, status_code=400
) )
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
db.commit() db.commit()
return _render(request, "settings.html", {"user": user, "success": "密码修改成功"} return templates.TemplateResponse(
"settings.html", {"request": request, "user": user, "success": "密码修改成功"}
) )
@@ -593,48 +407,45 @@ def dashboard(request: Request, user: User = Depends(get_current_user_web), db:
all_cards.extend(cards) all_cards.extend(cards)
total = len(all_cards) total = len(all_cards)
pending = sum(1 for c in all_cards if c.status == "pending")
sent = sum(1 for c in all_cards if c.status == "sent") sent = sum(1 for c in all_cards if c.status == "sent")
delivered = sum(1 for c in all_cards if c.status == "delivered") delivered = sum(1 for c in all_cards if c.status == "delivered")
received = sum(1 for c in all_cards if c.status == "received") received = sum(1 for c in all_cards if c.status == "received")
countries = len({c.country_to for c in all_cards if c.country_to}) countries = len({c.country for c in all_cards if c.country})
# recent postcards across all profiles (last 8), sorted by most recently updated # recent postcards across all profiles (last 8)
all_cards.sort(key=lambda c: c.updated_at or c.created_at, reverse=True) all_cards.sort(key=lambda c: c.created_at, reverse=True)
recent = all_cards[:8] recent = all_cards[:8]
# status distribution for progress bar # status distribution for progress bar
if total > 0: if total > 0:
pending_pct = round(pending / total * 100)
sent_pct = round(sent / total * 100) sent_pct = round(sent / total * 100)
delivered_pct = round(delivered / total * 100) delivered_pct = round(delivered / total * 100)
received_pct = round(received / total * 100) received_pct = round(received / total * 100)
else: else:
pending_pct = sent_pct = delivered_pct = received_pct = 0 sent_pct = delivered_pct = received_pct = 0
# country stats split by sent/received # country stats split by sent/received
sent_country_counts: dict[str, int] = {} sent_country_counts: dict[str, int] = {}
received_country_counts: dict[str, int] = {} received_country_counts: dict[str, int] = {}
for c in all_cards: for c in all_cards:
if c.status in ("pending", "sent", "delivered"): if not c.country:
if not c.country_to: continue
continue if c.status in ("sent", "delivered"):
sent_country_counts[c.country_to] = sent_country_counts.get(c.country_to, 0) + 1 sent_country_counts[c.country] = sent_country_counts.get(c.country, 0) + 1
elif c.status == "received": elif c.status == "received":
if not c.country_from: received_country_counts[c.country] = received_country_counts.get(c.country, 0) + 1
continue
received_country_counts[c.country_from] = received_country_counts.get(c.country_from, 0) + 1
sent_country_stats = sorted(sent_country_counts.items(), key=lambda x: -x[1]) sent_country_stats = sorted(sent_country_counts.items(), key=lambda x: -x[1])
received_country_stats = sorted(received_country_counts.items(), key=lambda x: -x[1]) received_country_stats = sorted(received_country_counts.items(), key=lambda x: -x[1])
from datetime import datetime, timezone as _tz from datetime import datetime, timezone as _tz
return _render(request, "dashboard.html", {"user": user, "profiles": profiles, return templates.TemplateResponse("dashboard.html", {
"total": total, "pending": pending, "sent": sent, "delivered": delivered, "received": received, "request": request, "user": user, "profiles": profiles,
"total": total, "sent": sent, "delivered": delivered, "received": received,
"countries": countries, "recent": recent, "countries": countries, "recent": recent,
"pending_pct": pending_pct, "sent_pct": sent_pct, "delivered_pct": delivered_pct, "received_pct": received_pct, "sent_pct": sent_pct, "delivered_pct": delivered_pct, "received_pct": received_pct,
"sent_country_stats": sent_country_stats, "received_country_stats": received_country_stats, "sent_country_stats": sent_country_stats, "received_country_stats": received_country_stats,
"now": datetime.now(_tz.utc) + timedelta(hours=8), "now": datetime.now(_tz.utc),
}) })
@@ -648,19 +459,17 @@ def profile_list(request: Request, user: User = Depends(get_current_user_web), d
cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all() cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
extras[p.id] = { extras[p.id] = {
"total": len(cards), "total": len(cards),
"pending": sum(1 for c in cards if c.status == "pending"),
"sent": sum(1 for c in cards if c.status == "sent"), "sent": sum(1 for c in cards if c.status == "sent"),
"delivered": sum(1 for c in cards if c.status == "delivered"), "delivered": sum(1 for c in cards if c.status == "delivered"),
"received": sum(1 for c in cards if c.status == "received"), "received": sum(1 for c in cards if c.status == "received"),
"countries": len({c.country_to for c in cards if c.country_to}), "countries": len({c.country for c in cards if c.country}),
} }
return _render(request, "profiles.html", {"user": user, "profiles": profiles, "extras": extras}) return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles, "extras": extras})
@router.post("/profiles") @router.post("/profiles")
def profile_create(request: Request, nickname: str = Form(...), country: str = Form(""), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): def profile_create(request: Request, nickname: 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))
db.add(Profile(user_id=user.id, nickname=nickname, country=c))
db.commit() db.commit()
return _redirect("/profiles") return _redirect("/profiles")
@@ -675,11 +484,10 @@ def profile_delete(profile_id: int, user: User = Depends(get_current_user_web),
@router.post("/profiles/{profile_id}/rename") @router.post("/profiles/{profile_id}/rename")
def profile_rename(profile_id: int, nickname: str = Form(...), country: str = Form(""), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): def profile_rename(profile_id: int, nickname: 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() p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if p: if p:
p.nickname = nickname p.nickname = nickname
p.country = country.strip().upper() if country else None
db.commit() db.commit()
return _redirect("/profiles") return _redirect("/profiles")
@@ -708,31 +516,32 @@ def postcard_list(
return _redirect("/profiles") return _redirect("/profiles")
q = db.query(Postcard).filter(Postcard.profile_id == profile_id) q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
if country: if country:
q = q.filter(Postcard.country_to == country) q = q.filter(Postcard.country == country)
postcards = q.all() postcards = q.all()
# collect existing countries for dropdown # collect existing countries for dropdown
all_countries = sorted({pc.country_to for pc in db.query(Postcard.country_to).filter(Postcard.profile_id == profile_id, Postcard.country_to.isnot(None)).all()}) all_countries = sorted({pc.country for pc in db.query(Postcard.country).filter(Postcard.profile_id == profile_id, Postcard.country.isnot(None)).all()})
# status sort order: pending=0, sent=1, delivered=2, received=3 # status sort order: sent=0, delivered=1, received=2
_status_order = {"pending": 0, "sent": 1, "delivered": 2, "received": 3} _status_order = {"sent": 0, "delivered": 1, "received": 2}
postcards.sort(key=lambda pc: (_status_order.get(pc.status, 9), -(pc.send_time or pc.arrival_time or pc.receive_time or pc.created_at).timestamp())) postcards.sort(key=lambda pc: (_status_order.get(pc.status, 9), -(pc.send_time or pc.arrival_time or pc.receive_time or pc.created_at).timestamp()))
# split into sent and received # split into sent and received
sent = [pc for pc in postcards if pc.status in ("pending", "sent", "delivered")] sent = [pc for pc in postcards if pc.status in ("sent", "delivered")]
received = [pc for pc in postcards if pc.status == "received"] received = [pc for pc in postcards if pc.status == "received"]
return _render(request, "postcards.html", {"user": user, "profile": profile, "sent": sent, "received": received, "countries": all_countries, "country": country}, return templates.TemplateResponse(
"postcards.html",
{"request": request, "user": user, "profile": profile, "sent": sent, "received": received, "countries": all_countries, "country": country},
) )
@router.get("/profiles/{profile_id}/postcards/new", response_class=HTMLResponse) @router.get("/profiles/{profile_id}/postcards/new", response_class=HTMLResponse)
def postcard_new(request: Request, profile_id: int, create_type: str = Query("sent"), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): 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() profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if not profile: if not profile:
return _redirect("/profiles") return _redirect("/profiles")
today = datetime.now().strftime("%Y-%m-%d") return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": profile, "postcard": None})
return _render(request, "postcard_form.html", {"user": user, "profile": profile, "postcard": None, "create_type": create_type, "today": today})
@router.post("/profiles/{profile_id}/postcards") @router.post("/profiles/{profile_id}/postcards")
@@ -742,8 +551,7 @@ def postcard_create(
card_number: str = Form(...), card_number: str = Form(...),
status: str = Form(...), status: str = Form(...),
recipient_name: str = Form(""), recipient_name: str = Form(""),
country_to: str = Form(""), country: str = Form(""),
country_from: str = Form(""),
send_time: str = Form(""), send_time: str = Form(""),
arrival_time: str = Form(""), arrival_time: str = Form(""),
sender_name: str = Form(""), sender_name: str = Form(""),
@@ -757,11 +565,9 @@ def postcard_create(
return _redirect("/profiles") return _redirect("/profiles")
cn = card_number.strip() cn = card_number.strip()
dup = db.query(Postcard).join(Profile).filter( if db.query(Postcard).filter(Postcard.card_number == cn).first():
Postcard.card_number == cn, Profile.user_id == user.id return templates.TemplateResponse("postcard_form.html", {
).first() "request": request, "user": user, "profile": profile, "postcard": None,
if dup:
return _render(request, "postcard_form.html", {"user": user, "profile": profile, "postcard": None,
"error": _t("pc_list.duplicate", user.language), "error": _t("pc_list.duplicate", user.language),
}) })
@@ -782,8 +588,7 @@ def postcard_create(
card_number=card_number.strip(), card_number=card_number.strip(),
status=status, status=status,
recipient_name=recipient_name or None, recipient_name=recipient_name or None,
country_from=(country_from.strip().upper() if country_from else profile.country), country=(country.strip().upper() if country else None),
country_to=(country_to.strip().upper() if country_to else None),
send_time=_parse_dt(send_time), send_time=_parse_dt(send_time),
arrival_time=_parse_dt(arrival_time), arrival_time=_parse_dt(arrival_time),
sender_name=sender_name or None, sender_name=sender_name or None,
@@ -805,7 +610,7 @@ def postcard_detail(request: Request, postcard_id: int, user: User = Depends(get
) )
if not pc: if not pc:
return _redirect("/profiles") return _redirect("/profiles")
return _render(request, "postcard_detail.html", {"user": user, "postcard": pc}) return templates.TemplateResponse("postcard_detail.html", {"request": request, "user": user, "postcard": pc})
@router.get("/postcards/{postcard_id}/edit", response_class=HTMLResponse) @router.get("/postcards/{postcard_id}/edit", response_class=HTMLResponse)
@@ -818,7 +623,7 @@ def postcard_edit(request: Request, postcard_id: int, user: User = Depends(get_c
) )
if not pc: if not pc:
return _redirect("/profiles") return _redirect("/profiles")
return _render(request, "postcard_form.html", {"user": user, "profile": pc.profile, "postcard": pc}) return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": pc.profile, "postcard": pc})
@router.post("/postcards/{postcard_id}/edit") @router.post("/postcards/{postcard_id}/edit")
@@ -828,8 +633,7 @@ def postcard_update(
card_number: str = Form(...), card_number: str = Form(...),
status: str = Form(...), status: str = Form(...),
recipient_name: str = Form(""), recipient_name: str = Form(""),
country_to: str = Form(""), country: str = Form(""),
country_from: str = Form(""),
send_time: str = Form(""), send_time: str = Form(""),
arrival_time: str = Form(""), arrival_time: str = Form(""),
sender_name: str = Form(""), sender_name: str = Form(""),
@@ -848,12 +652,10 @@ def postcard_update(
return _redirect("/profiles") return _redirect("/profiles")
cn = card_number.strip() cn = card_number.strip()
dup = db.query(Postcard).join(Profile).filter( dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
Postcard.card_number == cn, Postcard.id != postcard_id,
Profile.user_id == user.id
).first()
if dup: if dup:
return _render(request, "postcard_form.html", {"user": user, "profile": pc.profile, "postcard": pc, return templates.TemplateResponse("postcard_form.html", {
"request": request, "user": user, "profile": pc.profile, "postcard": pc,
"error": _t("pc_list.duplicate", user.language), "error": _t("pc_list.duplicate", user.language),
}) })
@@ -872,8 +674,7 @@ def postcard_update(
pc.card_number = card_number.strip() pc.card_number = card_number.strip()
pc.status = status pc.status = status
pc.recipient_name = recipient_name or None pc.recipient_name = recipient_name or None
pc.country_from = country_from.strip().upper() if country_from else pc.country_from pc.country = country or None
pc.country_to = country_to or None
pc.send_time = _parse_dt(send_time) pc.send_time = _parse_dt(send_time)
pc.arrival_time = None if status == "sent" else _parse_dt(arrival_time) pc.arrival_time = None if status == "sent" else _parse_dt(arrival_time)
pc.sender_name = sender_name or None pc.sender_name = sender_name or None
@@ -900,7 +701,7 @@ def postcard_delete(postcard_id: int, user: User = Depends(get_current_user_web)
@router.post("/postcards/{postcard_id}/mark-delivered") @router.post("/postcards/{postcard_id}/mark-delivered")
async def mark_delivered( def mark_delivered(
postcard_id: int, postcard_id: int,
user: User = Depends(get_current_user_web), user: User = Depends(get_current_user_web),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -912,34 +713,12 @@ async def mark_delivered(
.first() .first()
) )
if not pc: if not pc:
return JSONResponse({"ok": False}, status_code=404) return _redirect("/profiles")
pc.status = "delivered" pc.status = "delivered"
if not pc.arrival_time: if not pc.arrival_time:
pc.arrival_time = datetime.now() pc.arrival_time = datetime.now()
db.commit() db.commit()
return JSONResponse({"ok": True}) return _redirect(f"/postcards/{postcard_id}")
@router.post("/postcards/{postcard_id}/mark-sent")
async def mark_sent(
postcard_id: int,
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
"""Transition pending → sent, auto-fill send_time if empty."""
pc = (
db.query(Postcard)
.join(Profile)
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
.first()
)
if not pc:
return JSONResponse({"ok": False}, status_code=404)
pc.status = "sent"
if not pc.send_time:
pc.send_time = datetime.now()
db.commit()
return JSONResponse({"ok": True})
# ---------- Image Upload ---------- # ---------- Image Upload ----------
@@ -962,13 +741,12 @@ async def upload_image(
) )
if not pc: if not pc:
return _redirect("/profiles") return _redirect("/profiles")
from app.storage import get_storage
storage = get_storage()
ext = Path(file.filename or "upload.jpg").suffix or ".jpg" ext = Path(file.filename or "upload.jpg").suffix or ".jpg"
key = f"{uuid4().hex}{ext}" filename = f"{uuid4().hex}{ext}"
dest = UPLOAD_DIR / filename
content = await file.read() content = await file.read()
storage.put(key, content) dest.write_bytes(content)
setattr(pc, f"image_{side}", key) setattr(pc, f"image_{side}", f"/uploads/{filename}")
db.commit() db.commit()
return _redirect(f"/postcards/{postcard_id}") return _redirect(f"/postcards/{postcard_id}")
@@ -999,7 +777,8 @@ def profile_showcase_page(
if not p: if not p:
return _redirect("/settings?tab=showcase") return _redirect("/settings?tab=showcase")
cards = db.query(Postcard).filter(Postcard.profile_id == profile_id).all() cards = db.query(Postcard).filter(Postcard.profile_id == profile_id).all()
return _render(request, "profile_showcase.html", {"user": user, "profile": p, "cards": cards, return templates.TemplateResponse("profile_showcase.html", {
"request": request, "user": user, "profile": p, "cards": cards,
}) })
@@ -1049,16 +828,13 @@ 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() profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all()
sent_all, received_all, profile_groups = [], [], [] sent_all, received_all, profile_groups = [], [], []
for p in profiles: for p in profiles:
cards = db.query(Postcard).filter( cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
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"): if p.showcase_visible in ("sent", "both"):
sent_all.extend([c for c in cards if c.status in ("pending", "sent", "delivered")]) sent_all.extend([c for c in cards if c.status in ("sent", "delivered")])
if p.showcase_visible in ("received", "both"): if p.showcase_visible in ("received", "both"):
received_all.extend([c for c in cards if c.status == "received"]) received_all.extend([c for c in cards if c.status == "received"])
if p.showcase_visible in ("sent", "both"): if p.showcase_visible in ("sent", "both"):
p_sent = [c for c in cards if c.status in ("pending", "sent", "delivered")] p_sent = [c for c in cards if c.status in ("sent", "delivered")]
else: else:
p_sent = [] p_sent = []
if p.showcase_visible in ("received", "both"): if p.showcase_visible in ("received", "both"):
@@ -1071,7 +847,8 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d
profile_groups.append({"profile": p, "sent": p_sent, "received": p_received}) profile_groups.append({"profile": p, "sent": p_sent, "received": p_received})
sent_all.sort(key=lambda c: c.send_time or c.created_at, reverse=True) sent_all.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
received_all.sort(key=lambda c: c.receive_time or c.created_at, reverse=True) received_all.sort(key=lambda c: c.receive_time or c.created_at, reverse=True)
resp = _render(request, "showcase.html", {"profile_user": user, "mode": user.showcase_mode, resp = templates.TemplateResponse("showcase.html", {
"request": request, "profile_user": user, "mode": user.showcase_mode,
"profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all, "profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all,
"lang": visitor_lang, "available_languages": _avail, "lang": visitor_lang, "available_languages": _avail,
}) })
@@ -1085,23 +862,20 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d
def apikey_create( def apikey_create(
request: Request, request: Request,
name: str = Form(""), name: str = Form(""),
permissions: list[str] = Form([]),
user: User = Depends(get_current_user_web), user: User = Depends(get_current_user_web),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
from app.models import ApiKey, ALL_PERMISSIONS from app.models import ApiKey
from secrets import token_hex from secrets import token_hex
import json
key = f"mv_{token_hex(24)}" key = f"mv_{token_hex(24)}"
# Filter to valid scopes only ak = ApiKey(key=key, name=name.strip(), user_id=user.id)
valid = [p for p in permissions if p in ALL_PERMISSIONS]
perms_json = json.dumps(valid) if valid else "[]"
ak = ApiKey(key=key, name=name.strip(), user_id=user.id, permissions=perms_json)
db.add(ak) db.add(ak)
db.commit() db.commit()
db.refresh(ak) db.refresh(ak)
# Re-render settings with apikeys tab active and the new key shown
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
return _render(request, "settings.html", {"user": user, "active_tab": "apikeys", return templates.TemplateResponse("settings.html", {
"request": request, "user": user, "active_tab": "apikeys",
"api_keys": api_keys, "new_key": ak.key, "success": "API Key created", "api_keys": api_keys, "new_key": ak.key, "success": "API Key created",
}) })
@@ -1120,27 +894,6 @@ def apikey_delete(
return _redirect("/settings?tab=apikeys") return _redirect("/settings?tab=apikeys")
@router.post("/settings/apikeys/{key_id}/edit")
def apikey_edit(
key_id: int,
request: Request,
name: str = Form(""),
permissions: list[str] = Form([]),
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
from app.models import ApiKey, ALL_PERMISSIONS
import json
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
if not ak:
return _redirect("/settings?tab=apikeys")
ak.name = name.strip()
valid = [p for p in permissions if p in ALL_PERMISSIONS]
ak.permissions = json.dumps(valid) if valid else "[]"
db.commit()
return _redirect("/settings?tab=apikeys")
# ---------- API Docs ---------- # ---------- API Docs ----------
@router.get("/api/docs", response_class=HTMLResponse) @router.get("/api/docs", response_class=HTMLResponse)
@@ -1149,5 +902,6 @@ def api_docs_page(
user: User = Depends(get_current_user_web), user: User = Depends(get_current_user_web),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
return _render(request, "api_docs.html", {"user": user, return templates.TemplateResponse("api_docs.html", {
"request": request, "user": user,
}) })
+4 -28
View File
@@ -1,35 +1,21 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, model_validator from pydantic import BaseModel
# ---------- Profile ---------- # ---------- Profile ----------
class ProfileCreate(BaseModel): class ProfileCreate(BaseModel):
nickname: str nickname: str
country: str | None = None
class ProfileUpdate(BaseModel):
nickname: str | None = None
country: str | None = None
class ProfileOut(BaseModel): class ProfileOut(BaseModel):
id: int id: int
nickname: str nickname: str
country: str | None
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@model_validator(mode="after")
def _resolve_image_urls(self) -> "PostcardOut":
from app.storage import resolve_url
self.image_front = resolve_url(self.image_front) or None
self.image_back = resolve_url(self.image_back) or None
return self
# ---------- Postcard ---------- # ---------- Postcard ----------
@@ -37,8 +23,7 @@ class PostcardCreate(BaseModel):
card_number: str card_number: str
status: str status: str
recipient_name: str | None = None recipient_name: str | None = None
country_from: str | None = None country: str | None = None
country_to: str | None = None
send_time: datetime | None = None send_time: datetime | None = None
arrival_time: datetime | None = None arrival_time: datetime | None = None
sender_name: str | None = None sender_name: str | None = None
@@ -49,8 +34,7 @@ class PostcardUpdate(BaseModel):
card_number: str | None = None card_number: str | None = None
status: str | None = None status: str | None = None
recipient_name: str | None = None recipient_name: str | None = None
country_from: str | None = None country: str | None = None
country_to: str | None = None
send_time: datetime | None = None send_time: datetime | None = None
arrival_time: datetime | None = None arrival_time: datetime | None = None
sender_name: str | None = None sender_name: str | None = None
@@ -63,8 +47,7 @@ class PostcardOut(BaseModel):
card_number: str card_number: str
status: str status: str
recipient_name: str | None recipient_name: str | None
country_from: str | None country: str | None
country_to: str | None
send_time: datetime | None send_time: datetime | None
arrival_time: datetime | None arrival_time: datetime | None
sender_name: str | None sender_name: str | None
@@ -75,10 +58,3 @@ class PostcardOut(BaseModel):
updated_at: datetime updated_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@model_validator(mode="after")
def _resolve_image_urls(self) -> "PostcardOut":
from app.storage import resolve_url
self.image_front = resolve_url(self.image_front) or None
self.image_back = resolve_url(self.image_back) or None
return self
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+10 -61
View File
@@ -40,29 +40,6 @@ a:hover { text-decoration: underline; }
.lang-option:hover { background: var(--bg); color: var(--primary); } .lang-option:hover { background: var(--bg); color: var(--primary); }
.lang-option.active { color: var(--primary); font-weight: 600; } .lang-option.active { color: var(--primary); font-weight: 600; }
.showcase-lang-bar { position: fixed; top: 1rem; right: 1.5rem; z-index: 100; } .showcase-lang-bar { position: fixed; top: 1rem; right: 1.5rem; z-index: 100; }
.nav-hamburger { display: none; background: none; border: none; font-size: 1.25rem; cursor: pointer; padding: .25rem .5rem; color: var(--text); }
@media (max-width: 640px) {
.nav-hamburger { display: block; }
.nav-links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border-bottom: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0,0,0,.08);
flex-direction: column;
align-items: flex-start;
padding: .5rem 1.5rem;
gap: .75rem;
z-index: 50;
}
.nav-links.open { display: flex; }
.lang-switcher { position: static; }
.lang-dropdown { position: static; box-shadow: none; border: none; }
}
/* Container */ /* Container */
.container { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; } .container { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; }
@@ -88,8 +65,7 @@ input[type="text"] + label, input[type="password"] + label, input[type="datetime
.form-row .btn { white-space: nowrap; flex-shrink: 0; } .form-row .btn { white-space: nowrap; flex-shrink: 0; }
.filter-bar .form-row select { flex: 0 0 auto; width: auto; } .filter-bar .form-row select { flex: 0 0 auto; width: auto; }
.inline-form { margin-bottom: 1.25rem; } .inline-form { margin-bottom: 1.25rem; }
.rename-form { display: inline-flex; gap: .35rem; align-items: center; margin-right: .5rem; flex-wrap: wrap; } .rename-form { display: inline-flex; gap: .35rem; align-items: center; margin-right: .5rem; }
.rename-form input { flex: 1; min-width: 0; }
.nickname-display { display: inline; } .nickname-display { display: inline; }
.nickname-edit { display: inline-flex; gap: .35rem; align-items: center; } .nickname-edit { display: inline-flex; gap: .35rem; align-items: center; }
.nickname-edit input[type="text"] { width: 140px; flex: none; } .nickname-edit input[type="text"] { width: 140px; flex: none; }
@@ -156,7 +132,6 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
/* Badge */ /* Badge */
.badge { display: inline-block; padding: .1rem .4rem; border-radius: 999px; font-size: .65rem; font-weight: 600; color: #fff; background: var(--text-muted); white-space: nowrap; line-height: 1.4; align-self: center; } .badge { display: inline-block; padding: .1rem .4rem; border-radius: 999px; font-size: .65rem; font-weight: 600; color: #fff; background: var(--text-muted); white-space: nowrap; line-height: 1.4; align-self: center; }
.badge-sent { background: var(--sent); } .badge-sent { background: var(--sent); }
.badge-pending { background: var(--primary); }
.badge-delivered { background: var(--delivered); } .badge-delivered { background: var(--delivered); }
.badge-received { background: var(--received); } .badge-received { background: var(--received); }
@@ -186,20 +161,17 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
.dash-stat-body { display: flex; flex-direction: column; } .dash-stat-body { display: flex; flex-direction: column; }
.dash-stat-value { font-size: 1.15rem; font-weight: 700; line-height: 1.2; } .dash-stat-value { font-size: 1.15rem; font-weight: 700; line-height: 1.2; }
.dash-stat-label { font-size: .68rem; color: var(--text-muted); } .dash-stat-label { font-size: .68rem; color: var(--text-muted); }
.dash-pending { color: var(--primary); }
.dash-sent { color: var(--sent); } .dash-sent { color: var(--sent); }
.dash-delivered { color: var(--delivered); } .dash-delivered { color: var(--delivered); }
.dash-received { color: var(--received); } .dash-received { color: var(--received); }
.dash-progress { margin-bottom: 2rem; } .dash-progress { margin-bottom: 2rem; }
.progress-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--border); } .progress-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--border); }
.progress-seg { height: 100%; } .progress-seg { height: 100%; }
.progress-pending { background: var(--primary); }
.progress-sent { background: var(--sent); } .progress-sent { background: var(--sent); }
.progress-delivered { background: var(--delivered); } .progress-delivered { background: var(--delivered); }
.progress-received { background: var(--received); } .progress-received { background: var(--received); }
.progress-legend { display: flex; gap: 1.25rem; margin-top: .5rem; font-size: .8rem; color: var(--text-muted); } .progress-legend { display: flex; gap: 1.25rem; margin-top: .5rem; font-size: .8rem; color: var(--text-muted); }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: .25rem; vertical-align: middle; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: .25rem; vertical-align: middle; }
.dot-pending { background: var(--primary); }
.dot-sent { background: var(--sent); } .dot-sent { background: var(--sent); }
.dot-delivered { background: var(--delivered); } .dot-delivered { background: var(--delivered); }
.dot-received { background: var(--received); } .dot-received { background: var(--received); }
@@ -232,7 +204,6 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
.stat-value { font-size: 1.1rem; font-weight: 700; } .stat-value { font-size: 1.1rem; font-weight: 700; }
.stat-label { font-size: .75rem; color: var(--text-muted); } .stat-label { font-size: .75rem; color: var(--text-muted); }
.badge-sent-text { color: var(--sent); } .badge-sent-text { color: var(--sent); }
.badge-pending-text { color: var(--primary); }
.badge-delivered-text { color: var(--delivered); } .badge-delivered-text { color: var(--delivered); }
.badge-received-text { color: var(--received); } .badge-received-text { color: var(--received); }
.profile-card-footer { display: flex; align-items: center; justify-content: space-between; } .profile-card-footer { display: flex; align-items: center; justify-content: space-between; }
@@ -306,48 +277,26 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
.detail-grid { grid-template-columns: 1fr; } .detail-grid { grid-template-columns: 1fr; }
.card-grid { grid-template-columns: 1fr; } .card-grid { grid-template-columns: 1fr; }
.postcard-row-info { flex-wrap: wrap; gap: .35rem; } .postcard-row-info { flex-wrap: wrap; gap: .35rem; }
.navbar { padding: .5rem 1rem; }
/* Navbar */ .nav-links { gap: .75rem; }
.navbar { padding: .5rem .75rem; } .nav-links a { font-size: .8rem; }
.nav-brand { font-size: .9rem; } .container { margin: 1rem auto; padding: 0 1rem; }
.nav-links { gap: .5rem; }
.nav-links a { font-size: .78rem; }
.nav-links .btn-sm { padding: .3rem .6rem; font-size: .75rem; }
.container { margin: 1rem auto; padding: 0 .75rem; }
/* Dashboard */
.dash-stats { display: grid; grid-template-columns: 1fr 1fr; } .dash-stats { display: grid; grid-template-columns: 1fr 1fr; }
.dash-stat-card { min-width: 0; } .dash-stat-card { min-width: 0; }
.dash-sections { grid-template-columns: 1fr; } .dash-sections { grid-template-columns: 1fr; }
.recent-meta > span:nth-child(-n+2) { display: none; } .recent-meta > span:nth-child(-n+2) { display: none; }
.recent-meta .recent-time { display: inline; } .recent-meta .recent-time { display: inline; }
.recent-item { gap: .5rem; padding: .5rem; } .recent-item { gap: .5rem; padding: .5rem; }
.section-header { flex-direction: column; gap: .75rem; }
/* Section header */
.section-header { flex-direction: column; gap: .5rem; text-align: center; }
.section-header .btn { width: auto; } .section-header .btn { width: auto; }
/* Detail */
.detail-title-row h1 { font-size: 1.2rem; } .detail-title-row h1 { font-size: 1.2rem; }
.btn-back { padding: .25rem .5rem; font-size: .85rem; } .btn-back { padding: .25rem .5rem; font-size: .85rem; }
/* Profiles */
.profile-grid { grid-template-columns: 1fr; } .profile-grid { grid-template-columns: 1fr; }
.profile-card-stats { gap: .75rem; flex-wrap: wrap; } .profile-card-stats { gap: .75rem; flex-wrap: wrap; }
.showcase-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: .5rem; }
/* Showcase / Landing grid — 2 columns on mobile */ .showcase-card-meta { flex-wrap: wrap; }
.showcase-grid { grid-template-columns: repeat(2, 1fr); gap: .5rem; } .detail-title-row h1 { font-size: 1.2rem; }
.showcase-card-body { padding: .5rem; } .btn-back { padding: .25rem .5rem; font-size: .85rem; }
.showcase-card-meta { font-size: .72rem; flex-wrap: wrap; gap: .25rem; }
/* Lightbox — tighter on mobile */
.lightbox-content { max-width: 95vw; }
.lightbox-content img { max-width: 95vw; max-height: 75vh; }
.lightbox-prev, .lightbox-next { font-size: 1.4rem; padding: .4rem .6rem; }
.lightbox-prev { left: .5rem; }
.lightbox-next { right: .5rem; }
.lightbox-close { top: .5rem; right: .75rem; font-size: 1.6rem; }
} }
/* Admin invite form */ /* Admin invite form */
-202
View File
@@ -1,202 +0,0 @@
"""Image storage abstraction: local filesystem or S3-compatible object storage.
All system configuration lives in ``config.json`` in the project root::
{
"storage": {
"backend": "local",
"s3": {
"endpoint": "https://s3.amazonaws.com",
"bucket": "my-mailova-bucket",
"region": "auto",
"access_key": "AKIA...",
"secret_key": "...",
"public_url": "https://cdn.example.com/mailova"
}
}
}
``storage.backend``: ``"local"`` (default) or ``"s3"``.
URL scheme
~~~~~~~~~~
Database stores **bare filenames** (``xxx.jpeg``) regardless of backend.
``resolve_url(key)`` converts a bare key to a full URL:
- S3 → ``<public_url>/xxx.jpeg``
- local → ``/uploads/xxx.jpeg``
Legacy records that still contain ``/uploads/xxx.jpeg`` are auto-stripped
by ``resolve_url``.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Protocol
from app.config import UPLOAD_DIR
CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json"
class StorageBackend(Protocol):
def put(self, key: str, data: bytes, content_type: str = "image/jpeg") -> str:
"""Store data and return the bare key."""
...
def delete(self, key: str) -> None: ...
def exists(self, key: str) -> bool: ...
def resolve_url(self, key: str) -> str:
"""Convert a bare key (or legacy /uploads/xxx path) to a full URL."""
...
# ── Local Storage ──────────────────────────────────────────────────
class LocalStorage:
def __init__(self) -> None:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
def put(self, key: str, data: bytes, content_type: str = "") -> str:
(UPLOAD_DIR / key).write_bytes(data)
return key
def delete(self, key: str) -> None:
p = UPLOAD_DIR / key
if p.exists():
p.unlink()
def exists(self, key: str) -> bool:
return (UPLOAD_DIR / key).exists()
def resolve_url(self, key: str) -> str:
return f"/uploads/{_strip_prefix(key)}"
# ── S3 Storage ─────────────────────────────────────────────────────
class S3Storage:
def __init__(
self,
endpoint_url: str,
bucket: str,
access_key: str,
secret_key: str,
region: str = "auto",
public_url: str = "",
) -> None:
try:
import boto3
except ImportError:
raise ImportError(
"boto3 is required for S3 storage. "
"Install it with: pip install boto3"
)
self.bucket = bucket
self.public_url = public_url.rstrip("/")
self._client = boto3.client(
"s3",
endpoint_url=endpoint_url or None,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region or "auto",
)
def put(self, key: str, data: bytes, content_type: str = "image/jpeg") -> str:
self._client.put_object(
Bucket=self.bucket, Key=key, Body=data, ContentType=content_type,
CacheControl="public, max-age=2592000, immutable",
)
return key
def delete(self, key: str) -> None:
self._client.delete_object(Bucket=self.bucket, Key=key)
def exists(self, key: str) -> bool:
try:
self._client.head_object(Bucket=self.bucket, Key=key)
return True
except Exception:
return False
def resolve_url(self, key: str) -> str:
k = _strip_prefix(key)
if self.public_url:
return f"{self.public_url}/{k}"
# private bucket: generate presigned URL (7 days expiry)
return self._client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": k},
ExpiresIn=86400,
)
# ── Helpers ────────────────────────────────────────────────────────
def _strip_prefix(key: str) -> str:
"""Strip ``/uploads/`` prefix from legacy DB values."""
if key.startswith("/uploads/"):
return key[len("/uploads/"):]
return key
# ── Config loading ─────────────────────────────────────────────────
def load_config() -> dict:
"""Load config.json. Returns empty dict if missing."""
if not CONFIG_PATH.exists():
return {}
try:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
# ── Singleton accessor ─────────────────────────────────────────────
_backend: StorageBackend | None = None
def get_storage() -> StorageBackend:
"""Get the active storage backend based on config.json."""
global _backend
if _backend is not None:
return _backend
cfg = load_config()
backend = cfg.get("storage", {}).get("backend", "local")
if backend == "s3":
s3 = cfg.get("storage", {}).get("s3", {})
if not s3.get("bucket"):
# fallback to local if S3 is misconfigured
_backend = LocalStorage()
else:
_backend = S3Storage(
endpoint_url=s3.get("endpoint", ""),
bucket=s3["bucket"],
access_key=s3.get("access_key", ""),
secret_key=s3.get("secret_key", ""),
region=s3.get("region", "auto"),
public_url=s3.get("public_url", ""),
)
else:
_backend = LocalStorage()
return _backend
def resolve_url(key: str | None) -> str:
"""Convenience: resolve a possibly-None image key to a URL (or empty)."""
if not key:
return ""
return get_storage().resolve_url(key)
def reset_storage() -> None:
"""Force re-detection on next get_storage() call."""
global _backend
_backend = None
-141
View File
@@ -1,141 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ 'admin.title'|t(user.language) }} - {{ 'brand'|t(user.language) }}{% endblock %}
{% block content %}
<div class="section-header">
<h1>{{ 'admin.title'|t(user.language) }}</h1>
</div>
<div class="tabs" id="tabs">
<button class="tab {% if active_tab != 'config' %}active{% endif %}" onclick="switchTab('invites')">{{ 'admin.tab_invites'|t(user.language) }}</button>
<button class="tab {% if active_tab == 'config' %}active{% endif %}" onclick="switchTab('config')">{{ 'admin.tab_config'|t(user.language) }}</button>
</div>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<!-- ═══ Tab: Invites ═══ -->
<div id="tab-invites" class="tab-panel" {% if active_tab == 'config' %}style="display:none"{% endif %}>
<div class="form-card" style="margin-bottom:1.5rem">
<h3 style="margin-bottom:.75rem">{{ 'invites.create'|t(user.language) }}</h3>
<form method="post" action="/admin/invites/add" style="display:flex;flex-direction:column;gap:.85rem">
<div>
<label style="margin-top:0">{{ 'invites.code_label'|t(user.language) }}</label>
<input type="text" name="code" maxlength="8" placeholder="{{ 'invites.code_ph'|t(user.language) }}" style="text-transform:uppercase">
</div>
<div>
<label style="margin-top:0">{{ 'invites.limit_type'|t(user.language) }}</label>
<select name="limit_type" id="limit_type" onchange="toggleLimitFields()">
<option value="unlimited">{{ 'invites.unlimited'|t(user.language) }}</option>
<option value="uses">{{ 'invites.uses'|t(user.language) }}</option>
<option value="expires">{{ 'invites.expires'|t(user.language) }}</option>
</select>
</div>
<div id="max_uses_field" style="display:none;align-items:center;gap:.3rem">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.times'|t(user.language) }}</label>
<input type="number" name="max_uses" min="1" value="1" class="aif-input aif-short">
</div>
<div id="expires_field" style="display:none;align-items:center;gap:.3rem">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.expires'|t(user.language) }}</label>
<input type="datetime-local" name="expires_at" class="aif-input aif-date">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'invites.create_btn'|t(user.language) }}</button>
</div>
</form>
</div>
{% if codes %}
<div class="postcard-list">
{% for c in codes %}
<div class="postcard-row" style="flex-direction:column;align-items:stretch;gap:.75rem">
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
<strong style="font-family:monospace;font-size:1.1rem">{{ c.code }}</strong>
<span style="font-size:.8rem;color:var(--text-muted)">{{ 'invites.creator'|t(user.language) }}: {{ c.creator.username if c.creator else '-' }}</span>
<span style="font-size:.8rem;color:var(--text-muted)">{{ c.created_at|to_local|strftime('%Y-%m-%d %H:%M') }}</span>
{% if c.max_uses is not none %}
<span class="badge">{{ 'invites.used'|t(user.language) }} {{ c.use_count }} / {{ c.max_uses }}</span>
{% else %}
<span class="badge">{{ 'invites.used'|t(user.language) }} {{ c.use_count }} / ∞</span>
{% endif %}
{% if c.expires_at %}
<span class="badge">{{ 'invites.expires_at'|t(user.language) }}: {{ c.expires_at|to_local|strftime('%Y-%m-%d %H:%M') }}</span>
{% else %}
<span class="badge">{{ 'invites.no_expire'|t(user.language) }}</span>
{% endif %}
</div>
{% if c.registered_users %}
<div style="font-size:.8rem;color:var(--text-muted)">{{ 'invites.registered_users'|t(user.language) }}: {{ c.registered_users|map(attribute='username')|join(', ') }}</div>
{% endif %}
<form method="post" action="/admin/invites/{{ c.id }}/edit" class="admin-invite-form" id="edit-form-{{ c.id }}">
<div class="aif-field">
<select name="limit_type" class="aif-select" onchange="toggleEditFields({{ c.id }}, this.value)">
<option value="unlimited"{% if c.max_uses is none and not c.expires_at %} selected{% endif %}>{{ 'invites.unlimited'|t(user.language) }}</option>
<option value="uses"{% if c.max_uses is not none %} selected{% endif %}>{{ 'invites.uses'|t(user.language) }}</option>
<option value="expires"{% if c.expires_at %} selected{% endif %}>{{ 'invites.expires'|t(user.language) }}</option>
</select>
</div>
<div style="display:{% if c.max_uses is not none %}inline-flex{% else %}none{% endif %};align-items:center;gap:.3rem;align-self:flex-end">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.times'|t(user.language) }}</label>
<input type="number" name="max_uses" value="{{ c.max_uses or 1 }}" min="1" class="aif-input aif-short">
</div>
<div style="display:{% if c.expires_at %}inline-flex{% else %}none{% endif %};align-items:center;gap:.3rem;align-self:flex-end">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.expires'|t(user.language) }}</label>
<input type="datetime-local" name="expires_at" value="{{ c.expires_at|to_local|strftime('%Y-%m-%dT%H:%M') if c.expires_at else '' }}" class="aif-input aif-date">
</div>
<div style="display:flex;gap:.4rem;align-self:flex-end">
<button type="submit" class="btn btn-sm">{{ 'invites.save'|t(user.language) }}</button>
<button type="button" class="btn btn-danger btn-sm" onclick="if(confirm('{{ 'invites.confirm_delete'|t(user.language) }}'))this.closest('form').nextElementSibling.submit()">{{ 'invites.delete'|t(user.language) }}</button>
</div>
</form>
<form method="post" action="/admin/invites/{{ c.id }}/delete" style="display:none"></form>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🔑</div>
<p>{{ 'invites.empty'|t(user.language) }}</p>
</div>
{% endif %}
</div>
<!-- ═══ Tab: System Config ═══ -->
<div id="tab-config" class="tab-panel" {% if active_tab != 'config' %}style="display:none"{% endif %}>
<div class="form-card">
<h3 style="margin-bottom:.75rem">{{ 'admin.config_title'|t(user.language) }}</h3>
<form method="post" action="/admin/config">
<label>{{ 'admin.copyright_label'|t(user.language) }}</label>
<textarea name="copyright_text" rows="3" style="width:100%;resize:vertical">{{ config.get('copyright_text', '') }}</textarea>
<p style="font-size:.8rem;color:var(--text-muted);margin-top:.25rem">{{ 'admin.copyright_hint'|t(user.language) }}</p>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'admin.save_config'|t(user.language) }}</button>
</div>
</form>
</div>
</div>
<script>
function switchTab(name) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
event.currentTarget.classList.add('active');
document.getElementById('tab-' + name).style.display = '';
}
function toggleLimitFields() {
var t = document.getElementById('limit_type').value;
document.getElementById('max_uses_field').style.display = t === 'uses' ? 'inline-flex' : 'none';
document.getElementById('expires_field').style.display = t === 'expires' ? 'inline-flex' : 'none';
}
function toggleEditFields(id, val) {
var form = document.getElementById('edit-form-' + id);
var num = form.querySelector('[name="max_uses"]').parentElement;
var date = form.querySelector('[name="expires_at"]').parentElement;
num.style.display = val === 'uses' ? 'inline-flex' : 'none';
date.style.display = val === 'expires' ? 'inline-flex' : 'none';
}
</script>
{% endblock %}
-15
View File
@@ -52,21 +52,6 @@
</div> </div>
</div> </div>
<div class="doc-section">
<h2>{{ 'api_docs.permissions'|t(user.language) }}</h2>
<p class="doc-desc">{{ 'api_docs.permissions_desc'|t(user.language) }}</p>
<table class="doc-table">
<tr><th>{{ 'api_docs.scope'|t(user.language) }}</th><th>{{ 'api_docs.scope_desc'|t(user.language) }}</th><th>{{ 'api_docs.scope_endpoints'|t(user.language) }}</th></tr>
<tr><td><code>profiles:read</code></td><td>{{ 'apikey.perm.profiles_read'|t(user.language) }}</td><td>GET /profiles, GET /profiles/{id}</td></tr>
<tr><td><code>profiles:write</code></td><td>{{ 'apikey.perm.profiles_write'|t(user.language) }}</td><td>POST /profiles, PUT /profiles/{id}, DELETE /profiles/{id}</td></tr>
<tr><td><code>postcards:read</code></td><td>{{ 'apikey.perm.postcards_read'|t(user.language) }}</td><td>GET /profiles/{id}/postcards, GET /profiles/{id}/postcards/{id}</td></tr>
<tr><td><code>postcards:write</code></td><td>{{ 'apikey.perm.postcards_write'|t(user.language) }}</td><td>POST /profiles/{id}/postcards, PUT /profiles/{id}/postcards/{id}, DELETE /profiles/{id}/postcards/{id}</td></tr>
<tr><td><code>images:upload</code></td><td>{{ 'apikey.perm.images_upload'|t(user.language) }}</td><td>POST /profiles/{id}/postcards/{id}/upload</td></tr>
<tr><td colspan="3" style="color:var(--text-muted);font-style:italic">{{ 'api_docs.permissions_keys_note'|t(user.language) }}</td></tr>
</table>
<p class="doc-note" style="margin-top:.5rem">{{ 'api_docs.permissions_note'|t(user.language) }}</p>
</div>
<!-- ═══════ Profiles ═══════ --> <!-- ═══════ Profiles ═══════ -->
<div class="doc-section"> <div class="doc-section">
<h2>{{ 'api_docs.profile_ops'|t(user.language) }}</h2> <h2>{{ 'api_docs.profile_ops'|t(user.language) }}</h2>
+5 -14
View File
@@ -2,7 +2,6 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}星笺{% endblock %}</title> <title>{% block title %}星笺{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
@@ -10,21 +9,19 @@
<body> <body>
{% block nav %} {% block nav %}
<nav class="navbar"> <nav class="navbar">
<a href="/" class="nav-brand">{{ 'brand'|t(user.language if user else 'zh') }}</a> <a href="/dashboard" class="nav-brand">{{ 'brand'|t(user.language if user else 'zh') }}</a>
{% if user is defined and user %}
<button class="nav-hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')"></button>
<div class="nav-links"> <div class="nav-links">
{% if user is defined and user %}
<a href="/profiles">{{ 'nav.profiles'|t(user.language) }}</a> <a href="/profiles">{{ 'nav.profiles'|t(user.language) }}</a>
<a href="/u/{{ user.username }}" target="_blank">{{ 'nav.showcase'|t(user.language) }}</a>
<a href="/settings">{{ 'nav.settings'|t(user.language) }}</a> <a href="/settings">{{ 'nav.settings'|t(user.language) }}</a>
{% if user.is_admin %} {% if user.is_admin %}
<a href="/admin">{{ 'nav.admin'|t(user.language) }}</a> <a href="/admin/invites">{{ 'nav.invites'|t(user.language) }}</a>
{% endif %} {% endif %}
<a href="/api/docs">{{ 'nav.api_docs'|t(user.language) }}</a> <a href="/api/docs">{{ 'nav.api_docs'|t(user.language) }}</a>
<div class="lang-switcher" id="langSwitcher"> <div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button> <button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<div class="lang-dropdown" id="langDropdown"> <div class="lang-dropdown" id="langDropdown">
{% for code, name in get_languages().items() %} {% for code, name in available_languages.items() %}
<form method="post" action="/settings/language"> <form method="post" action="/settings/language">
<input type="hidden" name="language" value="{{ code }}"> <input type="hidden" name="language" value="{{ code }}">
<button type="submit" class="lang-option {% if user.language == code %}active{% endif %}">{{ name }}</button> <button type="submit" class="lang-option {% if user.language == code %}active{% endif %}">{{ name }}</button>
@@ -33,19 +30,13 @@
</div> </div>
</div> </div>
<a href="/logout" class="nav-logout">{{ 'nav.logout'|t(user.language) }}</a> <a href="/logout" class="nav-logout">{{ 'nav.logout'|t(user.language) }}</a>
{% endif %}
</div> </div>
{% endif %}
</nav> </nav>
{% endblock %} {% endblock %}
<main class="container"> <main class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% set _c = get_copyright() %}
{% if _c %}
<footer style="text-align:center;padding:2rem 1rem;color:var(--text-muted);font-size:.8rem;border-top:1px solid var(--border);margin-top:2rem">
{{ _c|safe }}
</footer>
{% endif %}
</body> </body>
<script> <script>
// Auto-trim whitespace from all text inputs and textareas on form submit // Auto-trim whitespace from all text inputs and textareas on form submit
+14 -36
View File
@@ -14,13 +14,6 @@
<span class="dash-stat-label">{{ 'dash.total_cards'|t(user.language) }}</span> <span class="dash-stat-label">{{ 'dash.total_cards'|t(user.language) }}</span>
</div> </div>
</div> </div>
<div class="dash-stat-card">
<div class="dash-stat-icon">📋</div>
<div class="dash-stat-body">
<span class="dash-stat-value dash-pending">{{ pending }}</span>
<span class="dash-stat-label">{{ 'status.pending'|t(user.language) }}</span>
</div>
</div>
<div class="dash-stat-card"> <div class="dash-stat-card">
<div class="dash-stat-icon">📤</div> <div class="dash-stat-icon">📤</div>
<div class="dash-stat-body"> <div class="dash-stat-body">
@@ -54,13 +47,11 @@
{% if total > 0 %} {% if total > 0 %}
<div class="dash-progress"> <div class="dash-progress">
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-seg progress-pending" style="width:{{ pending_pct }}%"></div>
<div class="progress-seg progress-sent" style="width:{{ sent_pct }}%"></div> <div class="progress-seg progress-sent" style="width:{{ sent_pct }}%"></div>
<div class="progress-seg progress-delivered" style="width:{{ delivered_pct }}%"></div> <div class="progress-seg progress-delivered" style="width:{{ delivered_pct }}%"></div>
<div class="progress-seg progress-received" style="width:{{ received_pct }}%"></div> <div class="progress-seg progress-received" style="width:{{ received_pct }}%"></div>
</div> </div>
<div class="progress-legend"> <div class="progress-legend">
<span><i class="dot dot-pending"></i>{{ 'status.pending'|t(user.language) }} {{ pending_pct }}%</span>
<span><i class="dot dot-sent"></i>{{ 'dash.sent'|t(user.language) }} {{ sent_pct }}%</span> <span><i class="dot dot-sent"></i>{{ 'dash.sent'|t(user.language) }} {{ sent_pct }}%</span>
<span><i class="dot dot-delivered"></i>{{ 'dash.delivered'|t(user.language) }} {{ delivered_pct }}%</span> <span><i class="dot dot-delivered"></i>{{ 'dash.delivered'|t(user.language) }} {{ delivered_pct }}%</span>
<span><i class="dot dot-received"></i>{{ 'dash.received'|t(user.language) }} {{ received_pct }}%</span> <span><i class="dot dot-received"></i>{{ 'dash.received'|t(user.language) }} {{ received_pct }}%</span>
@@ -87,22 +78,22 @@
<script> <script>
{% if sent_country_stats %} {% if sent_country_stats %}
new Chart(document.getElementById('chart-sent'), { new Chart(document.getElementById('chart-sent'), {
type: 'pie', type: 'bar',
data: { data: {
labels: [{% for code, _ in sent_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}], labels: [{% for code, _ in sent_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}],
datasets: [{ data: [{% for _, count in sent_country_stats %}{{ count }},{% endfor %}], backgroundColor: ['#3b82f6','#f59e0b','#10b981','#8b5cf6','#ef4444','#06b6d4','#f97316','#84cc16','#ec4899','#6366f1'] }] datasets: [{ data: [{% for _, count in sent_country_stats %}{{ count }},{% endfor %}], backgroundColor: '#3b82f6', borderRadius: 4, maxBarThickness: 40 }]
}, },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { padding: 12, usePointStyle: true } } } } options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } }, x: { grid: { display: false } } } }
}); });
{% endif %} {% endif %}
{% if received_country_stats %} {% if received_country_stats %}
new Chart(document.getElementById('chart-received'), { new Chart(document.getElementById('chart-received'), {
type: 'pie', type: 'bar',
data: { data: {
labels: [{% for code, _ in received_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}], labels: [{% for code, _ in received_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}],
datasets: [{ data: [{% for _, count in received_country_stats %}{{ count }},{% endfor %}], backgroundColor: ['#8b5cf6','#3b82f6','#f59e0b','#10b981','#ef4444','#06b6d4','#f97316','#84cc16','#ec4899','#6366f1'] }] datasets: [{ data: [{% for _, count in received_country_stats %}{{ count }},{% endfor %}], backgroundColor: '#8b5cf6', borderRadius: 4, maxBarThickness: 40 }]
}, },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { padding: 12, usePointStyle: true } } } } options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } }, x: { grid: { display: false } } } }
}); });
{% endif %} {% endif %}
</script> </script>
@@ -117,48 +108,35 @@ new Chart(document.getElementById('chart-received'), {
<a href="/postcards/{{ pc.id }}" class="recent-item"> <a href="/postcards/{{ pc.id }}" class="recent-item">
<div class="recent-img"> <div class="recent-img">
{% if pc.image_front %} {% if pc.image_front %}
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy"> <img src="{{ pc.image_front }}" alt="" loading="lazy">
{% else %} {% else %}
<span>📷</span> <span>📷</span>
{% endif %} {% endif %}
</div> </div>
<div class="recent-info"> <div class="recent-info">
<strong>{{ pc.card_number }}</strong> <strong>{{ pc.card_number }}</strong>
<span class="badge badge-{{ pc.status }}">{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered','received':'Received'}.get(pc.status) }}</span> <span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(pc.status) }}</span>
</div> </div>
<div style="margin-left:auto;text-align:right;display:flex;gap:1rem;align-items:center"> <div class="recent-meta">
<div> {% if pc.status in ('sent','delivered') %}
<div class="recent-meta" style="margin-left:0"> <span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
</div>
<div class="recent-meta" style="margin-left:0">
{% if pc.status in ('pending','sent','delivered') %}
<span>→ {{ pc.recipient_name or '-' }}</span> <span>→ {{ pc.recipient_name or '-' }}</span>
{% else %} {% else %}
<span>← {{ pc.sender_name or '-' }}</span> <span>← {{ pc.sender_name or '-' }}</span>
{% endif %} {% endif %}
</div>
</div>
<div class="recent-meta" style="margin-left:0">
<span class="recent-time"> <span class="recent-time">
{% if pc.status == 'sent' and pc.send_time %} {% if pc.status == 'sent' and pc.send_time %}
{% set local_time = pc.send_time|to_local %} {% set delta = (now.date() - pc.send_time.date()).days %}
{% set delta = (now.date() - local_time.date()).days %}
{% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %} {% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %}
{% elif pc.status == 'delivered' and pc.arrival_time %} {% elif pc.status == 'delivered' and pc.arrival_time %}
{% set local_time = pc.arrival_time|to_local %} {% set delta = (now.date() - pc.arrival_time.date()).days %}
{% set delta = (now.date() - local_time.date()).days %}
{% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %} {% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %}
{% elif pc.status == 'received' and pc.receive_time %} {% elif pc.status == 'received' and pc.receive_time %}
{% set local_time = pc.receive_time|to_local %} {% set delta = (now.date() - pc.receive_time.date()).days %}
{% set delta = (now.date() - local_time.date()).days %}
{% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %} {% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %}
{% endif %} {% endif %}
</span> </span>
</div> </div>
</div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
-173
View File
@@ -1,173 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ 'brand'|t(lang) }}{% endblock %}
{% block nav %}
<nav class="navbar">
<a href="/" class="nav-brand">{{ 'brand'|t(lang) }}</a>
<button class="nav-hamburger" onclick="this.nextElementSibling.classList.toggle('open')"></button>
<div class="nav-links">
<div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<div class="lang-dropdown" id="langDropdown">
{% for code, name in get_languages().items() %}
<a href="/?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a>
{% endfor %}
</div>
</div>
<a href="/login" class="btn btn-sm">{{ 'index.go_login'|t(lang) }}</a>
<a href="/register" class="btn btn-primary btn-sm">{{ 'index.go_register'|t(lang) }}</a>
</div>
</nav>
{% endblock %}
{% block content %}
<div class="section-header" style="text-align:center">
<h1 style="font-size:1.3rem">📮 {{ 'index.title'|t(lang) }}</h1>
<p style="color:var(--text-muted);font-size:.85rem;margin-top:.15rem">{{ 'index.subtitle'|t(lang) }}</p>
</div>
{% set visible = [] %}
{% for pc in postcards %}
{% if pc.image_front %}
{% if visible.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% if visible|length > 0 or has_more %}
<div class="showcase-grid" id="showcase-grid">
{% for pc in visible %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% if has_more %}
<div id="scroll-sentinel" style="height:1px"></div>
<div id="scroll-loading" style="text-align:center;padding:1rem;color:var(--text-muted);display:none"></div>
{% endif %}
{% else %}
<div class="empty-state">
<p>{{ 'index.empty'|t(lang) }}</p>
</div>
{% endif %}
<!-- Lightbox -->
<div id="lightbox" class="lightbox" onclick="closeLightbox()" style="display:none">
<div class="lightbox-content" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox()">&times;</button>
<button class="lightbox-prev" onclick="navLightbox(-1)">&#8249;</button>
<button class="lightbox-next" onclick="navLightbox(1)">&#8250;</button>
<img id="lightbox-img" src="" alt="">
<div id="lightbox-caption" class="lightbox-caption"></div>
</div>
</div>
<script>
/* ---- Infinite Scroll ---- */
var _page = 1;
var _hasMore = {{ 'true' if has_more else 'false' }};
var _loading = false;
function _appendCard(c) {
var grid = document.getElementById('showcase-grid');
if (!grid) return;
var div = document.createElement('div');
div.className = 'showcase-card';
div.setAttribute('data-front', c.image_front);
div.onclick = function() { openLightbox(div); };
var countryHtml = '';
if (c.country_from && c.country_to) countryHtml = '<span>' + _flag(c.country_from) + ' ' + c.country_from + ' → ' + _flag(c.country_to) + ' ' + c.country_to + '</span>';
else if (c.country_to) countryHtml = '<span>' + _flag(c.country_to) + ' ' + c.country_to + '</span>';
div.innerHTML = '<img src="' + c.image_front + '" alt="" class="showcase-card-img" loading="lazy">' +
'<div class="showcase-card-body"><div class="showcase-card-meta">' + countryHtml + '</div></div>';
grid.appendChild(div);
}
function _flag(code) {
if (!code || code.length !== 2) return code || '';
return String.fromCodePoint(0x1F1E6 + code.charCodeAt(0) - 65) + String.fromCodePoint(0x1F1E6 + code.charCodeAt(1) - 65);
}
function _loadMore() {
if (_loading || !_hasMore) return;
_loading = true;
document.getElementById('scroll-loading').style.display = '';
fetch('/api/public/cards?page=' + (_page + 1) + '&limit=20')
.then(function(r) { return r.json(); })
.then(function(data) {
_loading = false;
document.getElementById('scroll-loading').style.display = 'none';
_page = data.page;
_hasMore = data.has_more;
data.cards.forEach(_appendCard);
if (!_hasMore) {
var s = document.getElementById('scroll-sentinel');
if (s) s.remove();
}
})
.catch(function() { _loading = false; });
}
var _observer = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting) _loadMore();
}, {rootMargin: '400px'});
var _sentinel = document.getElementById('scroll-sentinel');
if (_sentinel) _observer.observe(_sentinel);
/* ---- Lightbox ---- */
var lightboxItems = [];
var lightboxIdx = 0;
function buildLightboxItems() {
lightboxItems = [];
document.querySelectorAll('#showcase-grid .showcase-card[data-front]').forEach(function(card) {
lightboxItems.push({src: card.getAttribute('data-front')});
});
}
function openLightbox(el) {
buildLightboxItems();
var src = el.getAttribute('data-front');
if (!src) return;
for (var i = 0; i < lightboxItems.length; i++) {
if (lightboxItems[i].src === src) { lightboxIdx = i; break; }
}
showLightboxItem();
document.getElementById('lightbox').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function showLightboxItem() {
if (!lightboxItems.length) return;
var item = lightboxItems[lightboxIdx];
document.getElementById('lightbox-img').src = item.src;
document.getElementById('lightbox-caption').textContent = (lightboxIdx+1) + '/' + lightboxItems.length;
}
function closeLightbox(e) {
if (e) e.stopPropagation();
document.getElementById('lightbox').style.display = 'none';
document.body.style.overflow = '';
}
function navLightbox(dir, e) {
if (e) e.stopPropagation();
if (!lightboxItems.length) return;
lightboxIdx = (lightboxIdx + dir + lightboxItems.length) % lightboxItems.length;
showLightboxItem();
}
document.addEventListener('keydown', function(e) {
var lb = document.getElementById('lightbox');
if (!lb || lb.style.display === 'none') return;
if (e.key === 'Escape') closeLightbox();
else if (e.key === 'ArrowLeft') navLightbox(-1);
else if (e.key === 'ArrowRight') navLightbox(1);
});
</script>
{% endblock %}
+10 -13
View File
@@ -5,14 +5,14 @@
<div class="detail-title-row"> <div class="detail-title-row">
<a href="/profiles/{{ postcard.profile_id }}/postcards" class="btn btn-back"></a> <a href="/profiles/{{ postcard.profile_id }}/postcards" class="btn btn-back"></a>
<h1>{{ postcard.card_number }}</h1> <h1>{{ postcard.card_number }}</h1>
<span class="badge badge-{{ postcard.status }}">{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</span> <span class="badge badge-{{ postcard.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</span>
</div> </div>
<div class="detail-actions"> <div class="detail-actions">
<a href="/postcards/{{ postcard.id }}/edit" class="btn">{{ 'pc_detail.edit'|t(user.language) }}</a> <a href="/postcards/{{ postcard.id }}/edit" class="btn">{{ 'pc_detail.edit'|t(user.language) }}</a>
{% if postcard.status == 'pending' %} {% if postcard.status == 'sent' %}
<button type="button" class="btn btn-primary" onclick="if(confirm('{{ 'pc_detail.confirm_sent'|t(user.language) }}'))fetch('/postcards/{{ postcard.id }}/mark-sent',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_detail.mark_sent'|t(user.language) }}</button> <form method="post" action="/postcards/{{ postcard.id }}/mark-delivered" class="inline">
{% elif postcard.status == 'sent' %} <button type="submit" class="btn btn-primary" onclick="return confirm('{{ 'pc_detail.confirm_delivered'|t(user.language) }}')">{{ 'pc_detail.mark_delivered'|t(user.language) }}</button>
<button type="button" class="btn btn-primary" onclick="if(confirm('{{ 'pc_detail.confirm_delivered'|t(user.language) }}'))fetch('/postcards/{{ postcard.id }}/mark-delivered',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_detail.mark_delivered'|t(user.language) }}</button> </form>
{% endif %} {% endif %}
<form method="post" action="/postcards/{{ postcard.id }}/delete" class="inline"> <form method="post" action="/postcards/{{ postcard.id }}/delete" class="inline">
<button type="submit" class="btn btn-danger" onclick="return confirm('{{ 'pc_detail.confirm_delete'|t(user.language) }}')">{{ 'pc_detail.delete'|t(user.language) }}</button> <button type="submit" class="btn btn-danger" onclick="return confirm('{{ 'pc_detail.confirm_delete'|t(user.language) }}')">{{ 'pc_detail.delete'|t(user.language) }}</button>
@@ -24,7 +24,7 @@
<div class="image-block"> <div class="image-block">
<h3>{{ 'pc_detail.front'|t(user.language) }}</h3> <h3>{{ 'pc_detail.front'|t(user.language) }}</h3>
{% if postcard.image_front %} {% if postcard.image_front %}
<img src="{{ postcard.image_front|image_url }}" alt="" class="detail-img"> <img src="{{ postcard.image_front }}" alt="" class="detail-img">
{% else %} {% else %}
<div class="detail-img placeholder">{{ 'pc_detail.no_front'|t(user.language) }}</div> <div class="detail-img placeholder">{{ 'pc_detail.no_front'|t(user.language) }}</div>
{% endif %} {% endif %}
@@ -36,7 +36,7 @@
<div class="image-block"> <div class="image-block">
<h3>{{ 'pc_detail.back'|t(user.language) }}</h3> <h3>{{ 'pc_detail.back'|t(user.language) }}</h3>
{% if postcard.image_back %} {% if postcard.image_back %}
<img src="{{ postcard.image_back|image_url }}" alt="" class="detail-img"> <img src="{{ postcard.image_back }}" alt="" class="detail-img">
{% else %} {% else %}
<div class="detail-img placeholder">{{ 'pc_detail.no_back'|t(user.language) }}</div> <div class="detail-img placeholder">{{ 'pc_detail.no_back'|t(user.language) }}</div>
{% endif %} {% endif %}
@@ -49,17 +49,14 @@
<div class="detail-meta"> <div class="detail-meta">
<dl> <dl>
<dt>{{ 'pc_detail.card_number'|t(user.language) }}</dt><dd>{{ postcard.card_number }}</dd> <dt>{{ 'pc_detail.card_number'|t(user.language) }}</dt><dd>{{ postcard.card_number }}</dd>
<dt>{{ 'pc_detail.status'|t(user.language) }}</dt><dd>{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</dd> <dt>{{ 'pc_detail.status'|t(user.language) }}</dt><dd>{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(postcard.status, postcard.status) }}</dd>
{% if postcard.status in ('pending','sent','delivered') %} {% if postcard.status in ('sent','delivered') %}
<dt>{{ 'pc_detail.recipient'|t(user.language) }}</dt><dd>{{ postcard.recipient_name or '-' }}</dd> <dt>{{ 'pc_detail.recipient'|t(user.language) }}</dt><dd>{{ postcard.recipient_name or '-' }}</dd>
<dt>{{ 'pc_detail.route'|t(user.language) }}</dt><dd>{% if postcard.country_from %}{{ postcard.country_from|flag }} {{ postcard.country_from }} → {% endif %}{{ postcard.country_to|flag }} {{ postcard.country_to or '-' }}</dd> <dt>{{ 'pc_detail.country'|t(user.language) }}</dt><dd>{{ postcard.country|flag }} {{ postcard.country or '-' }}</dd>
<dt>{{ 'pc_detail.send_time'|t(user.language) }}</dt><dd>{{ postcard.send_time.strftime('%Y-%m-%d') if postcard.send_time else '-' }}</dd> <dt>{{ 'pc_detail.send_time'|t(user.language) }}</dt><dd>{{ postcard.send_time.strftime('%Y-%m-%d') if postcard.send_time else '-' }}</dd>
{% if postcard.status == 'delivered' %}
<dt>{{ 'pc_detail.arrival_time'|t(user.language) }}</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard.arrival_time else '-' }}</dd> <dt>{{ 'pc_detail.arrival_time'|t(user.language) }}</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard.arrival_time else '-' }}</dd>
{% endif %}
{% else %} {% else %}
<dt>{{ 'pc_detail.sender'|t(user.language) }}</dt><dd>{{ postcard.sender_name or '-' }}</dd> <dt>{{ 'pc_detail.sender'|t(user.language) }}</dt><dd>{{ postcard.sender_name or '-' }}</dd>
<dt>{{ 'pc_detail.route'|t(user.language) }}</dt><dd>{% if postcard.country_from %}{{ postcard.country_from|flag }} {{ postcard.country_from }} → {% endif %}{{ postcard.country_to|flag }} {{ postcard.country_to or '-' }}</dd>
<dt>{{ 'pc_detail.receive_time'|t(user.language) }}</dt><dd>{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard.receive_time else '-' }}</dd> <dt>{{ 'pc_detail.receive_time'|t(user.language) }}</dt><dd>{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard.receive_time else '-' }}</dd>
{% endif %} {% endif %}
<dt>{{ 'pc_detail.profile'|t(user.language) }}</dt><dd>{{ postcard.profile.nickname }}</dd> <dt>{{ 'pc_detail.profile'|t(user.language) }}</dt><dd>{{ postcard.profile.nickname }}</dd>
+69 -121
View File
@@ -1,79 +1,40 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% if postcard %}{{ 'pc_form.edit'|t(user.language) }}{% else %}{% if create_type == 'received' %}{{ 'pc_form.new_received'|t(user.language) }}{% else %}{{ 'pc_form.new_sent'|t(user.language) }}{% endif %}{% endif %} - {{ 'brand'|t(user.language) }}{% endblock %} {% block title %}{{ ('pc_form.edit'|t(user.language)) if postcard else ('pc_form.new'|t(user.language)) }} - {{ 'brand'|t(user.language) }}{% endblock %}
{% block content %} {% block content %}
<div class="section-header"> <div class="section-header">
<h1>{% if postcard %}{{ 'pc_form.edit'|t(user.language) }}{% else %}{% if create_type == 'received' %}{{ 'pc_form.new_received'|t(user.language) }}{% else %}{{ 'pc_form.new_sent'|t(user.language) }}{% endif %}{% endif %}</h1> <h1>{{ ('pc_form.edit'|t(user.language)) if postcard else ('pc_form.new'|t(user.language)) }}</h1>
</div> </div>
<form method="post" action="{{ '/postcards/%d/edit' % postcard.id if postcard else '/profiles/%d/postcards' % profile.id }}" class="form-card">
{% if error %}<div class="form-card" style="border-left:3px solid var(--danger);margin-bottom:1rem;padding:.75rem 1rem;color:var(--danger)">{{ error }}</div>{% endif %}
{% set is_new = (postcard is none) %}
{% set is_edit = (postcard is not none) %}
{% set edit_status = postcard.status if is_edit else '' %}
{% set is_received_form = (create_type == 'received' and is_new) or (is_edit and edit_status == 'received') %}
<form method="post" action="{{ '/postcards/%d/edit' % postcard.id if is_edit else '/profiles/%d/postcards' % profile.id }}" class="form-card">
<label>{{ 'pc_form.number'|t(user.language) }} *</label> <label>{{ 'pc_form.number'|t(user.language) }} *</label>
<input type="text" name="card_number" value="{{ postcard.card_number if is_edit else '' }}" required> <input type="text" name="card_number" value="{{ postcard.card_number if postcard else '' }}" required>
{% if is_received_form %}
{# ── 收到表单:无状态选择 ── #}
<input type="hidden" name="status" value="received">
<label>{{ 'pc_form.sender'|t(user.language) }} *</label>
<input type="text" name="sender_name" value="{{ postcard.sender_name if is_edit else '' }}">
<label>{{ 'pc_form.send_time'|t(user.language) }}</label>
<input type="date" name="send_time" value="{{ postcard.send_time.strftime('%Y-%m-%d') if is_edit and postcard.send_time else '' }}">
<label>{{ 'pc_form.receive_time'|t(user.language) }} *</label>
<input type="date" name="receive_time" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if is_edit and postcard.receive_time else today }}">
<label>{{ 'pc_form.country_from'|t(user.language) }}</label>
<input type="text" name="country_from" value="{{ postcard.country_from if is_edit and postcard.country_from else '' }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<label>{{ 'pc_form.country_to'|t(user.language) }}</label>
<input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else (profile.country if profile else '') }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
{% else %}
{# ── 寄出表单 ── #}
{% if is_new %}
{# 新建寄出:状态默认由日期自动判定,可手动覆盖 #}
<label>{{ 'pc_form.status'|t(user.language) }}</label>
<select name="status" id="status-select">
<option value="pending">{{ 'status.pending'|t(user.language) }}</option>
<option value="sent">{{ 'status.sent'|t(user.language) }}</option>
<option value="delivered">{{ 'status.delivered'|t(user.language) }}</option>
</select>
{% else %}
{# 编辑时:显示状态选择 #}
<label>{{ 'pc_form.status'|t(user.language) }} *</label> <label>{{ 'pc_form.status'|t(user.language) }} *</label>
<select name="status" id="status-select" onchange="toggleFields()" required> <select name="status" id="status-select" onchange="toggleFields()" required>
<option value="pending" {% if edit_status=='pending' %}selected{% endif %}>{{ 'status.pending'|t(user.language) }}</option> <option value="sent" {% if postcard and postcard.status=='sent' %}selected{% endif %}>{{ 'status.sent'|t(user.language) }}</option>
<option value="sent" {% if edit_status=='sent' %}selected{% endif %}>{{ 'status.sent'|t(user.language) }}</option> <option value="delivered" {% if postcard and postcard.status=='delivered' %}selected{% endif %}>{{ 'status.delivered'|t(user.language) }}</option>
<option value="delivered" {% if edit_status=='delivered' %}selected{% endif %}>{{ 'status.delivered'|t(user.language) }}</option> <option value="received" {% if postcard and postcard.status=='received' %}selected{% endif %}>{{ 'status.received'|t(user.language) }}</option>
</select> </select>
{% endif %}
<div id="fields-sent-delivered"> <div id="fields-sent-delivered">
<label>{{ 'pc_form.recipient'|t(user.language) }} *</label> <label>{{ 'pc_form.recipient'|t(user.language) }} *</label>
<input type="text" name="recipient_name" value="{{ postcard.recipient_name if is_edit else '' }}"> <input type="text" name="recipient_name" id="input-recipient" value="{{ postcard.recipient_name if postcard else '' }}">
<label id="label-send-time">{{ 'pc_form.send_time'|t(user.language) }} *</label> <label>{{ 'pc_form.country'|t(user.language) }} *</label>
<input type="date" name="send_time" id="input-send-time" value="{{ postcard.send_time.strftime('%Y-%m-%d') if is_edit and postcard.send_time else (today if is_new else '') }}"> <input type="text" name="country" id="input-country" value="{{ postcard.country if postcard else '' }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<label>{{ 'pc_form.send_time'|t(user.language) }} *</label>
<input type="date" name="send_time" id="input-send-time" value="{{ postcard.send_time.strftime('%Y-%m-%d') if postcard and postcard.send_time else '' }}">
<label id="label-arrival">{{ 'pc_form.arrival_time'|t(user.language) }}</label> <label id="label-arrival">{{ 'pc_form.arrival_time'|t(user.language) }}</label>
<input type="date" name="arrival_time" id="input-arrival" value="{{ postcard.arrival_time.strftime('%Y-%m-%d') if is_edit and postcard.arrival_time else '' }}" {% if is_new or (is_edit and edit_status=='sent') %}disabled{% endif %}> <input type="date" name="arrival_time" id="input-arrival" value="{{ postcard.arrival_time.strftime('%Y-%m-%d') if postcard and postcard.arrival_time else '' }}">
</div> </div>
<label>{{ 'pc_form.country_from'|t(user.language) }}</label> <div id="fields-received" style="display:none">
<input type="text" name="country_from" value="{{ postcard.country_from if is_edit and postcard.country_from else (profile.country if profile else '') }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()"> <label>{{ 'pc_form.sender'|t(user.language) }} *</label>
<input type="text" name="sender_name" id="input-sender" value="{{ postcard.sender_name if postcard else '' }}">
<label>{{ 'pc_form.country_to'|t(user.language) }}</label> <label>{{ 'pc_form.receive_time'|t(user.language) }} *</label>
<input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else '' }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()"> <input type="date" name="receive_time" id="input-receive" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if postcard and postcard.receive_time else '' }}">
</div>
{% endif %}
<label>{{ 'pc_form.notes'|t(user.language) }}</label> <label>{{ 'pc_form.notes'|t(user.language) }}</label>
<textarea name="notes" rows="3" placeholder="{{ 'pc_form.notes_ph'|t(user.language) }}">{{ postcard.notes if is_edit else '' }}</textarea> <textarea name="notes" rows="3" placeholder="{{ 'pc_form.notes_ph'|t(user.language) }}">{{ postcard.notes if postcard else '' }}</textarea>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'pc_form.save'|t(user.language) }}</button> <button type="submit" class="btn btn-primary">{{ 'pc_form.save'|t(user.language) }}</button>
@@ -81,81 +42,68 @@
</div> </div>
</form> </form>
<script> <script>
{% if is_new and not is_received_form %}
// ── 新建寄出:待寄出时禁用寄出时间,寄出时自动填入 ──
(function() {
var sendTime = document.getElementById('input-send-time');
var select = document.getElementById('status-select');
var arrival = document.getElementById('input-arrival');
var labelArrival = document.getElementById('label-arrival');
var labelSend = document.getElementById('label-send-time');
var today = new Date().toISOString().slice(0, 10);
function updateSendTime(s) {
if (s === 'pending') {
sendTime.disabled = true;
sendTime.value = '';
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }}';
} else {
sendTime.disabled = false;
if (!sendTime.value) sendTime.value = today;
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }} *';
}
}
function updateArrival(s) {
if (s === 'delivered') {
arrival.disabled = false;
arrival.value = arrival.value || today;
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }} *';
} else {
arrival.disabled = true;
arrival.value = '';
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }}';
}
}
function sync() {
updateArrival(select.value);
updateSendTime(select.value);
}
select.addEventListener('change', sync);
sync();
})();
{% elif is_edit and not is_received_form %}
// ── 编辑寄出:根据状态切换 send_time / arrival 可用性 ──
function toggleFields() { function toggleFields() {
var s = document.getElementById('status-select').value; var s = document.getElementById('status-select').value;
var sd = document.getElementById('fields-sent-delivered');
var rc = document.getElementById('fields-received');
var recipient = document.getElementById('input-recipient');
var country = document.getElementById('input-country');
var sendTime = document.getElementById('input-send-time'); var sendTime = document.getElementById('input-send-time');
var labelSend = document.getElementById('label-send-time');
var arrival = document.getElementById('input-arrival'); var arrival = document.getElementById('input-arrival');
var labelArrival = document.getElementById('label-arrival'); var labelArrival = document.getElementById('label-arrival');
var today = new Date().toISOString().slice(0, 10); var sender = document.getElementById('input-sender');
if (s === 'pending') { var receive = document.getElementById('input-receive');
sendTime.disabled = true; if (s === 'received') {
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }}'; sd.style.display = 'none';
} else { rc.style.display = '';
sendTime.disabled = false; recipient.required = false;
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }} *'; country.required = false;
} sendTime.required = false;
if (s === 'delivered') { arrival.required = false;
arrival.disabled = false; arrival.disabled = false;
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }} *'; sender.required = true;
} else { receive.required = true;
} else if (s === 'sent') {
sd.style.display = '';
rc.style.display = 'none';
recipient.required = true;
country.required = true;
sendTime.required = true;
arrival.required = false;
arrival.disabled = true; arrival.disabled = true;
arrival.value = ''; arrival.value = '';
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }}'; labelArrival.textContent = '到达时间';
sender.required = false;
receive.required = false;
} else {
sd.style.display = '';
rc.style.display = 'none';
recipient.required = true;
country.required = true;
sendTime.required = true;
arrival.disabled = false;
arrival.required = true;
labelArrival.textContent = '到达时间 *';
sender.required = false;
receive.required = false;
} }
} }
toggleFields(); toggleFields();
{% endif %} {% if not postcard %}
{% if is_new and is_received_form %} // Auto-fill date fields with today on new postcards
// ── 新建收到:自动填日期 ──
(function() { (function() {
var today = new Date().toISOString().slice(0, 10); var today = new Date().toISOString().slice(0, 10);
var rt = document.querySelector('input[name="receive_time"]'); var sendTime = document.getElementById('input-send-time');
if (rt && !rt.value) rt.value = today; var receiveTime = document.getElementById('input-receive');
var statusSel = document.getElementById('status-select');
// Fill on page load
if (statusSel.value === 'sent' && !sendTime.value) sendTime.value = today;
if (statusSel.value === 'received' && !receiveTime.value) receiveTime.value = today;
// Fill on status change
statusSel.addEventListener('change', function() {
if (this.value === 'sent' && !sendTime.value) sendTime.value = today;
if (this.value === 'received' && !receiveTime.value) receiveTime.value = today;
});
})(); })();
{% endif %} {% endif %}
</script> </script>
+10 -16
View File
@@ -3,10 +3,7 @@
{% block content %} {% block content %}
<div class="section-header"> <div class="section-header">
<h1>{{ profile.nickname }} {{ 'pc_list.title'|t(user.language) }}</h1> <h1>{{ profile.nickname }} {{ 'pc_list.title'|t(user.language) }}</h1>
<div style="display:flex;gap:.5rem;flex-wrap:wrap"> <a href="/profiles/{{ profile.id }}/postcards/new" class="btn btn-primary">{{ 'pc_list.new'|t(user.language) }}</a>
<a href="/profiles/{{ profile.id }}/postcards/new?create_type=sent" class="btn btn-primary">{{ 'pc_list.new_sent'|t(user.language) }}</a>
<a href="/profiles/{{ profile.id }}/postcards/new?create_type=received" class="btn">{{ 'pc_list.new_received'|t(user.language) }}</a>
</div>
</div> </div>
<div style="margin-bottom:1rem"> <div style="margin-bottom:1rem">
{% if profile.notes %} {% if profile.notes %}
@@ -36,7 +33,7 @@
<a href="/postcards/{{ pc.id }}" class="postcard-row"> <a href="/postcards/{{ pc.id }}" class="postcard-row">
<div class="postcard-row-img"> <div class="postcard-row-img">
{% if pc.image_front %} {% if pc.image_front %}
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy"> <img src="{{ pc.image_front }}" alt="" loading="lazy">
{% else %} {% else %}
<div class="thumb placeholder">📷</div> <div class="thumb placeholder">📷</div>
{% endif %} {% endif %}
@@ -44,10 +41,10 @@
<div class="postcard-row-info"> <div class="postcard-row-info">
<strong>{{ pc.card_number }}</strong> <strong>{{ pc.card_number }}</strong>
<div class="postcard-row-detail"> <div class="postcard-row-detail">
<span>{% if pc.country_from %}{{ pc.country_from|flag }} {{ pc.country_from }} → {% endif %}{{ pc.country_to|flag }} {{ pc.country_to or '-' }}</span> <span>{{ pc.country|flag }} {{ pc.country or '-' }}</span>
<span>→ {{ pc.recipient_name or '-' }}</span> <span>→ {{ pc.recipient_name or '-' }}</span>
</div> </div>
<span class="badge badge-{{ pc.status }}">{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达'}.get(pc.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered'}.get(pc.status) }}</span> <span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered'}.get(pc.status) }}</span>
<span class="postcard-row-date"> <span class="postcard-row-date">
{% if pc.send_time %}{{ pc.send_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}寄出{% endif %}{% endif %} {% if pc.send_time %}{{ pc.send_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}寄出{% endif %}{% endif %}
{% if pc.arrival_time %}<br>{{ pc.arrival_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}送达{% endif %}{% endif %} {% if pc.arrival_time %}<br>{{ pc.arrival_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}送达{% endif %}{% endif %}
@@ -55,10 +52,10 @@
</div> </div>
{% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %} {% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %}
<div class="postcard-row-action"> <div class="postcard-row-action">
{% if pc.status == 'pending' %} {% if pc.status == 'sent' %}
<button type="button" class="btn btn-sm btn-primary" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_sent'|t(user.language) }}'))fetch('/postcards/{{ pc.id }}/mark-sent',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_list.mark_sent'|t(user.language) }}</button> <form method="post" action="/postcards/{{ pc.id }}/mark-delivered" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_delivered'|t(user.language) }}'))this.submit();">
{% elif pc.status == 'sent' %} <button type="submit" class="btn btn-sm btn-primary">{{ 'pc_list.mark_delivered'|t(user.language) }}</button>
<button type="button" class="btn btn-sm btn-primary" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_delivered'|t(user.language) }}'))fetch('/postcards/{{ pc.id }}/mark-delivered',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_list.mark_delivered'|t(user.language) }}</button> </form>
{% endif %} {% endif %}
</div> </div>
</a> </a>
@@ -74,7 +71,7 @@
<a href="/postcards/{{ pc.id }}" class="postcard-row"> <a href="/postcards/{{ pc.id }}" class="postcard-row">
<div class="postcard-row-img"> <div class="postcard-row-img">
{% if pc.image_front %} {% if pc.image_front %}
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy"> <img src="{{ pc.image_front }}" alt="" loading="lazy">
{% else %} {% else %}
<div class="thumb placeholder">📷</div> <div class="thumb placeholder">📷</div>
{% endif %} {% endif %}
@@ -84,10 +81,7 @@
<div class="postcard-row-detail"> <div class="postcard-row-detail">
<span>← {{ pc.sender_name or '-' }}</span> <span>← {{ pc.sender_name or '-' }}</span>
</div> </div>
<span class="badge badge-received">{{ 'status.received'|t(user.language) }}</span> {% if pc.receive_time %}<span class="postcard-row-date">{{ pc.receive_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}收到{% endif %}</span>{% endif %}
<span class="postcard-row-date">
{% if pc.receive_time %}{{ pc.receive_time.strftime('%Y-%m-%d') }}{% if user.language=='zh' %}收到{% endif %}{% endif %}
</span>
</div> </div>
{% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %} {% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %}
</a> </a>
-7
View File
@@ -7,7 +7,6 @@
<div class="inline-form"> <div class="inline-form">
<form method="post" action="/profiles" class="form-row"> <form method="post" action="/profiles" class="form-row">
<input type="text" name="nickname" placeholder="{{ 'profiles.new_ph'|t(user.language) }}" required> <input type="text" name="nickname" placeholder="{{ 'profiles.new_ph'|t(user.language) }}" required>
<input type="text" name="country" placeholder="{{ 'profiles.country_ph'|t(user.language) }}" maxlength="2" style="width:60px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<button type="submit" class="btn btn-primary">{{ 'profiles.new_btn'|t(user.language) }}</button> <button type="submit" class="btn btn-primary">{{ 'profiles.new_btn'|t(user.language) }}</button>
</form> </form>
</div> </div>
@@ -19,12 +18,10 @@
<div class="profile-card-header"> <div class="profile-card-header">
<h3 class="profile-card-name"> <h3 class="profile-card-name">
<span id="name-display-{{ p.id }}">{{ p.nickname }}</span> <span id="name-display-{{ p.id }}">{{ p.nickname }}</span>
{% if p.country %}<span style="font-size:.8rem;color:var(--text-muted);font-weight:400">{{ p.country|flag }} {{ p.country }}</span>{% endif %}
<button type="button" class="icon-btn" onclick="startRename({{ p.id }})" title="{{ 'profiles.rename'|t(user.language) }}">✏️</button> <button type="button" class="icon-btn" onclick="startRename({{ p.id }})" title="{{ 'profiles.rename'|t(user.language) }}">✏️</button>
</h3> </h3>
<form method="post" action="/profiles/{{ p.id }}/rename" class="rename-form" id="name-edit-{{ p.id }}" style="display:none"> <form method="post" action="/profiles/{{ p.id }}/rename" class="rename-form" id="name-edit-{{ p.id }}" style="display:none">
<input type="text" name="nickname" value="{{ p.nickname }}" required> <input type="text" name="nickname" value="{{ p.nickname }}" required>
<input type="text" name="country" value="{{ p.country or '' }}" maxlength="2" style="width:60px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()" placeholder="{{ 'profiles.country_ph'|t(user.language) }}">
<button type="submit" class="btn btn-primary btn-sm"></button> <button type="submit" class="btn btn-primary btn-sm"></button>
<button type="button" class="btn btn-sm" onclick="cancelRename({{ p.id }})"></button> <button type="button" class="btn btn-sm" onclick="cancelRename({{ p.id }})"></button>
</form> </form>
@@ -34,10 +31,6 @@
<span class="stat-value">{{ e.total }}</span> <span class="stat-value">{{ e.total }}</span>
<span class="stat-label">{{ 'profiles.total'|t(user.language) }}</span> <span class="stat-label">{{ 'profiles.total'|t(user.language) }}</span>
</div> </div>
<div class="stat">
<span class="stat-value badge-pending-text">{{ e.pending }}</span>
<span class="stat-label">{{ 'status.pending'|t(user.language) }}</span>
</div>
<div class="stat"> <div class="stat">
<span class="stat-value badge-sent-text">{{ e.sent }}</span> <span class="stat-value badge-sent-text">{{ e.sent }}</span>
<span class="stat-label">{{ 'dash.sent'|t(user.language) }}</span> <span class="stat-label">{{ 'dash.sent'|t(user.language) }}</span>
+12 -80
View File
@@ -51,7 +51,7 @@
<h3 style="margin-bottom:.75rem">{{ 'settings.language'|t(user.language) }}</h3> <h3 style="margin-bottom:.75rem">{{ 'settings.language'|t(user.language) }}</h3>
<form method="post" action="/settings/language" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap"> <form method="post" action="/settings/language" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
<select name="language" style="width:auto"> <select name="language" style="width:auto">
{% for code, name in get_languages().items() %} {% for code, name in available_languages.items() %}
<option value="{{ code }}" {% if user.language == code %}selected{% endif %}>{{ name }}</option> <option value="{{ code }}" {% if user.language == code %}selected{% endif %}>{{ name }}</option>
{% endfor %} {% endfor %}
</select> </select>
@@ -170,63 +170,31 @@
</div> </div>
{% endif %} {% endif %}
<form method="post" action="/settings/apikeys/create" style="margin-bottom:1rem"> <form method="post" action="/settings/apikeys/create" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem">
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:.75rem"> <input type="text" name="name" placeholder="{{ 'apikey.name_ph'|t(user.language) }}" style="max-width:200px">
<input type="text" name="name" placeholder="{{ 'apikey.name_ph'|t(user.language) }}" style="max-width:200px"> <button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.create'|t(user.language) }}</button>
<button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.create'|t(user.language) }}</button>
</div>
<details>
<summary style="font-size:.85rem;color:var(--text-muted);cursor:pointer;margin-bottom:.5rem">{{ 'apikey.permissions'|t(user.language) }}</summary>
<div style="display:flex;flex-wrap:wrap;gap:.75rem 1.5rem">
{% for scope, label in [
('profiles:read', 'apikey.perm.profiles_read'),
('profiles:write', 'apikey.perm.profiles_write'),
('postcards:read', 'apikey.perm.postcards_read'),
('postcards:write', 'apikey.perm.postcards_write'),
('images:upload', 'apikey.perm.images_upload')
] %}
<label style="display:flex;align-items:center;gap:.3rem;font-size:.85rem;white-space:nowrap">
<input type="checkbox" name="permissions" value="{{ scope }}">
{{ label|t(user.language) }}
</label>
{% endfor %}
</div>
<p style="font-size:.78rem;color:var(--text-muted);margin-top:.5rem">{{ 'apikey.perm.hint'|t(user.language) }}</p>
</details>
</form> </form>
{% if api_keys %} {% if api_keys %}
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch"> <div class="table-wrap">
<table class="table" style="min-width:700px;white-space:nowrap"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>{{ 'apikey.name'|t(user.language) }}</th> <th>{{ 'apikey.name'|t(user.language) }}</th>
<th>{{ 'apikey.key'|t(user.language) }}</th> <th>{{ 'apikey.key'|t(user.language) }}</th>
<th>{{ 'apikey.permissions'|t(user.language) }}</th>
<th>{{ 'apikey.created_at'|t(user.language) }}</th> <th>{{ 'apikey.created_at'|t(user.language) }}</th>
<th>{{ 'apikey.last_used'|t(user.language) }}</th> <th>{{ 'apikey.last_used'|t(user.language) }}</th>
<th style="white-space:nowrap"></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for ak in api_keys %} {% for ak in api_keys %}
{% set perms = ak.permissions|default('[]', true)|json_loads %}
<tr> <tr>
<td style="white-space:nowrap"><strong>{{ ak.name or '—' }}</strong></td> <td>{{ ak.name or '—' }}</td>
<td><code style="font-size:.78rem">{{ ak.key[:8] }}****</code></td> <td><code style="font-size:.8rem">{{ ak.key[:8] }}****</code></td>
<td style="white-space:nowrap"> <td>{{ ak.created_at|to_local|strftime('%Y-%m-%d') }}</td>
{% if not perms %} <td>{% if ak.last_used_at %}{{ ak.last_used_at|to_local|strftime('%Y-%m-%d') }}{% else %}—{% endif %}</td>
<span class="badge" style="background:var(--sent);font-size:.75rem">{{ 'apikey.perm_full_access'|t(user.language) }}</span> <td>
{% else %}
{% for p in perms %}
<span class="badge" style="font-size:.7rem;margin:1px">{{ p }}</span>
{% endfor %}
{% endif %}
</td>
<td style="white-space:nowrap;font-size:.82rem">{{ ak.created_at|to_local|strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td style="white-space:nowrap;font-size:.82rem">{% if ak.last_used_at %}{{ ak.last_used_at|to_local|strftime('%Y-%m-%d %H:%M:%S') }}{% else %}—{% endif %}</td>
<td style="white-space:nowrap">
<button type="button" class="btn btn-sm" onclick="toggleAkEdit({{ ak.id }})">{{ 'apikey.edit'|t(user.language) }}</button>
<form method="post" action="/settings/apikeys/{{ ak.id }}/delete" style="display:inline" onsubmit="return confirm('{{ 'apikey.confirm_delete'|t(user.language) }}')"> <form method="post" action="/settings/apikeys/{{ ak.id }}/delete" style="display:inline" onsubmit="return confirm('{{ 'apikey.confirm_delete'|t(user.language) }}')">
<button type="submit" class="btn btn-danger btn-sm">{{ 'apikey.delete'|t(user.language) }}</button> <button type="submit" class="btn btn-danger btn-sm">{{ 'apikey.delete'|t(user.language) }}</button>
</form> </form>
@@ -236,38 +204,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% for ak in api_keys %}
{% set perms = ak.permissions|default('[]', true)|json_loads %}
<div id="ak-edit-{{ ak.id }}" style="display:none;margin-top:.5rem;background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:1rem">
<h4 style="font-size:.85rem;margin-bottom:.75rem;color:var(--text-muted)">{{ 'apikey.edit'|t(user.language) }}: {{ ak.name or '—' }}</h4>
<form method="post" action="/settings/apikeys/{{ ak.id }}/edit" style="display:flex;flex-direction:column;gap:.5rem">
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<label style="font-size:.85rem;white-space:nowrap">{{ 'apikey.name'|t(user.language) }}</label>
<input type="text" name="name" value="{{ ak.name }}" style="max-width:200px">
</div>
<div style="display:flex;flex-wrap:wrap;gap:.6rem 1.25rem">
{% for scope, label in [
('profiles:read', 'apikey.perm.profiles_read'),
('profiles:write', 'apikey.perm.profiles_write'),
('postcards:read', 'apikey.perm.postcards_read'),
('postcards:write', 'apikey.perm.postcards_write'),
('images:upload', 'apikey.perm.images_upload')
] %}
<label style="display:flex;align-items:center;gap:.3rem;font-size:.85rem;white-space:nowrap">
<input type="checkbox" name="permissions" value="{{ scope }}" {% if scope in perms %}checked{% endif %}>
{{ label|t(user.language) }}
</label>
{% endfor %}
</div>
<div style="display:flex;gap:.5rem;margin-top:.25rem">
<button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.save'|t(user.language) }}</button>
<button type="button" class="btn btn-sm" onclick="toggleAkEdit({{ ak.id }})">{{ 'apikey.cancel'|t(user.language) }}</button>
</div>
</form>
</div>
{% endfor %}
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<p>{{ 'apikey.empty'|t(user.language) }}</p> <p>{{ 'apikey.empty'|t(user.language) }}</p>
@@ -283,9 +219,5 @@ function switchTab(name) {
event.currentTarget.classList.add('active'); event.currentTarget.classList.add('active');
document.getElementById('tab-' + name).style.display = ''; document.getElementById('tab-' + name).style.display = '';
} }
function toggleAkEdit(id) {
var el = document.getElementById('ak-edit-' + id);
if (el) el.style.display = el.style.display === 'none' ? '' : 'none';
}
</script> </script>
{% endblock %} {% endblock %}
+58 -140
View File
@@ -6,30 +6,17 @@
<div class="lang-switcher" id="langSwitcher"> <div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button> <button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<div class="lang-dropdown" id="langDropdown"> <div class="lang-dropdown" id="langDropdown">
{% for code, name in get_languages().items() %} {% for code, name in available_languages.items() %}
<a href="?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a> <a href="?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<div class="section-header" style="text-align:center"> <div class="section-header">
<h1 style="font-size:1.3rem">📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1> <h1>📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1>
</div> </div>
{% set sent_visible = [] %} {% set has_content = (sent_all|length + received_all|length) > 0 %}
{% set received_visible = [] %}
{% for pc in sent_all %}
{% if pc.image_front %}
{% if sent_visible.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% for pc in received_all %}
{% if pc.image_front %}
{% if received_visible.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% set has_content = (sent_visible|length + received_visible|length) > 0 %}
{% if not has_content %} {% if not has_content %}
<div class="empty-state"> <div class="empty-state">
<p>{{ 'showcase.empty'|t(lang) }}</p> <p>{{ 'showcase.empty'|t(lang) }}</p>
@@ -37,21 +24,24 @@
{% else %} {% else %}
<div class="tabs" id="tabs"> <div class="tabs" id="tabs">
<button class="tab active" onclick="switchTab('sent')">{{ 'showcase.tab_sent'|t(lang) }} ({{ sent_visible|length }})</button> <button class="tab active" onclick="switchTab('sent')">{{ 'showcase.tab_sent'|t(lang) }} ({{ sent_all|length }})</button>
<button class="tab" onclick="switchTab('received')">{{ 'showcase.tab_received'|t(lang) }} ({{ received_visible|length }})</button> <button class="tab" onclick="switchTab('received')">{{ 'showcase.tab_received'|t(lang) }} ({{ received_all|length }})</button>
</div> </div>
<div id="tab-sent" class="tab-panel"> <div id="tab-sent" class="tab-panel">
{% if mode == 'flat' %} {% if mode == 'flat' %}
<div class="showcase-grid" id="grid-sent"> <div class="showcase-grid">
{% for pc in sent_visible %} {% for pc in sent_all %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy"> {% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body"> <div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta"> <div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span> <span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div> </div>
</div> </div>
@@ -60,23 +50,20 @@
</div> </div>
{% else %} {% else %}
{% for g in profile_groups %} {% for g in profile_groups %}
{% set g_sent = [] %} {% if g.sent %}
{% for pc in g.sent %}
{% if pc.image_front %}
{% if g_sent.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% if g_sent %}
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3> <h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid"> <div class="showcase-grid">
{% for pc in g_sent %} {% for pc in g.sent %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy"> {% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body"> <div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta"> <div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span> <span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div> </div>
</div> </div>
@@ -86,23 +73,22 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if sent_visible|length > 19 %}
<div id="scroll-sentinel-sent" style="height:1px"></div>
<div id="scroll-loading-sent" style="text-align:center;padding:1rem;color:var(--text-muted);display:none"></div>
{% endif %}
</div> </div>
<div id="tab-received" class="tab-panel" style="display:none"> <div id="tab-received" class="tab-panel" style="display:none">
{% if mode == 'flat' %} {% if mode == 'flat' %}
<div class="showcase-grid" id="grid-received"> <div class="showcase-grid">
{% for pc in received_visible %} {% for pc in received_all %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy"> {% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body"> <div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta"> <div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span> <span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div> </div>
</div> </div>
@@ -111,23 +97,20 @@
</div> </div>
{% else %} {% else %}
{% for g in profile_groups %} {% for g in profile_groups %}
{% set g_received = [] %} {% if g.received %}
{% for pc in g.received %}
{% if pc.image_front %}
{% if g_received.append(pc) %}{% endif %}
{% endif %}
{% endfor %}
{% if g_received %}
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3> <h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid"> <div class="showcase-grid">
{% for pc in g_received %} {% for pc in g.received %}
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)"> <div class="showcase-card" data-front="{{ pc.image_front or '' }}" data-number="{{ pc.card_number }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy"> {% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body"> <div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta"> <div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span> <span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
{% endif %}
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %} {% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div> </div>
</div> </div>
@@ -137,90 +120,20 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if received_visible|length > 19 %}
<div id="scroll-sentinel-received" style="height:1px"></div>
<div id="scroll-loading-received" style="text-align:center;padding:1rem;color:var(--text-muted);display:none"></div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
<div id="lightbox" class="lightbox" onclick="closeLightbox(event)"> <div id="lightbox" class="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox(event)">&times;</button>
<button class="lightbox-prev" onclick="navLightbox(-1,event)">&#10094;</button>
<button class="lightbox-next" onclick="navLightbox(1,event)">&#10095;</button>
<div class="lightbox-content" onclick="event.stopPropagation()"> <div class="lightbox-content" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox(event)">&times;</button>
<button class="lightbox-prev" onclick="navLightbox(-1,event)">&#8249;</button>
<button class="lightbox-next" onclick="navLightbox(1,event)">&#8250;</button>
<img id="lightbox-img" src="" alt=""> <img id="lightbox-img" src="" alt="">
<div id="lightbox-caption" class="lightbox-caption"></div> <div id="lightbox-caption" class="lightbox-caption"></div>
</div> </div>
</div> </div>
<script> <script>
var _username = '{{ profile_user.username }}';
var _sentPage = 1, _sentMore = {{ 'true' if sent_visible|length > 19 else 'false' }};
var _recvPage = 1, _recvMore = {{ 'true' if received_visible|length > 19 else 'false' }};
var _sentLoading = false, _recvLoading = false;
function _flag(code) {
if (!code || code.length !== 2) return code || '';
return String.fromCodePoint(0x1F1E6 + code.charCodeAt(0) - 65) + String.fromCodePoint(0x1F1E6 + code.charCodeAt(1) - 65);
}
function _appendCard(c, gridId) {
var grid = document.getElementById(gridId);
if (!grid) return;
var div = document.createElement('div');
div.className = 'showcase-card';
div.setAttribute('data-front', c.image_front);
div.onclick = function() { openLightbox(div); };
var countryHtml = '';
if (c.country_from && c.country_to) countryHtml = '<span>' + _flag(c.country_from) + ' ' + c.country_from + ' → ' + _flag(c.country_to) + ' ' + c.country_to + '</span>';
else if (c.country_to) countryHtml = '<span>' + _flag(c.country_to) + '</span>';
div.innerHTML = '<img src="' + c.image_front + '" alt="" class="showcase-card-img" loading="lazy">' +
'<div class="showcase-card-body"><div class="showcase-card-meta">' + countryHtml + '</div></div>';
grid.appendChild(div);
}
function _loadMoreSent() {
if (_sentLoading || !_sentMore) return;
_sentLoading = true;
document.getElementById('scroll-loading-sent').style.display = '';
fetch('/api/public/cards/' + _username + '?page=' + (_sentPage + 1) + '&limit=20&tab=sent')
.then(function(r) { return r.json(); })
.then(function(data) {
_sentLoading = false;
document.getElementById('scroll-loading-sent').style.display = 'none';
_sentPage = data.page;
_sentMore = data.has_more;
data.cards.forEach(function(c) { _appendCard(c, 'grid-sent'); });
if (!_sentMore) { var s = document.getElementById('scroll-sentinel-sent'); if (s) s.remove(); }
})
.catch(function() { _sentLoading = false; });
}
function _loadMoreReceived() {
if (_recvLoading || !_recvMore) return;
_recvLoading = true;
document.getElementById('scroll-loading-received').style.display = '';
fetch('/api/public/cards/' + _username + '?page=' + (_recvPage + 1) + '&limit=20&tab=received')
.then(function(r) { return r.json(); })
.then(function(data) {
_recvLoading = false;
document.getElementById('scroll-loading-received').style.display = 'none';
_recvPage = data.page;
_recvMore = data.has_more;
data.cards.forEach(function(c) { _appendCard(c, 'grid-received'); });
if (!_recvMore) { var s = document.getElementById('scroll-sentinel-received'); if (s) s.remove(); }
})
.catch(function() { _recvLoading = false; });
}
var _obsSent = new IntersectionObserver(function(e) { if (e[0].isIntersecting) _loadMoreSent(); }, {rootMargin: '400px'});
var _obsRecv = new IntersectionObserver(function(e) { if (e[0].isIntersecting) _loadMoreReceived(); }, {rootMargin: '400px'});
var _sSent = document.getElementById('scroll-sentinel-sent');
var _sRecv = document.getElementById('scroll-sentinel-received');
if (_sSent) _obsSent.observe(_sSent);
if (_sRecv) _obsRecv.observe(_sRecv);
function switchTab(name) { function switchTab(name) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); }); document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; }); document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
@@ -236,7 +149,8 @@ function buildLightboxItems() {
var visible = document.querySelectorAll('.tab-panel:not([style*="display:none"]) .showcase-card[data-front]'); var visible = document.querySelectorAll('.tab-panel:not([style*="display:none"]) .showcase-card[data-front]');
visible.forEach(function(card) { visible.forEach(function(card) {
var front = card.getAttribute('data-front'); var front = card.getAttribute('data-front');
if (front) lightboxItems.push({src: front}); var number = card.getAttribute('data-number');
if (front) lightboxItems.push({src: front, number: number});
}); });
} }
@@ -244,8 +158,9 @@ function openLightbox(el) {
buildLightboxItems(); buildLightboxItems();
var front = el.getAttribute('data-front'); var front = el.getAttribute('data-front');
if (!front) return; if (!front) return;
var number = el.getAttribute('data-number');
for (var i = 0; i < lightboxItems.length; i++) { for (var i = 0; i < lightboxItems.length; i++) {
if (lightboxItems[i].src === front) { lightboxIdx = i; break; } if (lightboxItems[i].number === number) { lightboxIdx = i; break; }
} }
showLightboxItem(); showLightboxItem();
document.getElementById('lightbox').classList.add('active'); document.getElementById('lightbox').classList.add('active');
@@ -256,7 +171,7 @@ function showLightboxItem() {
if (!lightboxItems.length) return; if (!lightboxItems.length) return;
var item = lightboxItems[lightboxIdx]; var item = lightboxItems[lightboxIdx];
document.getElementById('lightbox-img').src = item.src; document.getElementById('lightbox-img').src = item.src;
document.getElementById('lightbox-caption').textContent = (lightboxIdx+1) + '/' + lightboxItems.length; document.getElementById('lightbox-caption').textContent = item.number + ' (' + (lightboxIdx+1) + '/' + lightboxItems.length + ')';
} }
function closeLightbox(e) { function closeLightbox(e) {
@@ -279,10 +194,13 @@ document.addEventListener('keydown', function(e) {
else if (e.key === 'ArrowLeft') navLightbox(-1); else if (e.key === 'ArrowLeft') navLightbox(-1);
else if (e.key === 'ArrowRight') navLightbox(1); else if (e.key === 'ArrowRight') navLightbox(1);
}); });
// Close language dropdown when clicking outside
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var sw = document.getElementById('langSwitcher'); var sw = document.getElementById('langSwitcher');
var dd = document.getElementById('langDropdown'); var dd = document.getElementById('langDropdown');
if (sw && dd && !sw.contains(e.target)) dd.classList.remove('show'); if (sw && dd && !sw.contains(e.target)) {
dd.classList.remove('show');
}
}); });
</script> </script>
{% endblock %} {% endblock %}
-13
View File
@@ -1,13 +0,0 @@
{
"storage": {
"backend": "local",
"s3": {
"endpoint": "https://s3.amazonaws.com",
"bucket": "my-mailova-bucket",
"region": "auto",
"access_key": "AKIA...",
"secret_key": "...",
"public_url": "https://cdn.example.com/mailova"
}
}
}
-1
View File
@@ -6,4 +6,3 @@ jinja2
bcrypt bcrypt
python-jose[cryptography] python-jose[cryptography]
aiofiles aiofiles
boto3
+912
View File
@@ -0,0 +1,912 @@
INFO: Started server process [19804]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: 127.0.0.1:56021 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:65187 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:65367 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:65377 - "POST /api/register HTTP/1.1" 200 OK
INFO: 127.0.0.1:65068 - "POST /api/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:65073 - "POST /api/profiles HTTP/1.1" 201 Created
INFO: 127.0.0.1:65075 - "POST /api/profiles/2/postcards HTTP/1.1" 201 Created
INFO: 127.0.0.1:49685 - "GET /api/profiles/2/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:63149 - "GET /dashboard HTTP/1.1" 303 See Other
INFO: 127.0.0.1:63151 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:53977 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:55341 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:55341 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:55341 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:52279 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:52279 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:56784 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:56784 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:56784 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:53049 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:53049 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:54774 - "GET /profiles/1/postcards HTTP/1.1" 303 See Other
INFO: 127.0.0.1:54774 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:54774 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:63885 - "GET /profiles/1/postcards HTTP/1.1" 303 See Other
INFO: 127.0.0.1:63885 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:57051 - "GET /profiles/2/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:57051 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:51260 - "GET /postcards/2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51260 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:49954 - "GET /profiles/2/postcards/new HTTP/1.1" 200 OK
INFO: 127.0.0.1:49954 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:58443 - "GET /api-keys HTTP/1.1" 200 OK
INFO: 127.0.0.1:58443 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64813 - "GET /register HTTP/1.1" 200 OK
INFO: 127.0.0.1:63648 - "GET /postcards/2 HTTP/1.1" 303 See Other
INFO: 127.0.0.1:63648 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:63648 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:63648 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:58651 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:58651 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:58651 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:57344 - "GET /postcards/2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57344 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:55540 - "GET /postcards/2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55540 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:57711 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:57711 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:55656 - "GET /profiles/2/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:55656 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:60856 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:49319 - "GET / HTTP/1.1" 303 See Other
INFO: 127.0.0.1:49319 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:49319 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:53181 - "POST /login HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:53181 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:53181 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:53181 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:53181 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63138 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63138 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:63138 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63141 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:63141 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:63141 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63141 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63141 - "GET /postcards/1 HTTP/1.1" 303 See Other
INFO: 127.0.0.1:63141 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:55924 - "GET /profiles/2 HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:55925 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:55924 - "GET /profiles/1 HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:55925 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:55924 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:55924 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55924 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55924 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:61501 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:61501 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:53677 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53677 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:53677 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:53677 - "GET /profiles HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET / HTTP/1.1" 303 See Other
INFO: 160.248.69.190:0 - "GET /login HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:60635 - "GET /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:60635 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:60635 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:60635 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63640 - "GET /profiles/2/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:63640 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:62991 - "GET /postcards/2/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:62991 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:62639 - "GET /postcards/2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62639 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:60832 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:60832 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 160.248.69.190:0 - "GET / HTTP/1.1" 303 See Other
INFO: 160.248.69.190:0 - "GET /login HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET /login HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "POST /login HTTP/1.1" 303 See Other
INFO: 160.248.69.190:0 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET / HTTP/1.1" 303 See Other
INFO: 160.248.69.190:0 - "GET /login HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 160.248.69.190:0 - "GET / HTTP/1.1" 303 See Other
INFO: 160.248.69.190:0 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:54642 - "GET / HTTP/1.1" 303 See Other
INFO: 127.0.0.1:54642 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:54642 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:54642 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:54642 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:57968 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:52619 - "GET /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:52619 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:52619 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:52619 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63819 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:63819 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:56244 - "GET /logout HTTP/1.1" 303 See Other
INFO: 127.0.0.1:56244 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:56244 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61879 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:61351 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:61351 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:61351 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61351 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:61351 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61351 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:61351 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:65088 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:65088 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /profiles/3/postcards?status=&country= HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61247 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:61247 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:58525 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:58525 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:58525 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:58525 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:58525 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64313 - "GET /postcards/2/edit HTTP/1.1" 303 See Other
INFO: 127.0.0.1:64313 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:64313 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:64313 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:56155 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56155 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:56155 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64186 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64186 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:64186 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:64186 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:64186 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64186 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:54092 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:54092 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:54092 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64172 - "GET /postcards/2/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64172 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:53612 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53612 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:53612 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64632 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64632 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:64632 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:56793 - "GET /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:56793 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:56793 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:56793 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63573 - "GET /postcards/2/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:63573 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:49705 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:49705 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:49705 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:49705 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:49709 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:49709 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:49709 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61191 - "GET /postcards/2/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:61191 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:61191 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:52648 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:52648 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /logout HTTP/1.1" 303 See Other
INFO: 127.0.0.1:52648 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:52648 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /register HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:52648 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:56935 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:56935 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:57402 - "GET /profiles/2/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:57402 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:57402 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:64784 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "POST /postcards/3/edit HTTP/1.1" 303 See Other
INFO: 127.0.0.1:64784 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64784 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64784 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /profiles/3/postcards/new HTTP/1.1" 200 OK
INFO: 127.0.0.1:64150 - "POST /profiles/3/postcards HTTP/1.1" 303 See Other
INFO: 127.0.0.1:64150 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:64150 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64150 - "GET /postcards/4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64150 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:51392 - "GET /postcards/4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /postcards/4/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "POST /postcards/3/delete HTTP/1.1" 303 See Other
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:51392 - "GET /postcards/4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "POST /postcards/4/delete HTTP/1.1" 303 See Other
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:61595 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:61595 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61595 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:57241 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:57241 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:57241 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:53577 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:53577 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:53577 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:54767 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:54767 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:54767 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:51793 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:51793 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:50915 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:50915 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:50915 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:63812 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63813 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50807 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50808 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61967 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61968 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61967 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61967 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61120 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61120 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61120 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:61120 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:49678 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:49678 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:49678 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:52638 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:61428 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:50943 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:50943 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:50943 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:64612 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:64612 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:64612 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:63763 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:63763 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:63763 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:58913 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:58913 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:54850 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:54850 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:54850 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:59821 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:59821 - "GET /logout HTTP/1.1" 303 See Other
INFO: 127.0.0.1:59821 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:59821 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:59821 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:59821 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:59821 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:55813 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:55813 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:55813 - "GET /profiles/3/postcards/new HTTP/1.1" 200 OK
INFO: 127.0.0.1:55813 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:55813 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:62477 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:62477 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:62477 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61813 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:61813 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:61813 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61813 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61813 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61818 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61818 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:61818 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50400 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:50400 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "GET /profiles/3/postcards/new HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:50400 - "GET /api-keys HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:58000 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50400 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:58000 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50400 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:58000 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:49883 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined
INFO: 127.0.0.1:59726 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50231 - "GET /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:50231 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined
INFO: 127.0.0.1:64510 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:51438 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined
INFO: 127.0.0.1:64876 - "GET /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:64876 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined
INFO: 127.0.0.1:57404 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:57404 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:59212 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:59212 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined
INFO: 127.0.0.1:54873 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined