feat(database): 迁移用户关联字段并优化打卡系统
- 修改数据库配置将MYSQL_DB从mys_info_v3改为mys_info_test - 新增auth_users表wechat_openid字段并建立唯一索引 - 为plan_checkins表添加计划性能索引(idx_plan_period_student) - 将checkin_records_old表关联方式从student_id改为user_id - 将plan_checkins表关联方式从student_id改为user_id并更新附件路径 - 移除routes/auth.py中的uuid导入使用安全token生成函数 - 重构routes/checkin.py打卡逻辑去除course_type字段并优化返回结构 - 添加打卡时间限制验证功能防止过期打卡操作 - 实现小组统计功能支持老师查看各组打卡情况 - 更新主页index.html界面适配新的打卡和计划功能 - 调整计划提交时间规则为周五至周四周期 - 添加月度和年度计划提交状态验证机制
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -53,7 +52,7 @@ def get_current_user(request: Request):
|
||||
|
||||
def _record_session(user_id):
|
||||
"""创建会话 token 并写入 auth_sessions。"""
|
||||
token = uuid.uuid4().hex + uuid.uuid4().hex[:16]
|
||||
token = security.create_session_token()
|
||||
expires = datetime.now() + timedelta(seconds=config.JWT_TTL_SECONDS)
|
||||
db.execute(
|
||||
"INSERT INTO auth_sessions (user_id, token, expires_at) VALUES (%s, %s, %s)",
|
||||
@@ -122,4 +121,3 @@ def logout(request: Request, user=Depends(get_current_user)):
|
||||
# 同时清除 webview cookie 登录态
|
||||
resp.delete_cookie(key="auth_token", path="/")
|
||||
return resp
|
||||
|
||||
|
||||
@@ -15,15 +15,51 @@ router = APIRouter(prefix="/api/v1/checkin", tags=["checkin"])
|
||||
|
||||
CHECKIN_STATUSES = {"正常", "迟到", "请假", "不参加讨论", "早退"}
|
||||
DEFAULT_STATUS = "正常"
|
||||
DEFAULT_COURSE_TYPE = "会员课程"
|
||||
MAX_NOTE_LEN = 500
|
||||
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
SELECT_COLS = "id,user_id,class_date,checked_at,status,course_type,note"
|
||||
SELECT_COLS = "id,user_id,class_date,checked_at,status,note"
|
||||
|
||||
# 周期列表起始年份
|
||||
PERIOD_START_YEAR = 2022
|
||||
|
||||
|
||||
def get_class_date():
|
||||
def get_class_date(ref_date=None):
|
||||
"""获取指定日期所在周的周六日期(上课日)。"""
|
||||
today = ref_date or date.today()
|
||||
return today + timedelta(days=(5 - today.weekday()))
|
||||
|
||||
|
||||
def get_saturday_of_week(week_monday: date) -> date:
|
||||
"""给定某周周一,返回该周周六。"""
|
||||
return week_monday + timedelta(days=5)
|
||||
|
||||
|
||||
def generate_week_list(start_year: int = PERIOD_START_YEAR) -> list:
|
||||
"""
|
||||
生成从 start_year 到今天的所有上课周(每周六)列表,倒序排列。
|
||||
返回格式: ["2026-09-19", "2026-09-12", ...]
|
||||
"""
|
||||
today = date.today()
|
||||
return today + timedelta(days=(6 - today.weekday()))
|
||||
# 找到 start_year 第一个周六
|
||||
# 从1月1日往前找最近的周六,再往后逐周生成
|
||||
d = date(start_year, 1, 1)
|
||||
# 调整到第一个周六
|
||||
days_until_saturday = (5 - d.weekday()) % 7
|
||||
if days_until_saturday == 0 and d.weekday() != 5:
|
||||
days_until_saturday = 0
|
||||
first_saturday = d + timedelta(days=days_until_saturday)
|
||||
# 确保第一个周六在 start_year 内
|
||||
if first_saturday.year < start_year:
|
||||
first_saturday += timedelta(days=7)
|
||||
|
||||
weeks = []
|
||||
current = first_saturday
|
||||
while current <= today + timedelta(days=7): # 多生成一周
|
||||
weeks.append(current.strftime("%Y-%m-%d"))
|
||||
current += timedelta(days=7)
|
||||
|
||||
weeks.reverse() # 倒序,最新的在前
|
||||
return weeks
|
||||
|
||||
|
||||
def _is_valid_date(value):
|
||||
@@ -42,29 +78,94 @@ def fmt_dt(value):
|
||||
return str(value) if value else ""
|
||||
|
||||
|
||||
def fmt_rec(rec, name=""):
|
||||
def fmt_rec(rec):
|
||||
return {
|
||||
"id": rec["id"],
|
||||
"user_id": rec["user_id"],
|
||||
"name": name,
|
||||
"class_date": str(rec["class_date"]),
|
||||
"checked_at": fmt_dt(rec.get("checked_at")),
|
||||
"status": rec.get("status", DEFAULT_STATUS),
|
||||
"course_type": rec.get("course_type", DEFAULT_COURSE_TYPE),
|
||||
"note": rec.get("note", ""),
|
||||
}
|
||||
|
||||
|
||||
def _is_teacher(user):
|
||||
"""判断当前用户是否为老师角色。"""
|
||||
return (user.get("role") or "").strip().lower() == "teacher"
|
||||
|
||||
|
||||
def _groups_stats(class_date, group_filter=None):
|
||||
"""
|
||||
统计各小组在指定上课日的打卡情况。
|
||||
返回 [{group_name, checked_count, total}],按 group_name 排序。
|
||||
group_filter: 只统计指定小组
|
||||
"""
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if group_filter:
|
||||
where_clauses.append("m.`group` = %s")
|
||||
params.append(group_filter)
|
||||
else:
|
||||
where_clauses.append("m.`group` IS NOT NULL AND m.`group` != ''")
|
||||
|
||||
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
||||
|
||||
rows = db.fetch_all(
|
||||
"SELECT m.`group` AS group_name, "
|
||||
" COUNT(*) AS total, "
|
||||
" SUM(CASE WHEN r.id IS NOT NULL THEN 1 ELSE 0 END) AS checked_count "
|
||||
"FROM auth_users m "
|
||||
"LEFT JOIN checkin_records_old r "
|
||||
" ON r.user_id = m.id AND r.class_date = %s "
|
||||
+ where_sql + " "
|
||||
"GROUP BY m.`group` "
|
||||
"ORDER BY m.`group`",
|
||||
(class_date, *params),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"group_name": r["group_name"] or "",
|
||||
"total": r["total"] or 0,
|
||||
"checked_count": r["checked_count"] or 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class CheckinRequest(BaseModel):
|
||||
status: str = DEFAULT_STATUS
|
||||
class_date: str = ""
|
||||
note: str = ""
|
||||
course_type: str = DEFAULT_COURSE_TYPE
|
||||
|
||||
|
||||
@router.get("/weeks")
|
||||
def weeks(user=Depends(get_current_user)):
|
||||
"""
|
||||
返回所有上课周列表(每周六日期),从 2022 年到当前,倒序排列。
|
||||
用于前端做历史周切换选择器。
|
||||
"""
|
||||
week_list = generate_week_list()
|
||||
current = str(get_class_date())
|
||||
return {
|
||||
"weeks": week_list,
|
||||
"current_week": current,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/week")
|
||||
def week(user=Depends(get_current_user)):
|
||||
class_date = str(get_class_date())
|
||||
def week(class_date: str = "", user=Depends(get_current_user)):
|
||||
"""
|
||||
[已弃用] 获取指定上课日的个人打卡状态。
|
||||
请使用 /groups 接口,其返回值已包含 checked + record。
|
||||
保留此接口仅为兼容旧客户端。
|
||||
"""
|
||||
if class_date:
|
||||
class_date = class_date.strip()
|
||||
if not _is_valid_date(class_date):
|
||||
raise HTTPException(status_code=400, detail="无效的上课日期")
|
||||
else:
|
||||
class_date = str(get_class_date())
|
||||
rec = db.fetch_one(
|
||||
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
|
||||
(user["id"], class_date),
|
||||
@@ -72,6 +173,83 @@ def week(user=Depends(get_current_user)):
|
||||
return {"class_date": class_date, "checked": rec is not None, "record": fmt_rec(rec) if rec else None}
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
def groups_view(class_date: str = "", user=Depends(get_current_user)):
|
||||
"""
|
||||
老师:返回所有小组统计 + 个人状态
|
||||
学员:返回自己所在小组统计 + 个人状态
|
||||
"""
|
||||
if class_date:
|
||||
class_date = class_date.strip()
|
||||
if not _is_valid_date(class_date):
|
||||
raise HTTPException(status_code=400, detail="无效的上课日期")
|
||||
else:
|
||||
class_date = str(get_class_date())
|
||||
|
||||
# 个人打卡状态
|
||||
rec = db.fetch_one(
|
||||
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
|
||||
(user["id"], class_date),
|
||||
)
|
||||
my_checked = rec is not None
|
||||
my_record = fmt_rec(rec) if rec else None
|
||||
|
||||
is_teacher = _is_teacher(user)
|
||||
my_group = (user.get("group") or "").strip()
|
||||
|
||||
if is_teacher:
|
||||
groups_list = _groups_stats(class_date)
|
||||
# 自己所在小组排最前
|
||||
if my_group:
|
||||
mine = [g for g in groups_list if g["group_name"] == my_group]
|
||||
others = [g for g in groups_list if g["group_name"] != my_group]
|
||||
groups_list = mine + others
|
||||
else:
|
||||
# 学员只看自己小组
|
||||
if not my_group:
|
||||
groups_list = []
|
||||
else:
|
||||
groups_list = _groups_stats(class_date, group_filter=my_group)
|
||||
|
||||
# 未分组人数(仅老师可见)
|
||||
unassigned_count = 0
|
||||
if is_teacher:
|
||||
unassigned_row = db.fetch_one(
|
||||
"SELECT COUNT(*) AS cnt FROM auth_users WHERE `group` IS NULL OR `group` = ''"
|
||||
)
|
||||
unassigned_count = unassigned_row["cnt"] if unassigned_row else 0
|
||||
|
||||
return {
|
||||
"class_date": class_date,
|
||||
"checked": my_checked,
|
||||
"record": my_record,
|
||||
"is_teacher": is_teacher,
|
||||
"my_group": my_group,
|
||||
"groups": groups_list,
|
||||
"unassigned_count": unassigned_count,
|
||||
}
|
||||
|
||||
|
||||
def _can_checkin_date(class_date_str):
|
||||
"""判断指定上课日是否还可以打卡。
|
||||
|
||||
规则:上课日所在自然周(周一~周日)与当前周相同,或为未来周,则可以打卡。
|
||||
上课日是周日,同一自然周内(周一到周日)都可以打卡、可以修改。
|
||||
"""
|
||||
try:
|
||||
class_date = datetime.strptime(class_date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return False, "无效的日期"
|
||||
today = date.today()
|
||||
# 找到上课日所在周的周一
|
||||
class_monday = class_date - timedelta(days=class_date.isoweekday() - 1)
|
||||
# 找到今天所在周的周一
|
||||
today_monday = today - timedelta(days=today.isoweekday() - 1)
|
||||
if class_monday < today_monday:
|
||||
return False, "已过打卡时间"
|
||||
return True, ""
|
||||
|
||||
|
||||
@router.post("")
|
||||
def checkin(req: CheckinRequest, user=Depends(get_current_user)):
|
||||
status = req.status.strip()
|
||||
@@ -82,6 +260,10 @@ def checkin(req: CheckinRequest, user=Depends(get_current_user)):
|
||||
if not _is_valid_date(class_date):
|
||||
raise HTTPException(status_code=400, detail="无效的上课日期")
|
||||
|
||||
can_submit, msg = _can_checkin_date(class_date)
|
||||
if not can_submit:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
|
||||
note = req.note.strip() if status != DEFAULT_STATUS else ""
|
||||
if len(note) > MAX_NOTE_LEN:
|
||||
raise HTTPException(status_code=400, detail="备注过长")
|
||||
@@ -94,21 +276,26 @@ def checkin(req: CheckinRequest, user=Depends(get_current_user)):
|
||||
)
|
||||
if existing:
|
||||
db.execute(
|
||||
"UPDATE checkin_records_old SET checked_at=%s, status=%s, course_type=%s, note=%s WHERE id=%s",
|
||||
(now, status, DEFAULT_COURSE_TYPE, note, existing["id"]),
|
||||
"UPDATE checkin_records_old SET checked_at=%s, status=%s, note=%s WHERE id=%s",
|
||||
(now, status, note, existing["id"]),
|
||||
)
|
||||
record_id = existing["id"]
|
||||
else:
|
||||
# course_type 固定为会员课程(规范),忽略客户端传入值
|
||||
db.execute(
|
||||
"INSERT INTO checkin_records_old (user_id, class_date, checked_at, status, course_type, note) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s)",
|
||||
(user["id"], class_date, now, status, DEFAULT_COURSE_TYPE, note),
|
||||
record_id = db.execute(
|
||||
"INSERT INTO checkin_records_old (user_id, class_date, checked_at, status, note) "
|
||||
"VALUES (%s, %s, %s, %s, %s)",
|
||||
(user["id"], class_date, now, status, note),
|
||||
)
|
||||
rec = db.fetch_one(
|
||||
"SELECT " + SELECT_COLS + " FROM checkin_records_old WHERE user_id=%s AND class_date=%s",
|
||||
(user["id"], class_date),
|
||||
)
|
||||
return {"success": True, "record": fmt_rec(rec, user.get("name", "")) if rec else None}
|
||||
# 直接构造返回,省一次 SELECT
|
||||
record = {
|
||||
"id": record_id,
|
||||
"user_id": user["id"],
|
||||
"class_date": class_date,
|
||||
"status": status,
|
||||
"checked_at": fmt_dt(now),
|
||||
"note": note,
|
||||
}
|
||||
return {"success": True, "record": record}
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
@@ -125,24 +312,42 @@ def list_records(page: int = 1, size: int = 10, user=Depends(get_current_user)):
|
||||
"WHERE user_id=%s ORDER BY class_date DESC LIMIT %s OFFSET %s",
|
||||
(user["id"], size, offset),
|
||||
)
|
||||
return {"list": [fmt_rec(r, user.get("name", "")) for r in rows], "total": total, "page": page, "size": size}
|
||||
return {"list": [fmt_rec(r) for r in rows], "total": total, "page": page, "size": size}
|
||||
|
||||
|
||||
@router.get("/group")
|
||||
def group_view(class_date: str = "", user=Depends(get_current_user)):
|
||||
def group_view(class_date: str = "", group_name: str = "", user=Depends(get_current_user)):
|
||||
"""
|
||||
查看某小组的打卡成员列表。
|
||||
- 老师:可以传 group_name 查看任意小组;不传则看自己所在小组
|
||||
- 学员:只能看自己所在小组,传其他小组名返回 403
|
||||
"""
|
||||
class_date = class_date.strip() or str(get_class_date())
|
||||
if not _is_valid_date(class_date):
|
||||
raise HTTPException(status_code=400, detail="无效的上课日期")
|
||||
|
||||
user_row = db.fetch_one("SELECT `group` FROM auth_users WHERE id=%s", (user["id"],))
|
||||
if not user_row or not user_row.get("group"):
|
||||
my_group = (user.get("group") or "").strip()
|
||||
is_teacher = _is_teacher(user)
|
||||
|
||||
if group_name:
|
||||
group_name = group_name.strip()
|
||||
# 权限校验:学员只能看自己小组
|
||||
if not is_teacher and group_name != my_group:
|
||||
raise HTTPException(status_code=403, detail="无权限查看其他小组")
|
||||
else:
|
||||
group_name = my_group
|
||||
|
||||
if not group_name:
|
||||
return {"class_date": class_date, "group_name": "", "members": []}
|
||||
|
||||
group_name = user_row["group"]
|
||||
# 校验小组是否存在
|
||||
g_row = db.fetch_one("SELECT `group` FROM auth_users WHERE `group`=%s LIMIT 1", (group_name,))
|
||||
if not g_row:
|
||||
return {"class_date": class_date, "group_name": group_name, "members": []}
|
||||
# 单查询:成员 + 同组成员该上课日的打卡记录,LEFT JOIN 一次取回
|
||||
rows = db.fetch_all(
|
||||
"SELECT m.id AS user_id, m.student_id, m.name, "
|
||||
" r.status, r.checked_at, r.note "
|
||||
" r.id AS record_id, r.status, r.checked_at, r.note "
|
||||
"FROM auth_users m "
|
||||
"LEFT JOIN checkin_records_old r "
|
||||
" ON r.user_id = m.id AND r.class_date = %s "
|
||||
@@ -156,6 +361,8 @@ def group_view(class_date: str = "", user=Depends(get_current_user)):
|
||||
rec = None
|
||||
if row.get("status") is not None:
|
||||
rec = {
|
||||
"id": row["record_id"],
|
||||
"user_id": row["user_id"],
|
||||
"status": row["status"],
|
||||
"checked_at": fmt_dt(row.get("checked_at")),
|
||||
"note": row.get("note", ""),
|
||||
@@ -164,7 +371,6 @@ def group_view(class_date: str = "", user=Depends(get_current_user)):
|
||||
"user_id": row["user_id"],
|
||||
"student_id": row["student_id"],
|
||||
"name": row["name"],
|
||||
"role": "",
|
||||
"checked": rec is not None,
|
||||
"record": rec,
|
||||
})
|
||||
|
||||
288
routes/plan.py
288
routes/plan.py
@@ -56,7 +56,9 @@ def parse_iso_week(period_key):
|
||||
|
||||
def default_weekly_period(today=None):
|
||||
today = today or date.today()
|
||||
if today.weekday() >= 1:
|
||||
# 周五及以后(周五、周六、周日)默认填写下周计划
|
||||
# 周一到周四默认填写本周计划
|
||||
if today.weekday() >= 4:
|
||||
return iso_week_key(today + timedelta(days=7))
|
||||
return iso_week_key(today)
|
||||
|
||||
@@ -65,8 +67,10 @@ def weekly_window(period_key):
|
||||
monday = parse_iso_week(period_key)
|
||||
if not monday:
|
||||
return None, None
|
||||
# 开启:目标周的上一周周五 00:00
|
||||
open_at = datetime.combine(monday - timedelta(days=3), time.min)
|
||||
deadline = datetime.combine(monday + timedelta(days=1), time(23, 59, 59))
|
||||
# 截止:目标周的周四 23:59:59
|
||||
deadline = datetime.combine(monday + timedelta(days=3), time(23, 59, 59))
|
||||
return open_at, deadline
|
||||
|
||||
|
||||
@@ -78,10 +82,87 @@ def weekly_submit_state(period_key):
|
||||
if now < open_at:
|
||||
return False, "周五开始填写"
|
||||
if now > deadline:
|
||||
return False, "已过截止时间"
|
||||
return False, "周四截止,已过提交时间"
|
||||
return True, ""
|
||||
|
||||
|
||||
PERIOD_START_YEAR = 2022
|
||||
|
||||
|
||||
def monthly_submit_state(period_key):
|
||||
"""月计划提交状态:当前月内可提交,历史月/未来月均不可提交。"""
|
||||
if not _validate_month_key(period_key):
|
||||
return False, "无效的月周期"
|
||||
today = date.today()
|
||||
year, month = period_key.split("-")
|
||||
period_year, period_month = int(year), int(month)
|
||||
if period_year < today.year or (period_year == today.year and period_month < today.month):
|
||||
return False, "已过提交时间"
|
||||
if period_year > today.year or (period_year == today.year and period_month > today.month):
|
||||
return False, "未到提交时间"
|
||||
return True, ""
|
||||
|
||||
|
||||
def yearly_submit_state(period_key):
|
||||
"""年计划提交状态:当前年内可提交,往年/未来年均不可提交。"""
|
||||
if not _validate_year_key(period_key):
|
||||
return False, "无效的年周期"
|
||||
today = date.today()
|
||||
period_year = int(period_key)
|
||||
if period_year < today.year:
|
||||
return False, "已过提交时间"
|
||||
if period_year > today.year:
|
||||
return False, "未到提交时间"
|
||||
return True, ""
|
||||
|
||||
|
||||
def generate_period_list(plan_type, start_year: int = PERIOD_START_YEAR):
|
||||
"""
|
||||
生成从 start_year 到当前的所有自然周期列表,倒序排列(最新在前)。
|
||||
|
||||
- weekly: ISO 周格式 YYYY-Www
|
||||
- monthly: YYYY-MM
|
||||
- yearly: YYYY
|
||||
"""
|
||||
today = date.today()
|
||||
|
||||
if plan_type == "weekly":
|
||||
weeks = []
|
||||
# 从 start_year 第一个 ISO 周开始
|
||||
jan4 = date(start_year, 1, 4)
|
||||
first_monday = jan4 - timedelta(days=jan4.isoweekday() - 1)
|
||||
# 截止到当前 ISO 周
|
||||
iso_today = today.isocalendar()
|
||||
last_jan4 = date(iso_today[0], 1, 4)
|
||||
last_monday = last_jan4 - timedelta(days=last_jan4.isoweekday() - 1)
|
||||
end_monday = last_monday + timedelta(weeks=iso_today[1] - 1)
|
||||
current = first_monday
|
||||
while current <= end_monday:
|
||||
weeks.append(iso_week_key(current))
|
||||
current += timedelta(weeks=1)
|
||||
weeks.reverse()
|
||||
return weeks
|
||||
|
||||
if plan_type == "monthly":
|
||||
months = []
|
||||
for y in range(start_year, today.year + 1):
|
||||
for m in range(1, 13):
|
||||
if y == today.year and m > today.month:
|
||||
break
|
||||
months.append("%04d-%02d" % (y, m))
|
||||
months.reverse()
|
||||
return months
|
||||
|
||||
if plan_type == "yearly":
|
||||
years = []
|
||||
for y in range(start_year, today.year + 1):
|
||||
years.append(str(y))
|
||||
years.reverse()
|
||||
return years
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def get_period_key(plan_type):
|
||||
today = date.today()
|
||||
if plan_type == "weekly":
|
||||
@@ -132,93 +213,95 @@ class PlanCheckinRequest(BaseModel):
|
||||
attachments: List[str] = []
|
||||
|
||||
|
||||
|
||||
@router.get("/periods")
|
||||
def periods(type: str, user=Depends(get_current_user)):
|
||||
"""
|
||||
返回指定计划类型下的全部自然周期列表(从 2022 年到当前,倒序)。
|
||||
用于前端做周期切换选择器。
|
||||
"""
|
||||
if type not in PLAN_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的计划类型")
|
||||
period_list = generate_period_list(type)
|
||||
current = get_period_key(type)
|
||||
return {
|
||||
"type": type,
|
||||
"periods": period_list,
|
||||
"current_period": current,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def status(type: str, period_key: str = "", user=Depends(get_current_user)):
|
||||
if type not in PLAN_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的计划类型")
|
||||
period_key = period_key.strip() or get_period_key(type)
|
||||
if type == "weekly":
|
||||
if parse_iso_week(period_key) is None:
|
||||
raise HTTPException(status_code=400, detail="无效的周周期")
|
||||
if period_key != default_weekly_period(date.today()):
|
||||
raise HTTPException(status_code=400, detail="仅可查询当前周期")
|
||||
selected, can_submit, message = _normalize_period(type, period_key)
|
||||
rec = db.fetch_one(
|
||||
"SELECT " + SELECT_COLS + " FROM plan_checkins WHERE user_id=%s AND plan_type=%s AND period_key=%s",
|
||||
(user["id"], type, period_key),
|
||||
(user["id"], type, selected),
|
||||
)
|
||||
can_submit, message = (True, "")
|
||||
if type == "weekly":
|
||||
can_submit, message = weekly_submit_state(period_key)
|
||||
return {"plan_type": type, "period_key": period_key, "checked": rec is not None,
|
||||
return {"plan_type": type, "period_key": selected, "checked": rec is not None,
|
||||
"can_submit": can_submit, "message": message,
|
||||
"record": fmt_rec(rec) if rec else None}
|
||||
|
||||
|
||||
@router.get("/weekly-groups")
|
||||
def weekly_groups(period_key: str = "", user=Depends(get_current_user)):
|
||||
selected, _can_submit, _message = _validate_current_period("weekly", period_key)
|
||||
stats = _groups_stats("weekly", selected)
|
||||
groups = [
|
||||
{
|
||||
"group_name": g["group_name"],
|
||||
"checked_count": g["checked_count"],
|
||||
"unchecked_count": g["unchecked_count"],
|
||||
"total": g["total"],
|
||||
}
|
||||
for g in stats
|
||||
]
|
||||
return {"period_key": selected, "groups": groups}
|
||||
def _is_teacher(user):
|
||||
"""判断用户是否为老师角色。"""
|
||||
return (user.get("role") or "").strip().lower() == "teacher"
|
||||
|
||||
|
||||
@router.get("/weekly-group")
|
||||
def weekly_group(group_name: str = "", period_key: str = "", user=Depends(get_current_user)):
|
||||
default_period = default_weekly_period(date.today())
|
||||
selected = period_key.strip() or default_period
|
||||
if selected != default_period:
|
||||
raise HTTPException(status_code=400, detail="仅可查看当前周期")
|
||||
group_name = group_name.strip()
|
||||
if not group_name:
|
||||
raise HTTPException(status_code=400, detail="缺少小组名")
|
||||
group_row = db.fetch_one("SELECT `group` FROM auth_users WHERE `group`=%s LIMIT 1", (group_name,))
|
||||
if not group_row:
|
||||
raise HTTPException(status_code=404, detail="小组不存在")
|
||||
def _validate_month_key(period_key: str) -> bool:
|
||||
"""校验月周期格式 YYYY-MM。"""
|
||||
if not period_key:
|
||||
return False
|
||||
parts = period_key.split("-")
|
||||
if len(parts) != 2:
|
||||
return False
|
||||
try:
|
||||
year = int(parts[0])
|
||||
month = int(parts[1])
|
||||
return 2020 <= year <= 2100 and 1 <= month <= 12
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_year_key(period_key: str) -> bool:
|
||||
"""校验年周期格式 YYYY。"""
|
||||
if not period_key or len(period_key) != 4:
|
||||
return False
|
||||
try:
|
||||
year = int(period_key)
|
||||
return 2020 <= year <= 2100
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_period(plan_type, period_key):
|
||||
"""
|
||||
规范化周期 key,做基础校验(不限制必须是当前周期)。
|
||||
|
||||
用于查看类接口:允许查看任意历史周期。
|
||||
|
||||
Returns (selected, can_submit, message); 校验失败抛出 400。
|
||||
"""
|
||||
selected = period_key.strip() or get_period_key(plan_type)
|
||||
if plan_type == "weekly":
|
||||
if parse_iso_week(selected) is None:
|
||||
raise HTTPException(status_code=400, detail="无效的周周期")
|
||||
can_submit, message = weekly_submit_state(selected)
|
||||
elif plan_type == "monthly":
|
||||
if not _validate_month_key(selected):
|
||||
raise HTTPException(status_code=400, detail="无效的月周期")
|
||||
can_submit, message = monthly_submit_state(selected)
|
||||
elif plan_type == "yearly":
|
||||
if not _validate_year_key(selected):
|
||||
raise HTTPException(status_code=400, detail="无效的年周期")
|
||||
can_submit, message = yearly_submit_state(selected)
|
||||
else:
|
||||
can_submit, message = (True, "")
|
||||
return selected, can_submit, message
|
||||
|
||||
can_submit, message = weekly_submit_state(default_period)
|
||||
members = db.fetch_all(
|
||||
"SELECT id AS user_id, name FROM auth_users WHERE `group`=%s ORDER BY id",
|
||||
(group_name,),
|
||||
)
|
||||
uids = [m["user_id"] for m in members]
|
||||
rec_map = {}
|
||||
if uids:
|
||||
placeholders = ",".join(["%s"] * len(uids))
|
||||
records = db.fetch_all(
|
||||
"SELECT user_id, status, checked_at FROM plan_checkins "
|
||||
"WHERE plan_type='weekly' AND period_key=%s AND user_id IN (" + placeholders + ")",
|
||||
tuple([selected] + uids),
|
||||
)
|
||||
rec_map = {r["user_id"]: r for r in records}
|
||||
|
||||
result = []
|
||||
for m in members:
|
||||
rec = rec_map.get(m["user_id"])
|
||||
result.append({
|
||||
"user_id": m["user_id"],
|
||||
"name": m["name"],
|
||||
"filled": rec is not None,
|
||||
"record": {
|
||||
"status": rec["status"],
|
||||
"checked_at": fmt_dt(rec.get("checked_at")),
|
||||
} if rec else None,
|
||||
})
|
||||
result.sort(key=lambda item: (0 if not item["filled"] else 1, item["user_id"]))
|
||||
return {
|
||||
"period_key": selected,
|
||||
"group_name": group_name,
|
||||
"can_submit": can_submit,
|
||||
"message": message,
|
||||
"members": result,
|
||||
}
|
||||
|
||||
|
||||
def _validate_current_period(plan_type, period_key):
|
||||
@@ -240,11 +323,23 @@ def _validate_current_period(plan_type, period_key):
|
||||
return selected, can_submit, message
|
||||
|
||||
|
||||
def _groups_stats(plan_type, selected):
|
||||
def _groups_stats(plan_type, selected, group_filter=None):
|
||||
"""单条 JOIN 聚合 SQL:提交计划即已完成,未提交即未完成。
|
||||
|
||||
按小组统计总人数 / 已完成(有记录)/ 未完成(无记录),避免 N+1 查询。
|
||||
group_filter: 只统计指定小组
|
||||
"""
|
||||
where_clauses = []
|
||||
params = [plan_type, selected]
|
||||
|
||||
if group_filter:
|
||||
where_clauses.append("u.`group` = %s")
|
||||
params.append(group_filter)
|
||||
else:
|
||||
where_clauses.append("u.`group` IS NOT NULL AND u.`group` <> ''")
|
||||
|
||||
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
||||
|
||||
rows = db.fetch_all(
|
||||
"SELECT u.`group` AS group_name, "
|
||||
"COUNT(u.id) AS total, "
|
||||
@@ -252,9 +347,9 @@ def _groups_stats(plan_type, selected):
|
||||
"FROM auth_users u "
|
||||
"LEFT JOIN plan_checkins pc ON pc.user_id = u.id "
|
||||
"AND pc.plan_type = %s AND pc.period_key = %s "
|
||||
"WHERE u.`group` IS NOT NULL AND u.`group` <> '' "
|
||||
+ where_sql + " "
|
||||
"GROUP BY u.`group` ORDER BY u.`group`",
|
||||
(plan_type, selected),
|
||||
tuple(params),
|
||||
)
|
||||
groups_list = []
|
||||
for item in rows or []:
|
||||
@@ -273,7 +368,7 @@ def _groups_stats(plan_type, selected):
|
||||
def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
|
||||
if type not in PLAN_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的计划类型")
|
||||
selected, can_submit, message = _validate_current_period(type, period_key)
|
||||
selected, can_submit, message = _normalize_period(type, period_key)
|
||||
|
||||
my_rec = db.fetch_one(
|
||||
"SELECT " + SELECT_COLS + " FROM plan_checkins "
|
||||
@@ -283,17 +378,30 @@ def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
|
||||
my_checked = my_rec is not None
|
||||
my_status = (my_rec or {}).get("status", "")
|
||||
|
||||
groups_list = _groups_stats(type, selected)
|
||||
is_teacher = _is_teacher(user)
|
||||
my_group = (user.get("group") or "").strip()
|
||||
if my_group:
|
||||
mine = [g for g in groups_list if g["group_name"] == my_group]
|
||||
others = [g for g in groups_list if g["group_name"] != my_group]
|
||||
groups_list = mine + others
|
||||
|
||||
unassigned_row = db.fetch_one(
|
||||
"SELECT COUNT(*) AS cnt FROM auth_users WHERE `group` IS NULL OR `group` = ''"
|
||||
)
|
||||
unassigned_count = unassigned_row["cnt"] if unassigned_row else 0
|
||||
if is_teacher:
|
||||
# 老师:查看所有小组
|
||||
groups_list = _groups_stats(type, selected)
|
||||
if my_group:
|
||||
mine = [g for g in groups_list if g["group_name"] == my_group]
|
||||
others = [g for g in groups_list if g["group_name"] != my_group]
|
||||
groups_list = mine + others
|
||||
else:
|
||||
# 学员:只看自己所在小组
|
||||
if not my_group:
|
||||
groups_list = []
|
||||
else:
|
||||
groups_list = _groups_stats(type, selected, group_filter=my_group)
|
||||
|
||||
# 未分组人数仅老师可见
|
||||
unassigned_count = 0
|
||||
if is_teacher:
|
||||
unassigned_row = db.fetch_one(
|
||||
"SELECT COUNT(*) AS cnt FROM auth_users WHERE `group` IS NULL OR `group` = ''"
|
||||
)
|
||||
unassigned_count = unassigned_row["cnt"] if unassigned_row else 0
|
||||
|
||||
return {
|
||||
"type": type,
|
||||
@@ -303,6 +411,8 @@ def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
|
||||
"my_checked": my_checked,
|
||||
"my_status": my_status,
|
||||
"my_record": fmt_rec(my_rec) if my_rec else None,
|
||||
"is_teacher": is_teacher,
|
||||
"my_group": my_group,
|
||||
"unassigned_count": unassigned_count,
|
||||
"groups": groups_list,
|
||||
}
|
||||
@@ -312,7 +422,7 @@ def groups(type: str, period_key: str = "", user=Depends(get_current_user)):
|
||||
def group(type: str, group_name: str, period_key: str = "", user=Depends(get_current_user)):
|
||||
if type not in PLAN_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的计划类型")
|
||||
selected, can_submit, message = _validate_current_period(type, period_key)
|
||||
selected, can_submit, message = _normalize_period(type, period_key)
|
||||
group_name = group_name.strip()
|
||||
if not group_name:
|
||||
raise HTTPException(status_code=400, detail="缺少小组名")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import config, db
|
||||
from ..user import user_payload
|
||||
@@ -19,7 +18,6 @@ def _profile(user):
|
||||
"name": payload["name"],
|
||||
"student_id": payload["student_id"],
|
||||
"display_name": payload["display_name"],
|
||||
"wechat_nickname": user.get("wechat_nickname", ""),
|
||||
"phone": user.get("phone_display", "") or "",
|
||||
"group": payload["group"],
|
||||
"role": payload["role"],
|
||||
@@ -29,26 +27,11 @@ def _profile(user):
|
||||
}
|
||||
|
||||
|
||||
class ProfileUpdateRequest(BaseModel):
|
||||
wechat_nickname: str = ""
|
||||
wechat_avatar: str = ""
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_profile(user=Depends(get_current_user)):
|
||||
return {"success": True, **_profile(user)}
|
||||
|
||||
|
||||
@router.put("")
|
||||
def update_profile(req: ProfileUpdateRequest, user=Depends(get_current_user)):
|
||||
db.execute(
|
||||
"UPDATE auth_users SET wechat_nickname=%s, wechat_avatar=%s WHERE id=%s",
|
||||
(req.wechat_nickname, req.wechat_avatar, user["id"]),
|
||||
)
|
||||
fresh = db.fetch_one("SELECT * FROM auth_users WHERE id=%s", (user["id"],))
|
||||
return {"success": True, **_profile(fresh or user)}
|
||||
|
||||
|
||||
@router.post("/avatar")
|
||||
def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)):
|
||||
data = file.file.read()
|
||||
@@ -80,4 +63,3 @@ def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)):
|
||||
|
||||
db.execute("UPDATE auth_users SET wechat_avatar=%s WHERE id=%s", (url_path, user["id"]))
|
||||
return {"success": True, "wechat_avatar": url_path}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user