Files
CosScene/server/app/admin/views.py
T
2026-05-09 16:40:29 +08:00

1036 lines
39 KiB
Python

from markupsafe import Markup
from sqladmin import ModelView, action
from sqladmin.fields import SelectField
from sqlalchemy import update
from sqlalchemy.orm import Session
from starlette.requests import Request
from starlette.responses import RedirectResponse
from app.admin.widgets import ImagePickerField
from app.db.session import sync_engine
from app.models.audit_log import AuditLog
from app.models.comment import Comment
from app.models.correction import Correction
from app.models.notification import Notification
from app.models.shooting import ShootingRequest, ShootingApplication
from app.models.event import Event, EventRegistration, EventPhoto
from app.models.promotion import Promotion
from app.models.membership import MembershipPlan, UserMembership
from app.models.app_nav_config import AppNavConfig
from app.models.favorite import Favorite
from app.models.point_ledger import PointLedger
from app.models.rating import Rating
from app.models.report import Report
from app.models.spot import Spot, SpotImage
from app.models.tag import Tag
from app.models.user import User
def _sync_log_admin(operator_id: int, action_name: str, target_type: str, target_id=None, detail=None):
import json
detail_str = json.dumps(detail, ensure_ascii=False) if isinstance(detail, dict) else (str(detail) if detail else None)
with Session(sync_engine) as session:
entry = AuditLog(
operator_id=operator_id,
action=action_name,
target_type=target_type,
target_id=target_id,
detail=detail_str,
)
session.add(entry)
session.commit()
def _bulk_update_status(model, pks_str: str, field: str, value: str):
if not pks_str:
return
pks = [int(pk) for pk in pks_str.split(",") if pk.strip()]
if not pks:
return
with Session(sync_engine) as session:
session.execute(
update(model).where(model.id.in_(pks)).values(**{field: value})
)
session.commit()
AUDIT_STATUS_CHOICES = [
("pending", "待审核"),
("approved", "已通过"),
("rejected", "已驳回"),
("deleted", "已删除"),
]
ROLE_CHOICES = [
("user", "普通用户"),
("moderator", "审核员"),
("admin", "管理员"),
]
IDENTITY_CHOICES = [
("photographer", "摄影师"),
("cosplayer", "Coser"),
("both", "都是"),
]
REPORT_STATUS_CHOICES = [
("pending", "待处理"),
("processing", "处理中"),
("resolved", "已处理"),
("dismissed", "已驳回"),
]
REPORT_TARGET_CHOICES = [
("comment", "评论"),
("spot", "地点"),
("user", "用户"),
]
CORRECTION_STATUS_CHOICES = [
("pending", "待处理"),
("accepted", "已采纳"),
("rejected", "已驳回"),
]
CORRECTION_FIELD_CHOICES = [
("title", "地点名称"),
("city", "所在城市"),
("description", "地点介绍"),
("transport", "交通方式"),
("best_time", "最佳拍摄时间"),
("difficulty", "路径难度"),
("price", "收费信息"),
]
def _select_overrides(*field_names):
return {field_name: SelectField for field_name in field_names}
def _select_args(**field_choices):
return {
field_name: {
"choices": choices,
}
for field_name, choices in field_choices.items()
}
STATUS_BADGE_MAP = {
"pending": ("待审核", "#f59e0b", "rgba(245,158,11,0.12)"),
"approved": ("已通过", "#22c55e", "rgba(34,197,94,0.12)"),
"rejected": ("已驳回", "#ef4444", "rgba(239,68,68,0.12)"),
"deleted": ("已删除", "#94a3b8", "rgba(148,163,184,0.12)"),
"processing": ("处理中", "#3b82f6", "rgba(59,130,246,0.12)"),
"resolved": ("已处理", "#22c55e", "rgba(34,197,94,0.12)"),
"dismissed": ("已驳回", "#94a3b8", "rgba(148,163,184,0.12)"),
"accepted": ("已采纳", "#22c55e", "rgba(34,197,94,0.12)"),
}
ROLE_BADGE_MAP = {
"user": ("普通用户", "#64748b", "rgba(100,116,139,0.12)"),
"moderator": ("审核员", "#3b82f6", "rgba(59,130,246,0.12)"),
"admin": ("管理员", "#ef4444", "rgba(239,68,68,0.12)"),
}
FIELD_NAME_MAP = dict(CORRECTION_FIELD_CHOICES)
TARGET_TYPE_MAP = {
"comment": "评论",
"spot": "地点",
"user": "用户",
}
def _badge(text, color, bg):
return Markup(
f'<span style="display:inline-block;padding:2px 10px;border-radius:6px;'
f'font-size:13px;font-weight:600;color:{color};background:{bg};white-space:nowrap">'
f'{text}</span>'
)
def _status_badge(model, name):
val = getattr(model, name, None) or ""
info = STATUS_BADGE_MAP.get(val)
if info:
return _badge(info[0], info[1], info[2])
return val
def _role_badge(model, name):
val = getattr(model, name, None) or ""
info = ROLE_BADGE_MAP.get(val)
if info:
return _badge(info[0], info[1], info[2])
return val
def _bool_badge(model, name):
val = getattr(model, name, None)
if val:
return _badge("", "#22c55e", "rgba(34,197,94,0.12)")
return _badge("", "#94a3b8", "rgba(148,163,184,0.12)")
def _field_name_label(model, name):
val = getattr(model, name, None) or ""
return FIELD_NAME_MAP.get(val, val)
def _target_type_label(model, name):
val = getattr(model, name, None) or ""
return TARGET_TYPE_MAP.get(val, val)
def _img_preview(model, name):
url = getattr(model, name, None) or getattr(model, "image_url", None)
if not url:
return Markup('<span style="color:#cbd5e1">无头像</span>')
return Markup(
f'<img src="{url}" width="48" height="48" '
f'style="object-fit:cover;border-radius:50%;cursor:pointer;border:2px solid #e2e8f0" '
f'onclick="window.open(this.src)"/>'
)
def _spot_images_preview(model, name):
if not hasattr(model, "images") or not model.images:
return Markup('<span style="color:#cbd5e1">无图片</span>')
html = '<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">'
for img in model.images[:5]:
html += (
f'<img src="{img.image_url}" width="64" height="64" '
f'style="object-fit:cover;border-radius:8px;cursor:pointer;'
f'border:1px solid #e2e8f0;transition:transform .2s" '
f'onmouseover="this.style.transform=\'scale(1.08)\'" '
f'onmouseout="this.style.transform=\'scale(1)\'" '
f'onclick="window.open(this.src)"/>'
)
if len(model.images) > 5:
html += (
f'<span style="color:#94a3b8;font-size:13px;font-weight:600">'
f'+{len(model.images) - 5}</span>'
)
html += "</div>"
return Markup(html)
def _text_preview(model, name, max_len=40):
val = getattr(model, name, None) or ""
if len(val) > max_len:
return Markup(
f'<span title="{val}" style="cursor:help">'
f'{val[:max_len]}…</span>'
)
return val
def _score_stars(model, name):
score = getattr(model, name, None)
if score is None:
return ""
full = int(score)
html = '<span style="color:#f59e0b;font-size:14px;white-space:nowrap">'
for _ in range(full):
html += ""
for _ in range(5 - full):
html += '<span style="color:#e2e8f0">★</span>'
html += f'</span> <span style="color:#64748b;font-size:12px">{score}</span>'
return Markup(html)
def _point_change(model, name):
val = getattr(model, name, None) or 0
if val > 0:
return _badge(f"+{val}", "#22c55e", "rgba(34,197,94,0.12)")
elif val < 0:
return _badge(str(val), "#ef4444", "rgba(239,68,68,0.12)")
return _badge("0", "#94a3b8", "rgba(148,163,184,0.12)")
class AuditedModelView(ModelView):
async def after_model_change(self, data, model, is_created, request: Request):
uid = request.session.get("admin_user_id")
if uid:
action_name = "admin.create" if is_created else "admin.edit"
target_type = model.__tablename__ if hasattr(model, "__tablename__") else type(model).__name__
target_id = getattr(model, "id", None)
_sync_log_admin(uid, action_name, target_type, target_id)
async def after_model_delete(self, model, request: Request):
uid = request.session.get("admin_user_id")
if uid:
target_type = model.__tablename__ if hasattr(model, "__tablename__") else type(model).__name__
target_id = getattr(model, "id", None)
_sync_log_admin(uid, "admin.delete", target_type, target_id)
class UserAdmin(AuditedModelView, model=User):
column_list = [User.id, "avatar_preview", User.nickname, User.phone, User.email, User.city, User.role, User.is_active, User.created_at]
column_details_list = [
User.id, User.nickname, User.phone, User.email,
User.avatar_url, User.city, User.bio, User.identity,
User.role, User.is_active, User.created_at, User.updated_at,
]
column_searchable_list = [User.nickname, User.phone, User.email]
column_sortable_list = [User.id, User.created_at, User.role]
column_default_sort = ("id", True)
can_create = False
can_delete = False
form_columns = [User.nickname, User.avatar_url, User.city, User.bio, User.identity, User.role, User.is_active]
name = "用户"
name_plural = "用户管理"
icon = "fa-solid fa-user"
column_labels = {
User.id: "ID",
User.nickname: "昵称",
User.phone: "手机号",
User.email: "邮箱",
User.city: "城市",
User.bio: "个人简介",
User.role: "角色",
User.is_active: "是否激活",
User.created_at: "注册时间",
User.updated_at: "更新时间",
User.identity: "身份",
User.avatar_url: "头像地址",
"avatar_preview": "头像",
}
form_overrides = {
**_select_overrides("role", "identity"),
"avatar_url": ImagePickerField,
}
form_args = _select_args(role=ROLE_CHOICES, identity=IDENTITY_CHOICES)
column_formatters = {
"avatar_preview": lambda m, n: _img_preview(m, "avatar_url"),
User.role: _role_badge,
User.is_active: _bool_badge,
}
class SpotAdmin(AuditedModelView, model=Spot):
column_list = [Spot.id, Spot.title, Spot.city, Spot.audit_status, "images_preview", "creator", Spot.created_at]
column_searchable_list = [Spot.title, Spot.city]
column_sortable_list = [Spot.id, Spot.created_at, Spot.audit_status]
column_default_sort = [("audit_status", False), ("created_at", False)]
column_details_list = [
Spot.id, Spot.title, Spot.city, Spot.description, Spot.transport,
Spot.best_time, Spot.difficulty, Spot.is_free, Spot.price_min, Spot.price_max,
Spot.audit_status, Spot.reject_reason,
Spot.avg_rating, Spot.rating_count,
"creator", Spot.created_at, Spot.updated_at,
]
form_include_pk = False
form_columns = ["title", "city", "description", "transport", "best_time", "difficulty", "is_free", "price_min", "price_max", "audit_status", "reject_reason", "creator"]
can_delete = False
name = "地点"
name_plural = "地点管理"
icon = "fa-solid fa-map-pin"
column_labels = {
Spot.id: "ID",
Spot.title: "名称",
Spot.city: "城市",
Spot.audit_status: "审核状态",
Spot.reject_reason: "驳回原因",
"creator": "投稿者",
Spot.created_at: "创建时间",
Spot.updated_at: "更新时间",
Spot.description: "介绍",
Spot.transport: "交通方式",
Spot.best_time: "最佳拍摄时间",
Spot.difficulty: "难度说明",
Spot.is_free: "是否免费",
Spot.price_min: "最低价格(元)",
Spot.price_max: "最高价格(元)",
Spot.avg_rating: "平均评分",
Spot.rating_count: "评分数",
"images_preview": "图片",
}
form_overrides = _select_overrides("audit_status")
form_args = _select_args(audit_status=AUDIT_STATUS_CHOICES)
form_ajax_refs = {
"creator": {"fields": ("nickname", "phone", "email"), "order_by": "nickname"},
}
column_formatters = {
"images_preview": _spot_images_preview,
Spot.audit_status: _status_badge,
}
@action(name="approve_spots", label="批量通过", confirmation_message="确认将选中地点设为「已通过」?")
async def action_approve(self, request: Request):
_bulk_update_status(Spot, request.query_params.get("pks", ""), "audit_status", "approved")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
@action(name="reject_spots", label="批量驳回", confirmation_message="确认将选中地点设为「已驳回」?")
async def action_reject(self, request: Request):
_bulk_update_status(Spot, request.query_params.get("pks", ""), "audit_status", "rejected")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
class SpotImageAdmin(AuditedModelView, model=SpotImage):
column_list = [SpotImage.id, "spot", "image_preview", SpotImage.is_cover, SpotImage.audit_status, SpotImage.created_at]
column_sortable_list = [SpotImage.id, SpotImage.spot_id, SpotImage.audit_status]
column_default_sort = ("id", True)
form_columns = ["spot", "image_url", "is_cover", "audit_status", "sort_order"]
name = "地点图片"
name_plural = "图片管理"
icon = "fa-solid fa-image"
column_labels = {
SpotImage.id: "ID",
"spot": "所属地点",
SpotImage.image_url: "图片地址",
"image_preview": "图片预览",
SpotImage.is_cover: "封面",
SpotImage.audit_status: "审核状态",
SpotImage.created_at: "上传时间",
SpotImage.sort_order: "排序",
}
form_overrides = {
**_select_overrides("audit_status"),
"image_url": ImagePickerField,
}
form_args = _select_args(audit_status=AUDIT_STATUS_CHOICES)
form_ajax_refs = {
"spot": {"fields": ("title", "city"), "order_by": "title"},
}
column_formatters = {
"image_preview": lambda m, n: Markup(
f'<img src="{m.image_url}" width="80" height="80" '
f'style="object-fit:cover;border-radius:8px;cursor:pointer;border:1px solid #e2e8f0" '
f'onclick="window.open(this.src)"/>'
) if m.image_url else Markup('<span style="color:#cbd5e1">无图片</span>'),
SpotImage.audit_status: _status_badge,
SpotImage.is_cover: _bool_badge,
}
@action(name="approve_images", label="批量通过", confirmation_message="确认将选中图片设为「已通过」?")
async def action_approve(self, request: Request):
_bulk_update_status(SpotImage, request.query_params.get("pks", ""), "audit_status", "approved")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
@action(name="reject_images", label="批量驳回", confirmation_message="确认将选中图片设为「已驳回」?")
async def action_reject(self, request: Request):
_bulk_update_status(SpotImage, request.query_params.get("pks", ""), "audit_status", "rejected")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
class FavoriteAdmin(ModelView, model=Favorite):
column_list = [Favorite.id, "user", "spot", Favorite.created_at]
column_sortable_list = [Favorite.id, Favorite.created_at]
column_default_sort = ("id", True)
can_create = False
can_edit = False
can_delete = False
name = "收藏"
name_plural = "收藏记录"
icon = "fa-solid fa-heart"
column_labels = {
Favorite.id: "ID",
"user": "用户",
"spot": "地点",
Favorite.created_at: "收藏时间",
}
class PointLedgerAdmin(ModelView, model=PointLedger):
column_list = [
PointLedger.id, "user", PointLedger.change,
PointLedger.balance, PointLedger.reason, PointLedger.ref_type, PointLedger.created_at,
]
column_sortable_list = [PointLedger.id, PointLedger.created_at]
column_default_sort = ("id", True)
can_create = False
can_edit = False
can_delete = False
name = "积分记录"
name_plural = "积分流水"
icon = "fa-solid fa-coins"
column_labels = {
PointLedger.id: "ID",
"user": "用户",
PointLedger.change: "变动",
PointLedger.balance: "余额",
PointLedger.reason: "原因",
PointLedger.ref_type: "关联类型",
PointLedger.created_at: "时间",
}
column_formatters = {
PointLedger.change: _point_change,
}
class CommentAdmin(AuditedModelView, model=Comment):
column_list = [Comment.id, "spot_id", "user", Comment.content, Comment.audit_status, Comment.created_at]
column_searchable_list = [Comment.content]
column_sortable_list = [Comment.id, Comment.created_at, Comment.audit_status]
column_default_sort = ("id", True)
form_columns = [Comment.audit_status]
can_create = False
can_delete = True
name = "评论"
name_plural = "评论管理"
icon = "fa-solid fa-comment"
column_labels = {
Comment.id: "ID",
"spot_id": "地点ID",
"user": "用户",
Comment.content: "内容",
Comment.audit_status: "审核状态",
Comment.created_at: "评论时间",
}
form_overrides = _select_overrides("audit_status")
form_args = _select_args(audit_status=AUDIT_STATUS_CHOICES)
column_formatters = {
Comment.audit_status: _status_badge,
Comment.content: lambda m, n: _text_preview(m, "content", 50),
}
@action(name="approve_comments", label="批量通过", confirmation_message="确认将选中评论设为「已通过」?")
async def action_approve(self, request: Request):
_bulk_update_status(Comment, request.query_params.get("pks", ""), "audit_status", "approved")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
@action(name="reject_comments", label="批量驳回", confirmation_message="确认将选中评论设为「已驳回」?")
async def action_reject(self, request: Request):
_bulk_update_status(Comment, request.query_params.get("pks", ""), "audit_status", "rejected")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
class ReportAdmin(AuditedModelView, model=Report):
column_list = [
Report.id, "reporter", Report.target_type, Report.target_id,
Report.reason, Report.status, "handler", Report.created_at,
]
column_searchable_list = [Report.target_type, Report.reason]
column_sortable_list = [Report.id, Report.created_at, Report.status]
column_default_sort = [("status", False), ("created_at", False)]
form_columns = ["status", "target_type", "handler", "conclusion", "resolved_at"]
can_create = False
can_delete = False
name = "举报"
name_plural = "举报管理"
icon = "fa-solid fa-flag"
column_labels = {
Report.id: "ID",
"reporter": "举报人",
Report.target_type: "目标类型",
Report.target_id: "目标ID",
Report.reason: "原因",
Report.status: "状态",
"handler": "处理人",
Report.created_at: "举报时间",
Report.conclusion: "处理结论",
Report.resolved_at: "处理时间",
}
form_overrides = _select_overrides("status", "target_type")
form_args = _select_args(
status=REPORT_STATUS_CHOICES,
target_type=REPORT_TARGET_CHOICES,
)
form_ajax_refs = {
"handler": {"fields": ("nickname", "phone", "email"), "order_by": "nickname"},
}
column_formatters = {
Report.status: _status_badge,
Report.target_type: _target_type_label,
Report.reason: lambda m, n: _text_preview(m, "reason", 40),
}
@action(name="resolve_reports", label="批量标记已处理", confirmation_message="确认将选中举报标记为「已处理」?")
async def action_resolve(self, request: Request):
_bulk_update_status(Report, request.query_params.get("pks", ""), "status", "resolved")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
@action(name="dismiss_reports", label="批量驳回", confirmation_message="确认将选中举报标记为「已驳回」?")
async def action_dismiss(self, request: Request):
_bulk_update_status(Report, request.query_params.get("pks", ""), "status", "dismissed")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
class RatingAdmin(ModelView, model=Rating):
column_list = [Rating.id, "spot_id", "user_id", Rating.score, Rating.short_comment, Rating.created_at]
column_sortable_list = [Rating.id, Rating.created_at, Rating.score]
column_default_sort = ("id", True)
can_create = False
can_edit = False
can_delete = False
name = "评分"
name_plural = "评分记录"
icon = "fa-solid fa-star"
column_labels = {
Rating.id: "ID",
Rating.spot_id: "地点ID",
Rating.user_id: "用户ID",
Rating.score: "评分",
Rating.short_comment: "短评",
Rating.created_at: "评分时间",
}
column_formatters = {
Rating.score: _score_stars,
Rating.short_comment: lambda m, n: _text_preview(m, "short_comment", 40),
}
class TagAdmin(AuditedModelView, model=Tag):
column_list = [Tag.id, Tag.name, Tag.category, Tag.usage_count, Tag.is_active, Tag.created_at]
column_searchable_list = [Tag.name, Tag.category]
column_sortable_list = [Tag.id, Tag.usage_count, Tag.name]
column_default_sort = ("usage_count", True)
form_columns = [Tag.name, Tag.category, Tag.is_active]
name = "标签"
name_plural = "标签管理"
icon = "fa-solid fa-tag"
column_labels = {
Tag.id: "ID",
Tag.name: "名称",
Tag.category: "分类",
Tag.usage_count: "使用次数",
Tag.is_active: "是否启用",
Tag.created_at: "创建时间",
}
class AuditLogAdmin(ModelView, model=AuditLog):
column_list = [
AuditLog.id, "operator", AuditLog.action,
AuditLog.target_type, AuditLog.target_id, AuditLog.detail, AuditLog.created_at,
]
column_searchable_list = [AuditLog.action, AuditLog.target_type]
column_sortable_list = [AuditLog.id, AuditLog.created_at, AuditLog.action]
column_default_sort = ("id", True)
can_create = False
can_edit = False
can_delete = False
name = "操作日志"
name_plural = "操作日志"
icon = "fa-solid fa-clipboard-list"
column_labels = {
AuditLog.id: "ID",
"operator": "操作人",
AuditLog.action: "操作",
AuditLog.target_type: "目标类型",
AuditLog.target_id: "目标ID",
AuditLog.detail: "详情",
AuditLog.created_at: "操作时间",
}
class CorrectionAdmin(AuditedModelView, model=Correction):
column_list = [
Correction.id, "spot", "user",
Correction.field_name, Correction.suggested_value,
Correction.reason, Correction.status, Correction.created_at,
]
column_searchable_list = [Correction.field_name, Correction.suggested_value]
column_sortable_list = [Correction.id, Correction.created_at, Correction.status]
column_default_sort = [("status", False), ("created_at", False)]
form_columns = ["status"]
can_create = False
can_delete = True
name = "校正建议"
name_plural = "校正建议"
icon = "fa-solid fa-pen-to-square"
column_labels = {
Correction.id: "ID",
"spot": "地点",
"user": "建议人",
Correction.field_name: "校正字段",
Correction.suggested_value: "建议值",
Correction.reason: "理由",
Correction.status: "状态",
Correction.created_at: "提交时间",
}
form_overrides = _select_overrides("status", "field_name")
form_args = _select_args(
status=CORRECTION_STATUS_CHOICES,
field_name=CORRECTION_FIELD_CHOICES,
)
column_formatters = {
Correction.status: _status_badge,
Correction.field_name: _field_name_label,
Correction.suggested_value: lambda m, n: _text_preview(m, "suggested_value", 40),
Correction.reason: lambda m, n: _text_preview(m, "reason", 30),
}
@action(name="accept_corrections", label="批量采纳", confirmation_message="确认将选中校正建议标记为「已采纳」?")
async def action_accept(self, request: Request):
_bulk_update_status(Correction, request.query_params.get("pks", ""), "status", "accepted")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
@action(name="reject_corrections", label="批量驳回", confirmation_message="确认将选中校正建议标记为「已驳回」?")
async def action_reject(self, request: Request):
_bulk_update_status(Correction, request.query_params.get("pks", ""), "status", "rejected")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
class NotificationAdmin(ModelView, model=Notification):
column_list = [Notification.id, "user", Notification.type, Notification.title, Notification.is_read, Notification.created_at]
column_searchable_list = [Notification.title, Notification.type]
column_sortable_list = [Notification.id, Notification.created_at]
column_default_sort = ("id", True)
can_create = False
can_edit = False
can_delete = True
name = "通知"
name_plural = "通知管理"
icon = "fa-solid fa-bell"
column_labels = {
Notification.id: "ID",
"user": "用户",
Notification.type: "类型",
Notification.title: "标题",
Notification.is_read: "已读",
Notification.created_at: "创建时间",
}
column_formatters = {
Notification.is_read: _bool_badge,
}
SHOOTING_STATUS_CHOICES = [
("open", "开放"),
("matched", "已匹配"),
("closed", "已关闭"),
]
ROLE_NEEDED_CHOICES = [
("photographer", "摄影师"),
("cosplayer", "Coser"),
("both", "不限"),
]
APP_STATUS_CHOICES = [
("pending", "待处理"),
("accepted", "已接受"),
("rejected", "已拒绝"),
]
class ShootingRequestAdmin(AuditedModelView, model=ShootingRequest):
column_list = [ShootingRequest.id, ShootingRequest.title, ShootingRequest.city, "creator", ShootingRequest.status, ShootingRequest.audit_status, ShootingRequest.created_at]
column_searchable_list = [ShootingRequest.title, ShootingRequest.city]
column_sortable_list = [ShootingRequest.id, ShootingRequest.created_at, ShootingRequest.status]
column_default_sort = ("id", True)
form_columns = ["title", "city", "description", "style", "status", "audit_status", "reject_reason", "role_needed"]
can_delete = False
name = "约拍单"
name_plural = "约拍管理"
icon = "fa-solid fa-camera"
column_labels = {
ShootingRequest.id: "ID",
ShootingRequest.title: "标题",
ShootingRequest.city: "城市",
"creator": "发布者",
ShootingRequest.status: "状态",
ShootingRequest.audit_status: "审核状态",
ShootingRequest.created_at: "创建时间",
ShootingRequest.description: "描述",
ShootingRequest.style: "风格",
ShootingRequest.reject_reason: "驳回原因",
ShootingRequest.role_needed: "需要角色",
}
form_overrides = _select_overrides("status", "audit_status", "role_needed")
form_args = _select_args(
status=SHOOTING_STATUS_CHOICES,
audit_status=AUDIT_STATUS_CHOICES,
role_needed=ROLE_NEEDED_CHOICES,
)
form_ajax_refs = {
"creator": {"fields": ("nickname", "phone", "email"), "order_by": "nickname"},
}
column_formatters = {
ShootingRequest.status: _status_badge,
ShootingRequest.audit_status: _status_badge,
}
@action(name="approve_shooting", label="批量通过", confirmation_message="确认将选中约拍设为「已通过」?")
async def action_approve(self, request: Request):
_bulk_update_status(ShootingRequest, request.query_params.get("pks", ""), "audit_status", "approved")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
@action(name="reject_shooting", label="批量驳回", confirmation_message="确认将选中约拍设为「已驳回」?")
async def action_reject(self, request: Request):
_bulk_update_status(ShootingRequest, request.query_params.get("pks", ""), "audit_status", "rejected")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
class ShootingApplicationAdmin(ModelView, model=ShootingApplication):
column_list = [ShootingApplication.id, "request", "applicant", ShootingApplication.status, ShootingApplication.created_at]
column_sortable_list = [ShootingApplication.id, ShootingApplication.created_at]
column_default_sort = ("id", True)
can_create = False
can_edit = False
can_delete = True
name = "约拍报名"
name_plural = "约拍报名"
icon = "fa-solid fa-handshake"
column_labels = {
ShootingApplication.id: "ID",
"request": "约拍单",
"applicant": "报名人",
ShootingApplication.status: "状态",
ShootingApplication.created_at: "报名时间",
}
column_formatters = {
ShootingApplication.status: _status_badge,
}
EVENT_STATUS_CHOICES = [
("upcoming", "即将开始"),
("ongoing", "进行中"),
("ended", "已结束"),
("cancelled", "已取消"),
]
class EventAdmin(AuditedModelView, model=Event):
column_list = [Event.id, Event.title, Event.city, "creator", Event.status, Event.audit_status, Event.registration_count, Event.start_time, Event.created_at]
column_searchable_list = [Event.title, Event.city]
column_sortable_list = [Event.id, Event.start_time, Event.created_at, Event.registration_count]
column_default_sort = ("id", True)
form_columns = ["title", "city", "description", "location_name", "status", "audit_status", "reject_reason", "max_participants"]
can_delete = False
name = "活动"
name_plural = "活动管理"
icon = "fa-solid fa-calendar-days"
column_labels = {
Event.id: "ID",
Event.title: "标题",
Event.city: "城市",
"creator": "发起人",
Event.status: "状态",
Event.audit_status: "审核状态",
Event.registration_count: "报名数",
Event.start_time: "开始时间",
Event.created_at: "创建时间",
Event.description: "描述",
Event.location_name: "地点名称",
Event.reject_reason: "驳回原因",
Event.max_participants: "人数上限",
}
form_overrides = _select_overrides("status", "audit_status")
form_args = _select_args(
status=EVENT_STATUS_CHOICES,
audit_status=AUDIT_STATUS_CHOICES,
)
form_ajax_refs = {
"creator": {"fields": ("nickname", "phone", "email"), "order_by": "nickname"},
}
column_formatters = {
Event.status: _status_badge,
Event.audit_status: _status_badge,
}
@action(name="approve_event", label="批量通过", confirmation_message="确认将选中活动设为「已通过」?")
async def action_approve(self, request: Request):
_bulk_update_status(Event, request.query_params.get("pks", ""), "audit_status", "approved")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
@action(name="reject_event", label="批量驳回", confirmation_message="确认将选中活动设为「已驳回」?")
async def action_reject(self, request: Request):
_bulk_update_status(Event, request.query_params.get("pks", ""), "audit_status", "rejected")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
class EventRegistrationAdmin(ModelView, model=EventRegistration):
column_list = [EventRegistration.id, "event", "user", EventRegistration.status, EventRegistration.created_at]
column_sortable_list = [EventRegistration.id, EventRegistration.created_at]
column_default_sort = ("id", True)
can_create = False
can_edit = False
can_delete = True
name = "活动报名"
name_plural = "活动报名"
icon = "fa-solid fa-clipboard-check"
column_labels = {
EventRegistration.id: "ID",
"event": "活动",
"user": "用户",
EventRegistration.status: "状态",
EventRegistration.created_at: "报名时间",
}
class EventPhotoAdmin(ModelView, model=EventPhoto):
column_list = [EventPhoto.id, "event", "uploader", EventPhoto.image_url, EventPhoto.created_at]
column_sortable_list = [EventPhoto.id, EventPhoto.created_at]
column_default_sort = ("id", True)
can_create = False
can_edit = False
can_delete = True
name = "活动照片"
name_plural = "活动相册"
icon = "fa-solid fa-images"
column_labels = {
EventPhoto.id: "ID",
"event": "活动",
"uploader": "上传者",
EventPhoto.image_url: "图片",
EventPhoto.created_at: "上传时间",
}
POSITION_CHOICES = [
("home_banner", "首页Banner"),
("discover_top", "发现页顶部"),
("spot_detail", "地点详情页"),
]
LINK_TYPE_CHOICES = [
("spot", "地点"),
("event", "活动"),
("shooting", "约拍"),
("url", "外部链接"),
]
class PromotionAdmin(AuditedModelView, model=Promotion):
column_list = [Promotion.id, Promotion.title, Promotion.position, Promotion.is_active, Promotion.sort_order, Promotion.impressions, Promotion.clicks, Promotion.start_time, Promotion.end_time]
column_searchable_list = [Promotion.title]
column_sortable_list = [Promotion.id, Promotion.sort_order, Promotion.impressions, Promotion.clicks]
column_default_sort = ("sort_order", False)
name = "推广位"
name_plural = "推广位管理"
icon = "fa-solid fa-bullhorn"
column_labels = {
Promotion.id: "ID",
Promotion.title: "标题",
Promotion.image_url: "图片",
Promotion.link_type: "链接类型",
Promotion.link_id: "关联ID",
"spot": "关联地点",
"event": "关联活动",
"shooting": "关联约拍",
Promotion.link_url: "外部链接",
Promotion.position: "展示位置",
Promotion.sort_order: "排序",
Promotion.is_active: "启用",
Promotion.impressions: "曝光",
Promotion.clicks: "点击",
Promotion.start_time: "开始时间",
Promotion.end_time: "结束时间",
}
form_columns = [
Promotion.title,
Promotion.image_url,
Promotion.link_type,
"spot",
"event",
"shooting",
Promotion.link_url,
Promotion.position,
Promotion.sort_order,
Promotion.is_active,
Promotion.start_time,
Promotion.end_time,
]
form_overrides = _select_overrides("position", "link_type")
form_args = _select_args(
position=POSITION_CHOICES,
link_type=LINK_TYPE_CHOICES,
)
form_ajax_refs = {
"spot": {"fields": ("title", "city"), "order_by": "title"},
"event": {"fields": ("title", "city"), "order_by": "title"},
"shooting": {"fields": ("title", "city"), "order_by": "title"},
}
column_formatters = {
Promotion.is_active: _bool_badge,
}
async def on_model_change(self, data, model, is_created, request):
model.link_id = None
if model.link_type == "spot" and model.spot is not None:
model.link_id = model.spot.id
elif model.link_type == "event" and model.event is not None:
model.link_id = model.event.id
elif model.link_type == "shooting" and model.shooting is not None:
model.link_id = model.shooting.id
class MembershipPlanAdmin(AuditedModelView, model=MembershipPlan):
column_list = [MembershipPlan.id, MembershipPlan.name, MembershipPlan.duration_days, MembershipPlan.price, MembershipPlan.is_active, MembershipPlan.sort_order]
column_searchable_list = [MembershipPlan.name]
column_sortable_list = [MembershipPlan.id, MembershipPlan.sort_order, MembershipPlan.price]
column_default_sort = ("sort_order", False)
name = "会员方案"
name_plural = "会员方案"
icon = "fa-solid fa-crown"
column_labels = {
MembershipPlan.id: "ID",
MembershipPlan.name: "名称",
MembershipPlan.description: "描述",
MembershipPlan.duration_days: "天数",
MembershipPlan.price: "价格",
MembershipPlan.benefits: "权益说明",
MembershipPlan.extra_uploads: "额外上传额度",
MembershipPlan.extra_top_count: "额外置顶次数",
MembershipPlan.is_active: "启用",
MembershipPlan.sort_order: "排序",
}
column_formatters = {
MembershipPlan.is_active: _bool_badge,
}
class UserMembershipAdmin(ModelView, model=UserMembership):
column_list = [UserMembership.id, "user", "plan", UserMembership.start_date, UserMembership.end_date, UserMembership.is_active]
column_sortable_list = [UserMembership.id, UserMembership.start_date, UserMembership.end_date]
column_default_sort = ("id", True)
can_create = False
can_delete = False
name = "会员记录"
name_plural = "会员记录"
icon = "fa-solid fa-id-card"
column_labels = {
UserMembership.id: "ID",
"user": "用户",
"plan": "方案",
UserMembership.start_date: "开始",
UserMembership.end_date: "到期",
UserMembership.is_active: "有效",
}
column_formatters = {
UserMembership.is_active: _bool_badge,
}
class AppNavConfigAdmin(AuditedModelView, model=AppNavConfig):
column_list = [
AppNavConfig.id,
AppNavConfig.key,
AppNavConfig.label,
AppNavConfig.page_path,
AppNavConfig.icon,
AppNavConfig.active_icon,
AppNavConfig.color,
AppNavConfig.active_color,
AppNavConfig.is_active,
AppNavConfig.sort_order,
AppNavConfig.updated_at,
]
column_searchable_list = [AppNavConfig.key, AppNavConfig.label, AppNavConfig.page_path]
column_sortable_list = [AppNavConfig.id, AppNavConfig.sort_order, AppNavConfig.updated_at]
column_default_sort = ("sort_order", False)
name = "底栏配置"
name_plural = "底栏配置"
icon = "fa-solid fa-table-columns"
column_labels = {
AppNavConfig.id: "ID",
AppNavConfig.key: "",
AppNavConfig.label: "文案",
AppNavConfig.page_path: "页面路径",
AppNavConfig.icon: "图标",
AppNavConfig.active_icon: "激活图标",
AppNavConfig.color: "默认颜色",
AppNavConfig.active_color: "激活颜色",
AppNavConfig.is_active: "启用",
AppNavConfig.sort_order: "排序",
AppNavConfig.updated_at: "更新时间",
}
column_formatters = {
AppNavConfig.is_active: _bool_badge,
}