Compare commits

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

No files matched your search

-1
View File
@@ -5,4 +5,3 @@ uploads/*
!uploads/.gitkeep !uploads/.gitkeep
*.db *.db
.env .env
server.log
-27
View File
@@ -8,15 +8,6 @@
"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",
"login.title": "Login", "login.title": "Login",
"login.username": "Username", "login.username": "Username",
"login.password": "Password", "login.password": "Password",
@@ -254,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": " *"
} }
-27
View File
@@ -8,15 +8,6 @@
"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": "保存设置",
"login.title": "登录", "login.title": "登录",
"login.username": "用户名", "login.username": "用户名",
"login.password": "密码", "login.password": "密码",
@@ -254,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": " *"
} }
-34
View File
@@ -109,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],
}
+41 -141
View File
@@ -7,63 +7,26 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session as DbSession from sqlalchemy.orm import Session as DbSession
from app.database import get_db from app.database import get_db
from app.models import ( from app.models import ApiKey, Profile, Postcard, User
ApiKey, Profile, Postcard, User,
PERM_PROFILES_READ, PERM_PROFILES_WRITE,
PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE,
PERM_IMAGES_UPLOAD,
)
from app.config import UPLOAD_DIR from app.config import UPLOAD_DIR
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,8 +36,7 @@ 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 ────────────────────────────────────────────
@@ -140,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
@@ -190,22 +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)),
):
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id)
db.add(p) db.add(p)
db.commit() db.commit()
db.refresh(p) db.refresh(p)
@@ -213,22 +165,13 @@ 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()
db.commit() db.commit()
@@ -237,12 +180,8 @@ def update_profile(
@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}
@@ -256,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)
@@ -270,10 +209,10 @@ 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()
if db.query(Postcard).filter(Postcard.card_number == cn).first(): if db.query(Postcard).filter(Postcard.card_number == cn).first():
raise HTTPException(status_code=409, detail="Card number already exists") raise HTTPException(status_code=409, detail="Card number already exists")
@@ -299,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)
@@ -310,10 +249,10 @@ 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:
@@ -339,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}
@@ -358,10 +297,10 @@ 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)
ext = Path(file.filename).suffix if file.filename else ".jpg" ext = Path(file.filename).suffix if file.filename else ".jpg"
save_name = f"pc_{pc.id}_{side}{ext}" save_name = f"pc_{pc.id}_{side}{ext}"
save_path = UPLOAD_DIR / save_name save_path = UPLOAD_DIR / save_name
@@ -381,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}
+28 -108
View File
@@ -67,19 +67,6 @@ 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)
@@ -90,21 +77,6 @@ from app.i18n import get_languages as _get_languages
templates.env.globals["available_languages"] = _get_languages() templates.env.globals["available_languages"] = _get_languages()
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["copyright_text"] = _get_copyright()
def _redirect(url: str) -> RedirectResponse: def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url, status_code=303) return RedirectResponse(url, status_code=303)
@@ -216,32 +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):
return templates.TemplateResponse("admin.html", _admin_context(user, db, active_tab, request=request, **extra))
@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,
@@ -250,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="Saved")
@router.post("/admin/invites/add") @router.post("/admin/invites/add")
@@ -291,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="邀请码必须为8个字符") 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="邀请码已存在") 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
@@ -307,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=f"邀请码 {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")
@@ -335,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=f"邀请码 {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")
@@ -351,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 ----------
@@ -917,21 +862,17 @@ 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 templates.TemplateResponse("settings.html", { return templates.TemplateResponse("settings.html", {
"request": request, "user": user, "active_tab": "apikeys", "request": request, "user": user, "active_tab": "apikeys",
@@ -953,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)
-23
View File
@@ -40,29 +40,6 @@ a:hover { text-decoration: underline; }
.lang-option:hover { background: var(--bg); color: var(--primary); } .lang-option:hover { background: var(--bg); color: var(--primary); }
.lang-option.active { color: var(--primary); font-weight: 600; } .lang-option.active { color: var(--primary); font-weight: 600; }
.showcase-lang-bar { position: fixed; top: 1rem; right: 1.5rem; z-index: 100; } .showcase-lang-bar { position: fixed; top: 1rem; right: 1.5rem; z-index: 100; }
.nav-hamburger { display: none; background: none; border: none; font-size: 1.25rem; cursor: pointer; padding: .25rem .5rem; color: var(--text); }
@media (max-width: 640px) {
.nav-hamburger { display: block; }
.nav-links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border-bottom: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0,0,0,.08);
flex-direction: column;
align-items: flex-start;
padding: .5rem 1.5rem;
gap: .75rem;
z-index: 50;
}
.nav-links.open { display: flex; }
.lang-switcher { position: static; }
.lang-dropdown { position: static; box-shadow: none; border: none; }
}
/* Container */ /* Container */
.container { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; } .container { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; }
-141
View File
@@ -1,141 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ 'admin.title'|t(user.language) }} - {{ 'brand'|t(user.language) }}{% endblock %}
{% block content %}
<div class="section-header">
<h1>{{ 'admin.title'|t(user.language) }}</h1>
</div>
<div class="tabs" id="tabs">
<button class="tab {% if active_tab != 'config' %}active{% endif %}" onclick="switchTab('invites')">{{ 'admin.tab_invites'|t(user.language) }}</button>
<button class="tab {% if active_tab == 'config' %}active{% endif %}" onclick="switchTab('config')">{{ 'admin.tab_config'|t(user.language) }}</button>
</div>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<!-- ═══ Tab: Invites ═══ -->
<div id="tab-invites" class="tab-panel" {% if active_tab == 'config' %}style="display:none"{% endif %}>
<div class="form-card" style="margin-bottom:1.5rem">
<h3 style="margin-bottom:.75rem">{{ 'invites.create'|t(user.language) }}</h3>
<form method="post" action="/admin/invites/add" style="display:flex;flex-direction:column;gap:.85rem">
<div>
<label style="margin-top:0">{{ 'invites.code_label'|t(user.language) }}</label>
<input type="text" name="code" maxlength="8" placeholder="{{ 'invites.code_ph'|t(user.language) }}" style="text-transform:uppercase">
</div>
<div>
<label style="margin-top:0">{{ 'invites.limit_type'|t(user.language) }}</label>
<select name="limit_type" id="limit_type" onchange="toggleLimitFields()">
<option value="unlimited">{{ 'invites.unlimited'|t(user.language) }}</option>
<option value="uses">{{ 'invites.uses'|t(user.language) }}</option>
<option value="expires">{{ 'invites.expires'|t(user.language) }}</option>
</select>
</div>
<div id="max_uses_field" style="display:none;align-items:center;gap:.3rem">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.times'|t(user.language) }}</label>
<input type="number" name="max_uses" min="1" value="1" class="aif-input aif-short">
</div>
<div id="expires_field" style="display:none;align-items:center;gap:.3rem">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.expires'|t(user.language) }}</label>
<input type="datetime-local" name="expires_at" class="aif-input aif-date">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'invites.create_btn'|t(user.language) }}</button>
</div>
</form>
</div>
{% if codes %}
<div class="postcard-list">
{% for c in codes %}
<div class="postcard-row" style="flex-direction:column;align-items:stretch;gap:.75rem">
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
<strong style="font-family:monospace;font-size:1.1rem">{{ c.code }}</strong>
<span style="font-size:.8rem;color:var(--text-muted)">{{ 'invites.creator'|t(user.language) }}: {{ c.creator.username if c.creator else '-' }}</span>
<span style="font-size:.8rem;color:var(--text-muted)">{{ c.created_at|to_local|strftime('%Y-%m-%d %H:%M') }}</span>
{% if c.max_uses is not none %}
<span class="badge">{{ 'invites.used'|t(user.language) }} {{ c.use_count }} / {{ c.max_uses }}</span>
{% else %}
<span class="badge">{{ 'invites.used'|t(user.language) }} {{ c.use_count }} / ∞</span>
{% endif %}
{% if c.expires_at %}
<span class="badge">{{ 'invites.expires_at'|t(user.language) }}: {{ c.expires_at|to_local|strftime('%Y-%m-%d %H:%M') }}</span>
{% else %}
<span class="badge">{{ 'invites.no_expire'|t(user.language) }}</span>
{% endif %}
</div>
{% if c.registered_users %}
<div style="font-size:.8rem;color:var(--text-muted)">{{ 'invites.registered_users'|t(user.language) }}: {{ c.registered_users|map(attribute='username')|join(', ') }}</div>
{% endif %}
<form method="post" action="/admin/invites/{{ c.id }}/edit" class="admin-invite-form" id="edit-form-{{ c.id }}">
<div class="aif-field">
<select name="limit_type" class="aif-select" onchange="toggleEditFields({{ c.id }}, this.value)">
<option value="unlimited"{% if c.max_uses is none and not c.expires_at %} selected{% endif %}>{{ 'invites.unlimited'|t(user.language) }}</option>
<option value="uses"{% if c.max_uses is not none %} selected{% endif %}>{{ 'invites.uses'|t(user.language) }}</option>
<option value="expires"{% if c.expires_at %} selected{% endif %}>{{ 'invites.expires'|t(user.language) }}</option>
</select>
</div>
<div style="display:{% if c.max_uses is not none %}inline-flex{% else %}none{% endif %};align-items:center;gap:.3rem;align-self:flex-end">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.times'|t(user.language) }}</label>
<input type="number" name="max_uses" value="{{ c.max_uses or 1 }}" min="1" class="aif-input aif-short">
</div>
<div style="display:{% if c.expires_at %}inline-flex{% else %}none{% endif %};align-items:center;gap:.3rem;align-self:flex-end">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.expires'|t(user.language) }}</label>
<input type="datetime-local" name="expires_at" value="{{ c.expires_at|to_local|strftime('%Y-%m-%dT%H:%M') if c.expires_at else '' }}" class="aif-input aif-date">
</div>
<div style="display:flex;gap:.4rem;align-self:flex-end">
<button type="submit" class="btn btn-sm">{{ 'invites.save'|t(user.language) }}</button>
<button type="button" class="btn btn-danger btn-sm" onclick="if(confirm('{{ 'invites.confirm_delete'|t(user.language) }}'))this.closest('form').nextElementSibling.submit()">{{ 'invites.delete'|t(user.language) }}</button>
</div>
</form>
<form method="post" action="/admin/invites/{{ c.id }}/delete" style="display:none"></form>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🔑</div>
<p>{{ 'invites.empty'|t(user.language) }}</p>
</div>
{% endif %}
</div>
<!-- ═══ Tab: System Config ═══ -->
<div id="tab-config" class="tab-panel" {% if active_tab != 'config' %}style="display:none"{% endif %}>
<div class="form-card">
<h3 style="margin-bottom:.75rem">{{ 'admin.config_title'|t(user.language) }}</h3>
<form method="post" action="/admin/config">
<label>{{ 'admin.copyright_label'|t(user.language) }}</label>
<textarea name="copyright_text" rows="3" style="width:100%;resize:vertical">{{ config.get('copyright_text', '') }}</textarea>
<p style="font-size:.8rem;color:var(--text-muted);margin-top:.25rem">{{ 'admin.copyright_hint'|t(user.language) }}</p>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'admin.save_config'|t(user.language) }}</button>
</div>
</form>
</div>
</div>
<script>
function switchTab(name) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
event.currentTarget.classList.add('active');
document.getElementById('tab-' + name).style.display = '';
}
function toggleLimitFields() {
var t = document.getElementById('limit_type').value;
document.getElementById('max_uses_field').style.display = t === 'uses' ? 'inline-flex' : 'none';
document.getElementById('expires_field').style.display = t === 'expires' ? 'inline-flex' : 'none';
}
function toggleEditFields(id, val) {
var form = document.getElementById('edit-form-' + id);
var num = form.querySelector('[name="max_uses"]').parentElement;
var date = form.querySelector('[name="expires_at"]').parentElement;
num.style.display = val === 'uses' ? 'inline-flex' : 'none';
date.style.display = val === 'expires' ? 'inline-flex' : 'none';
}
</script>
{% endblock %}
-15
View File
@@ -52,21 +52,6 @@
</div> </div>
</div> </div>
<div class="doc-section">
<h2>{{ 'api_docs.permissions'|t(user.language) }}</h2>
<p class="doc-desc">{{ 'api_docs.permissions_desc'|t(user.language) }}</p>
<table class="doc-table">
<tr><th>{{ 'api_docs.scope'|t(user.language) }}</th><th>{{ 'api_docs.scope_desc'|t(user.language) }}</th><th>{{ 'api_docs.scope_endpoints'|t(user.language) }}</th></tr>
<tr><td><code>profiles:read</code></td><td>{{ 'apikey.perm.profiles_read'|t(user.language) }}</td><td>GET /profiles, GET /profiles/{id}</td></tr>
<tr><td><code>profiles:write</code></td><td>{{ 'apikey.perm.profiles_write'|t(user.language) }}</td><td>POST /profiles, PUT /profiles/{id}, DELETE /profiles/{id}</td></tr>
<tr><td><code>postcards:read</code></td><td>{{ 'apikey.perm.postcards_read'|t(user.language) }}</td><td>GET /profiles/{id}/postcards, GET /profiles/{id}/postcards/{id}</td></tr>
<tr><td><code>postcards:write</code></td><td>{{ 'apikey.perm.postcards_write'|t(user.language) }}</td><td>POST /profiles/{id}/postcards, PUT /profiles/{id}/postcards/{id}, DELETE /profiles/{id}/postcards/{id}</td></tr>
<tr><td><code>images:upload</code></td><td>{{ 'apikey.perm.images_upload'|t(user.language) }}</td><td>POST /profiles/{id}/postcards/{id}/upload</td></tr>
<tr><td colspan="3" style="color:var(--text-muted);font-style:italic">{{ 'api_docs.permissions_keys_note'|t(user.language) }}</td></tr>
</table>
<p class="doc-note" style="margin-top:.5rem">{{ 'api_docs.permissions_note'|t(user.language) }}</p>
</div>
<!-- ═══════ Profiles ═══════ --> <!-- ═══════ Profiles ═══════ -->
<div class="doc-section"> <div class="doc-section">
<h2>{{ 'api_docs.profile_ops'|t(user.language) }}</h2> <h2>{{ 'api_docs.profile_ops'|t(user.language) }}</h2>
+3 -10
View File
@@ -10,14 +10,12 @@
{% block nav %} {% block nav %}
<nav class="navbar"> <nav class="navbar">
<a href="/dashboard" 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">
@@ -32,18 +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>
{% if copyright_text %}
<footer style="text-align:center;padding:2rem 1rem;color:var(--text-muted);font-size:.8rem;border-top:1px solid var(--border);margin-top:2rem">
{{ copyright_text }}
</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
+9 -77
View File
@@ -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 %}
-21
View File
@@ -1,21 +0,0 @@
"""One-time export: convert i18n.py translations to JSON files."""
import json
from pathlib import Path
from app.i18n import _translations
locales_dir = Path(__file__).resolve().parent / "app" / "locales"
locales_dir.mkdir(exist_ok=True)
langs: dict[str, dict[str, str]] = {}
for key, values in _translations.items():
for lang, text in values.items():
langs.setdefault(lang, {})[key] = text
for lang, data in sorted(langs.items()):
out = locales_dir / f"{lang}.json"
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"Exported {len(data)} keys -> {out.name}")
print("Done!")
-20
View File
@@ -1,20 +0,0 @@
"""Migrate: add permissions column to api_keys table."""
import sqlite3
from pathlib import Path
DB_PATH = Path(__file__).parent.parent / "postcard.db"
conn = sqlite3.connect(str(DB_PATH))
cur = conn.cursor()
cur.execute("PRAGMA table_info(api_keys)")
cols = [r[1] for r in cur.fetchall()]
if "permissions" not in cols:
cur.execute('ALTER TABLE api_keys ADD COLUMN permissions TEXT NOT NULL DEFAULT "[]"')
conn.commit()
print("OK: column 'permissions' added to api_keys")
else:
print("SKIP: column 'permissions' already exists")
conn.close()
-24
View File
@@ -1,24 +0,0 @@
"""Migrate: create system_config table."""
import sqlite3
from pathlib import Path
DB_PATH = Path(__file__).parent.parent / "postcard.db"
conn = sqlite3.connect(str(DB_PATH))
cur = conn.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='system_config'")
if not cur.fetchone():
cur.execute(
"CREATE TABLE system_config ("
"key TEXT PRIMARY KEY, "
"value TEXT NOT NULL DEFAULT '', "
"updated_at TIMESTAMP"
")"
)
conn.commit()
print("OK: system_config table created")
else:
print("SKIP: system_config table exists")
conn.close()
+912
View File
@@ -0,0 +1,912 @@
INFO: Started server process [19804]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: 127.0.0.1:56021 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:65187 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:65367 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:65377 - "POST /api/register HTTP/1.1" 200 OK
INFO: 127.0.0.1:65068 - "POST /api/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:65073 - "POST /api/profiles HTTP/1.1" 201 Created
INFO: 127.0.0.1:65075 - "POST /api/profiles/2/postcards HTTP/1.1" 201 Created
INFO: 127.0.0.1:49685 - "GET /api/profiles/2/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:63149 - "GET /dashboard HTTP/1.1" 303 See Other
INFO: 127.0.0.1:63151 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:53977 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:55341 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:55341 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:55341 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:52279 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:52279 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:56784 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:56784 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:56784 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:53049 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:53049 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:54774 - "GET /profiles/1/postcards HTTP/1.1" 303 See Other
INFO: 127.0.0.1:54774 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:54774 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:63885 - "GET /profiles/1/postcards HTTP/1.1" 303 See Other
INFO: 127.0.0.1:63885 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:57051 - "GET /profiles/2/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:57051 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:51260 - "GET /postcards/2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51260 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:49954 - "GET /profiles/2/postcards/new HTTP/1.1" 200 OK
INFO: 127.0.0.1:49954 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:58443 - "GET /api-keys HTTP/1.1" 200 OK
INFO: 127.0.0.1:58443 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64813 - "GET /register HTTP/1.1" 200 OK
INFO: 127.0.0.1:63648 - "GET /postcards/2 HTTP/1.1" 303 See Other
INFO: 127.0.0.1:63648 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:63648 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:63648 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:58651 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:58651 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:58651 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:57344 - "GET /postcards/2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57344 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:55540 - "GET /postcards/2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55540 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:57711 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:57711 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:55656 - "GET /profiles/2/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:55656 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:60856 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:49319 - "GET / HTTP/1.1" 303 See Other
INFO: 127.0.0.1:49319 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:49319 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:53181 - "POST /login HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:53181 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:53181 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:53181 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:53181 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63138 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63138 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:63138 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63141 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:63141 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:63141 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63141 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63141 - "GET /postcards/1 HTTP/1.1" 303 See Other
INFO: 127.0.0.1:63141 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:55924 - "GET /profiles/2 HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:55925 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:55924 - "GET /profiles/1 HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:55925 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:55924 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:55924 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55924 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55924 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:61501 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:61501 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:53677 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53677 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:53677 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:53677 - "GET /profiles HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET / HTTP/1.1" 303 See Other
INFO: 160.248.69.190:0 - "GET /login HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:60635 - "GET /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:60635 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:60635 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:60635 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63640 - "GET /profiles/2/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:63640 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:62991 - "GET /postcards/2/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:62991 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:62639 - "GET /postcards/2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62639 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:60832 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:60832 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 160.248.69.190:0 - "GET / HTTP/1.1" 303 See Other
INFO: 160.248.69.190:0 - "GET /login HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET /login HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "POST /login HTTP/1.1" 303 See Other
INFO: 160.248.69.190:0 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET / HTTP/1.1" 303 See Other
INFO: 160.248.69.190:0 - "GET /login HTTP/1.1" 200 OK
INFO: 160.248.69.190:0 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 160.248.69.190:0 - "GET / HTTP/1.1" 303 See Other
INFO: 160.248.69.190:0 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:54642 - "GET / HTTP/1.1" 303 See Other
INFO: 127.0.0.1:54642 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:54642 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:54642 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:54642 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:57968 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:52619 - "GET /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:52619 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:52619 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:52619 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63819 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:63819 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:56244 - "GET /logout HTTP/1.1" 303 See Other
INFO: 127.0.0.1:56244 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:56244 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61879 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:61351 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:61351 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:61351 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61351 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:61351 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61351 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:61351 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:65088 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:65088 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /profiles/3/postcards?status=&country= HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:65088 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61247 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:61247 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:58525 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:58525 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:58525 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:58525 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:58525 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64313 - "GET /postcards/2/edit HTTP/1.1" 303 See Other
INFO: 127.0.0.1:64313 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:64313 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:64313 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:56155 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56155 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:56155 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64186 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64186 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:64186 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:64186 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:64186 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64186 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:54092 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:54092 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:54092 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64172 - "GET /postcards/2/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64172 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:53612 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53612 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:53612 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64632 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64632 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:64632 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:56793 - "GET /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:56793 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:56793 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:56793 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63573 - "GET /postcards/2/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:63573 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:49705 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:49705 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:49705 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:49705 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:49709 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:49709 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:49709 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61191 - "GET /postcards/2/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:61191 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:61191 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:52648 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:52648 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /logout HTTP/1.1" 303 See Other
INFO: 127.0.0.1:52648 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:52648 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /register HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:52648 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:52648 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:56935 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:56935 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:57402 - "GET /profiles/2/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:57402 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:57402 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:64784 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "POST /postcards/3/edit HTTP/1.1" 303 See Other
INFO: 127.0.0.1:64784 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64784 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64784 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /postcards/3/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:64784 - "GET /profiles/3/postcards/new HTTP/1.1" 200 OK
INFO: 127.0.0.1:64150 - "POST /profiles/3/postcards HTTP/1.1" 303 See Other
INFO: 127.0.0.1:64150 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:64150 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:64150 - "GET /postcards/4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64150 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:51392 - "GET /postcards/4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /postcards/4/edit HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /postcards/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "POST /postcards/3/delete HTTP/1.1" 303 See Other
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:51392 - "GET /postcards/4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "POST /postcards/4/delete HTTP/1.1" 303 See Other
INFO: 127.0.0.1:51392 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:51392 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:61595 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:61595 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61595 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:57241 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:57241 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:57241 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:53577 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:53577 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:53577 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:54767 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:54767 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:54767 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:51793 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:51793 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:50915 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:50915 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:50915 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:63812 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63813 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50807 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50808 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61967 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61968 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61967 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61967 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61120 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61120 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61120 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:61120 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:49678 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:49678 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:49678 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:52638 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:61428 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:50943 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:50943 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:50943 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:64612 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:64612 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:64612 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:63763 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:63763 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:63763 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:58913 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:58913 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:54850 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:54850 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:54850 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:59821 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:59821 - "GET /logout HTTP/1.1" 303 See Other
INFO: 127.0.0.1:59821 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:59821 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:59821 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:59821 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:59821 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:59821 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:55813 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:55813 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:55813 - "GET /profiles/3/postcards/new HTTP/1.1" 200 OK
INFO: 127.0.0.1:55813 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:55813 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:62477 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:62477 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:62477 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61813 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:61813 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:61813 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61813 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61813 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:61818 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61818 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:61818 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50400 - "GET /dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:50400 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "GET /profiles/3/postcards/new HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "GET /profiles/3/postcards HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "GET /static/style.css HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:50400 - "GET /api-keys HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "GET /profiles HTTP/1.1" 200 OK
INFO: 127.0.0.1:50400 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:58000 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50400 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:58000 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50400 - "POST /profiles/3/rename HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:58000 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:49883 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined
INFO: 127.0.0.1:59726 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:50231 - "GET /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:50231 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined
INFO: 127.0.0.1:64510 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:51438 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined
INFO: 127.0.0.1:64876 - "GET /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:64876 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined
INFO: 127.0.0.1:57404 - "GET /login HTTP/1.1" 200 OK
INFO: 127.0.0.1:57404 - "GET /static/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:59212 - "POST /login HTTP/1.1" 303 See Other
INFO: 127.0.0.1:59212 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined
INFO: 127.0.0.1:54873 - "GET /dashboard HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 401, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
await self.app(scope, receive, _send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
await self.middleware_stack(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
await route.handle(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
await self.app(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
raise exc
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
await app(scope, receive, sender)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\fastapi\routing.py", line 214, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\concurrency.py", line 39, in run_in_threadpool
return await anyio.to_thread.run_sync(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2596, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1029, in run
result = context.run(func, *args)
File "J:\Projects\postcard-manager\app\routers\web.py", line 115, in dashboard
countries = sorted({c.country for c in cards if c.country})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 209, in TemplateResponse
return _TemplateResponse(
template,
...<4 lines>...
background=background,
)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\starlette\templating.py", line 40, in __init__
content = template.render(context)
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 1304, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 939, in handle_exception
raise rewrite_traceback_stack(source=source)
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 1, in top-level template code
{% extends "base.html" %}
File "J:\Projects\postcard-manager\app\templates\base.html", line 21, in top-level template code
{% block content %}{% endblock %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\app\templates\dashboard.html", line 10, in block 'content'
{% set ex = extras[p.id] %}
^^^^^^^^^^^^^^^^^
File "J:\Projects\postcard-manager\.venv\Lib\site-packages\jinja2\environment.py", line 468, in getitem
return obj[argument]
~~~^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'extras' is undefined