1036 lines
39 KiB
Python
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,
|
|
}
|