Compare commits
104
Commits
main
..
24532a4fc0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24532a4fc0 | ||
|
|
44d8ed4f75 | ||
|
|
ca9b3cc8dc | ||
|
|
969d8a1b18 | ||
|
|
504eef819e | ||
|
|
5c1e89290d | ||
|
|
69a2ab3e7e | ||
|
|
7e99ce9f08 | ||
|
|
261d53b91f | ||
|
|
4c9c6f6e05 | ||
|
|
e178b93d69 | ||
|
|
4cd2a2c38c | ||
|
|
e875e90161 | ||
|
|
da7ebe45ed | ||
|
|
1a6424ebce | ||
|
|
b3f1d07b7c | ||
|
|
18da45240a | ||
|
|
7620d453b7 | ||
|
|
0f01cc263d | ||
|
|
47a9c6a7ad | ||
|
|
e1e8199375 | ||
|
|
a62eb4cc58 | ||
|
|
189c3718df | ||
|
|
2989837b7d | ||
|
|
a19f6ccac9 | ||
|
|
58386e836a | ||
|
|
c6b5b68f25 | ||
|
|
ef06cd29dc | ||
|
|
48da00c8e7 | ||
|
|
46753a7c48 | ||
|
|
5fdd0d5ed2 | ||
|
|
5ba89eee4f | ||
|
|
028a4d3a05 | ||
|
|
202c1804af | ||
|
|
a34b46b4b1 | ||
|
|
623549009e | ||
|
|
7481af6e4d | ||
|
|
e710ce020e | ||
|
|
65a7c38ca5 | ||
|
|
2e0f15def2 | ||
|
|
9fbe5ce85f | ||
|
|
e77319da50 | ||
|
|
3287dae370 | ||
|
|
2c0725b242 | ||
|
|
a769ce7546 | ||
|
|
d8378e0acc | ||
|
|
630772ae4b | ||
|
|
185b743e05 | ||
|
|
d082a4b421 | ||
|
|
a69c6c10c9 | ||
|
|
9f4f57b80a | ||
|
|
2f770a3185 | ||
|
|
8df3b30f55 | ||
|
|
606662f022 | ||
|
|
16b6e1cb75 | ||
|
|
ab4df2d14c | ||
|
|
4597e739a5 | ||
|
|
7380ed2de4 | ||
|
|
97ab5deafe | ||
|
|
078d09bfbf | ||
|
|
370df969f8 | ||
|
|
dde441ac2f | ||
|
|
82b2442a1c | ||
|
|
f310a30d33 | ||
|
|
4fa72aabf8 | ||
|
|
95bcc42b13 | ||
|
|
ee8d9fa528 | ||
|
|
b50691e624 | ||
|
|
b56c3bc6c5 | ||
|
|
000f39779c | ||
|
|
98bcb4c306 | ||
|
|
2bba519c8a | ||
|
|
ec6cf30e94 | ||
|
|
f4b1c96148 | ||
|
|
c0130afe12 | ||
|
|
fae5a05be1 | ||
|
|
146e424d6c | ||
|
|
b975227458 | ||
|
|
83cea694a1 | ||
|
|
a46d9621be | ||
|
|
59865a9007 | ||
|
|
b5a89c2683 | ||
|
|
8aad025a74 | ||
|
|
e9e0533f5d | ||
|
|
8b2d6c98a3 | ||
|
|
2cfdee180d | ||
|
|
2f220906b5 | ||
|
|
ea326d90c6 | ||
|
|
66451fe05a | ||
|
|
6822de37c2 | ||
|
|
eac6f2f095 | ||
|
|
5b9e50fcb7 | ||
|
|
9ba4ab1021 | ||
|
|
5121fb1f8f | ||
|
|
2054d13825 | ||
|
|
bea6be20b0 | ||
|
|
22d16174a5 | ||
|
|
2ebec81137 | ||
|
|
bd500db365 | ||
|
|
53c8d6cf01 | ||
|
|
2faa8d6012 | ||
|
|
a4cd3fb66d | ||
|
|
cd5d706742 | ||
|
|
a03965796f |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||||
|
|
||||||
|
|||||||
+121
-367
@@ -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
|
||||||
sent_country_counts[c.country_to] = sent_country_counts.get(c.country_to, 0) + 1
|
if c.status in ("sent", "delivered"):
|
||||||
|
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
@@ -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
@@ -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
@@ -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
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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
@@ -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>
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</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,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>
|
||||||
|
|||||||
@@ -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()">×</button>
|
|
||||||
<button class="lightbox-prev" onclick="navLightbox(-1)">‹</button>
|
|
||||||
<button class="lightbox-next" onclick="navLightbox(1)">›</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 %}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
+10
-78
@@ -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 %}
|
||||||
+62
-144
@@ -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 %}
|
||||||
<div class="showcase-card-body">
|
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||||
<div class="showcase-card-meta">
|
{% else %}
|
||||||
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
|
<div class="showcase-card-img placeholder-img">📷</div>
|
||||||
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="showcase-card-body">
|
||||||
|
<strong>{{ pc.card_number }}</strong>
|
||||||
|
<div class="showcase-card-meta">
|
||||||
|
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
||||||
{% 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 %}
|
||||||
<div class="showcase-card-body">
|
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||||
<div class="showcase-card-meta">
|
{% else %}
|
||||||
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
|
<div class="showcase-card-img placeholder-img">📷</div>
|
||||||
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="showcase-card-body">
|
||||||
|
<strong>{{ pc.card_number }}</strong>
|
||||||
|
<div class="showcase-card-meta">
|
||||||
|
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
||||||
{% 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 %}
|
||||||
<div class="showcase-card-body">
|
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||||
<div class="showcase-card-meta">
|
{% else %}
|
||||||
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
|
<div class="showcase-card-img placeholder-img">📷</div>
|
||||||
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="showcase-card-body">
|
||||||
|
<strong>{{ pc.card_number }}</strong>
|
||||||
|
<div class="showcase-card-meta">
|
||||||
|
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
||||||
{% 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 %}
|
||||||
<div class="showcase-card-body">
|
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
|
||||||
<div class="showcase-card-meta">
|
{% else %}
|
||||||
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
|
<div class="showcase-card-img placeholder-img">📷</div>
|
||||||
{% elif pc.country_to %}<span>{{ pc.country_to|flag }} {{ pc.country_to }}</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="showcase-card-body">
|
||||||
|
<strong>{{ pc.card_number }}</strong>
|
||||||
|
<div class="showcase-card-meta">
|
||||||
|
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
|
||||||
{% 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)">
|
||||||
<div class="lightbox-content" onclick="event.stopPropagation()">
|
|
||||||
<button class="lightbox-close" onclick="closeLightbox(event)">×</button>
|
<button class="lightbox-close" onclick="closeLightbox(event)">×</button>
|
||||||
<button class="lightbox-prev" onclick="navLightbox(-1,event)">‹</button>
|
<button class="lightbox-prev" onclick="navLightbox(-1,event)">❮</button>
|
||||||
<button class="lightbox-next" onclick="navLightbox(1,event)">›</button>
|
<button class="lightbox-next" onclick="navLightbox(1,event)">❯</button>
|
||||||
|
<div class="lightbox-content" onclick="event.stopPropagation()">
|
||||||
<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 %}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,4 +6,3 @@ jinja2
|
|||||||
bcrypt
|
bcrypt
|
||||||
python-jose[cryptography]
|
python-jose[cryptography]
|
||||||
aiofiles
|
aiofiles
|
||||||
boto3
|
|
||||||
+912
@@ -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
|
||||||
Reference in New Issue
Block a user