| import hmac |
| import os |
| from datetime import date, datetime, timedelta |
| from pathlib import Path |
| from urllib.parse import urlparse |
| from zoneinfo import ZoneInfo |
|
|
| from flask import Flask, jsonify, redirect, render_template, request, session, url_for |
|
|
| from storage import DEFAULT_PERIOD_TIMES, ReminderStore, beijing_now, build_default_time_slots |
|
|
| BASE_DIR = Path(__file__).resolve().parent |
| STORE_PATH = BASE_DIR / "data" / "store.json" |
| TZ = ZoneInfo("Asia/Shanghai") |
| DEFAULT_PASSWORD = "123456" |
| WEEK_PATTERNS = {"all", "odd", "even"} |
| CLASS_PERIODS = [ |
| {"label": "第01节课", "start": "08:15", "end": "09:00", "major": "第一大节"}, |
| {"label": "第02节课", "start": "09:10", "end": "09:55", "major": "第一大节"}, |
| {"label": "第03节课", "start": "10:15", "end": "11:00", "major": "第二大节"}, |
| {"label": "第04节课", "start": "11:10", "end": "11:55", "major": "第二大节"}, |
| {"label": "第05节课", "start": "13:50", "end": "14:35", "major": "第三大节"}, |
| {"label": "第06节课", "start": "14:45", "end": "15:30", "major": "第三大节"}, |
| {"label": "第07节课", "start": "15:40", "end": "16:25", "major": "第三大节"}, |
| {"label": "第08节课", "start": "16:45", "end": "17:30", "major": "第四大节"}, |
| {"label": "第09节课", "start": "17:40", "end": "18:25", "major": "第四大节"}, |
| {"label": "第10节课", "start": "19:20", "end": "20:05", "major": "第五大节"}, |
| {"label": "第11节课", "start": "20:15", "end": "21:00", "major": "第五大节"}, |
| {"label": "第12节课", "start": "21:10", "end": "21:55", "major": "第五大节"}, |
| ] |
| MAJOR_BLOCKS = [ |
| {"label": "第一大节", "start": "08:15", "end": "09:55"}, |
| {"label": "第二大节", "start": "10:15", "end": "11:55"}, |
| {"label": "第三大节", "start": "13:50", "end": "16:25"}, |
| {"label": "第四大节", "start": "16:45", "end": "18:25"}, |
| {"label": "第五大节", "start": "19:20", "end": "21:55"}, |
| ] |
|
|
| app = Flask(__name__) |
| app.config["JSON_AS_ASCII"] = False |
| app.secret_key = os.getenv("SECRET_KEY", "daily-reminder-master-secret") |
| store = ReminderStore(STORE_PATH) |
|
|
|
|
| MSG_LOGIN_REQUIRED = "请先登录" |
| MSG_WRONG_PASSWORD = "密码不正确" |
| MSG_ENTER_DUE = "请输入截止日期" |
| MSG_DUE_AFTER_NOW = "截止日期需要晚于当前北京时间" |
| MSG_CATEGORY_NAME = "分类名称至少 2 个字符" |
| MSG_TASK_NAME = "任务名称至少 2 个字符" |
| MSG_CATEGORY_NOT_FOUND = "分类不存在" |
| MSG_TASK_NOT_FOUND = "任务不存在" |
| MSG_COURSE_NAME = "课程名称至少 2 个字符" |
| MSG_COURSE_NOT_FOUND = "课程不存在" |
| MSG_INVALID_DATE = "日期格式不正确" |
| MSG_INVALID_TIME = "时间格式不正确" |
| MSG_INVALID_TIME_RANGE = "请设置有效的时间区间" |
| MSG_INVALID_WEEKDAY = "星期设置不正确" |
| MSG_INVALID_WEEK_RANGE = "开始周数不能晚于结束周数" |
| MSG_INVALID_WEEK_PATTERN = "单双周设置不正确" |
| MSG_INVALID_DURATION = "默认任务时长需在 30 到 240 分钟之间" |
| MSG_SCHEDULE_CONFLICT = "该时间段已有课程或任务,请换一个时间" |
| WEEKDAYS = [ |
| "星期一", |
| "星期二", |
| "星期三", |
| "星期四", |
| "星期五", |
| "星期六", |
| "星期日", |
| ] |
| WEEKDAY_SHORT = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] |
| MAJOR_BLOCK_GROUPS = [ |
| ("第一大节", 0, 2), |
| ("第二大节", 2, 4), |
| ("第三大节", 4, 7), |
| ("第四大节", 7, 9), |
| ("第五大节", 9, 12), |
| ] |
|
|
|
|
| def get_password() -> str: |
| return os.getenv("PASSWORD", DEFAULT_PASSWORD) |
|
|
|
|
| def is_authed() -> bool: |
| return bool(session.get("authenticated")) |
|
|
|
|
| def safe_next_path(raw_next: str | None) -> str: |
| if not raw_next: |
| return url_for("index") |
| parsed = urlparse(raw_next) |
| if parsed.scheme or parsed.netloc: |
| return url_for("index") |
| if not raw_next.startswith("/"): |
| return url_for("index") |
| return raw_next |
|
|
|
|
| def require_auth(): |
| if not is_authed(): |
| return jsonify({"ok": False, "error": MSG_LOGIN_REQUIRED}), 401 |
| return None |
|
|
|
|
| def parse_due_at(raw_value: str) -> datetime: |
| if not raw_value: |
| raise ValueError(MSG_ENTER_DUE) |
|
|
| normalized = raw_value.strip() |
| if normalized.endswith("Z"): |
| normalized = normalized[:-1] + "+00:00" |
|
|
| due_at = datetime.fromisoformat(normalized) |
| if due_at.tzinfo is None: |
| due_at = due_at.replace(tzinfo=TZ) |
|
|
| due_at = due_at.astimezone(TZ).replace(minute=0, second=0, microsecond=0) |
|
|
| if due_at <= beijing_now(): |
| raise ValueError(MSG_DUE_AFTER_NOW) |
|
|
| return due_at |
|
|
|
|
| def parse_iso_date(raw_value: str | None) -> date: |
| if not raw_value: |
| return beijing_now().date() |
| try: |
| return date.fromisoformat(str(raw_value)) |
| except ValueError as exc: |
| raise ValueError(MSG_INVALID_DATE) from exc |
|
|
|
|
| def parse_time_to_minutes(raw_value: str) -> int: |
| try: |
| hour_text, minute_text = str(raw_value).split(":", 1) |
| hour = int(hour_text) |
| minute = int(minute_text) |
| except (TypeError, ValueError) as exc: |
| raise ValueError(MSG_INVALID_TIME) from exc |
|
|
| if not (0 <= hour <= 23 and 0 <= minute <= 59): |
| raise ValueError(MSG_INVALID_TIME) |
|
|
| return hour * 60 + minute |
|
|
|
|
| def minutes_to_hhmm(total_minutes: int) -> str: |
| hours = total_minutes // 60 |
| minutes = total_minutes % 60 |
| return f"{hours:02d}:{minutes:02d}" |
|
|
|
|
| def normalize_time_string(raw_value: str) -> str: |
| return minutes_to_hhmm(parse_time_to_minutes(raw_value)) |
|
|
|
|
| def normalize_time_slots(raw_slots: list[dict]) -> list[dict[str, str]]: |
| if not isinstance(raw_slots, list) or not raw_slots: |
| raise ValueError(MSG_INVALID_TIME_RANGE) |
|
|
| normalized: list[dict[str, str]] = [] |
| previous_end = None |
|
|
| for index, slot in enumerate(raw_slots, start=1): |
| label = str(slot.get("label", "")).strip() or f"第{index:02d}节课" |
| start = normalize_time_string(str(slot.get("start", "")).strip()) |
| end = normalize_time_string(str(slot.get("end", "")).strip()) |
| start_minutes = parse_time_to_minutes(start) |
| end_minutes = parse_time_to_minutes(end) |
|
|
| if start_minutes >= end_minutes: |
| raise ValueError(MSG_INVALID_TIME_RANGE) |
| if previous_end is not None and start_minutes < previous_end: |
| raise ValueError(MSG_INVALID_TIME_RANGE) |
|
|
| normalized.append( |
| { |
| "label": label, |
| "start": start, |
| "end": end, |
| } |
| ) |
| previous_end = end_minutes |
|
|
| return normalized |
|
|
|
|
| def get_configured_time_slots(settings: dict | None = None) -> list[dict[str, str]]: |
| source = settings or store.get_schedule_settings() |
| raw_slots = source.get("time_slots") or [ |
| { |
| "label": slot["label"], |
| "start": slot["start"], |
| "end": slot["end"], |
| } |
| for slot in CLASS_PERIODS |
| ] |
| return normalize_time_slots(raw_slots) |
|
|
|
|
| def build_major_blocks_from_time_slots(time_slots: list[dict[str, str]]) -> list[dict[str, str]]: |
| major_blocks: list[dict[str, str]] = [] |
| for label, start_index, end_index in MAJOR_BLOCK_GROUPS: |
| if len(time_slots) <= start_index: |
| continue |
| block_slots = time_slots[start_index:min(end_index, len(time_slots))] |
| if not block_slots: |
| continue |
| major_blocks.append( |
| { |
| "label": label, |
| "start": block_slots[0]["start"], |
| "end": block_slots[-1]["end"], |
| } |
| ) |
| return major_blocks |
|
|
|
|
| def build_clean_default_time_slots() -> list[dict[str, str]]: |
| return [ |
| { |
| "label": f"第{index:02d}节课", |
| "start": start, |
| "end": end, |
| } |
| for index, (start, end) in sorted(DEFAULT_PERIOD_TIMES.items()) |
| ] |
|
|
|
|
| def compute_task_progress(task: dict) -> float: |
| if task.get("completed"): |
| return 100.0 |
|
|
| created_at = datetime.fromisoformat(task["created_at"]) |
| due_at = datetime.fromisoformat(task["due_at"]) |
| total_seconds = max((due_at - created_at).total_seconds(), 1) |
| elapsed_seconds = max((beijing_now() - created_at).total_seconds(), 0) |
| progress = max(0.0, min(100.0, elapsed_seconds / total_seconds * 100)) |
| return round(progress, 1) |
|
|
|
|
| def current_date_payload() -> dict[str, str]: |
| now = beijing_now() |
| return { |
| "iso": now.isoformat(), |
| "weekday": WEEKDAYS[now.weekday()], |
| } |
|
|
|
|
| def serialize_task(task: dict) -> dict: |
| return { |
| **task, |
| "progress_percent": compute_task_progress(task), |
| } |
|
|
|
|
| def parse_course_payload(payload: dict) -> dict: |
| title = str(payload.get("title", "")).strip() |
| if len(title) < 2: |
| raise ValueError(MSG_COURSE_NAME) |
|
|
| try: |
| day_of_week = int(payload.get("day_of_week")) |
| start_week = int(payload.get("start_week")) |
| end_week = int(payload.get("end_week")) |
| except (TypeError, ValueError) as exc: |
| raise ValueError(MSG_INVALID_WEEKDAY) from exc |
|
|
| if day_of_week < 1 or day_of_week > 7: |
| raise ValueError(MSG_INVALID_WEEKDAY) |
| if start_week < 1 or end_week < 1 or start_week > end_week: |
| raise ValueError(MSG_INVALID_WEEK_RANGE) |
|
|
| week_pattern = str(payload.get("week_pattern", "all")).strip() or "all" |
| if week_pattern not in WEEK_PATTERNS: |
| raise ValueError(MSG_INVALID_WEEK_PATTERN) |
|
|
| start_time = normalize_time_string(str(payload.get("start_time", "")).strip()) |
| end_time = normalize_time_string(str(payload.get("end_time", "")).strip()) |
| if parse_time_to_minutes(start_time) >= parse_time_to_minutes(end_time): |
| raise ValueError(MSG_INVALID_TIME_RANGE) |
|
|
| return { |
| "title": title, |
| "day_of_week": day_of_week, |
| "start_time": start_time, |
| "end_time": end_time, |
| "start_week": start_week, |
| "end_week": end_week, |
| "week_pattern": week_pattern, |
| "location": str(payload.get("location", "")).strip(), |
| "color": str(payload.get("color", "")).strip() or None, |
| } |
|
|
|
|
| def parse_schedule_settings(payload: dict) -> dict: |
| semester_start = parse_iso_date(payload.get("semester_start")).isoformat() |
| day_start = normalize_time_string(str(payload.get("day_start", "")).strip()) |
| day_end = normalize_time_string(str(payload.get("day_end", "")).strip()) |
| if parse_time_to_minutes(day_start) >= parse_time_to_minutes(day_end): |
| raise ValueError(MSG_INVALID_TIME_RANGE) |
|
|
| try: |
| default_duration = int(payload.get("default_task_duration_minutes")) |
| except (TypeError, ValueError) as exc: |
| raise ValueError(MSG_INVALID_DURATION) from exc |
|
|
| if default_duration < 30 or default_duration > 240: |
| raise ValueError(MSG_INVALID_DURATION) |
|
|
| time_slots = normalize_time_slots(payload.get("time_slots") or get_configured_time_slots()) |
|
|
| return { |
| "semester_start": semester_start, |
| "day_start": day_start, |
| "day_end": day_end, |
| "default_task_duration_minutes": default_duration, |
| "time_slots": time_slots, |
| } |
|
|
|
|
| def parse_task_schedule_payload(payload: dict, settings: dict) -> dict | None: |
| if payload.get("clear"): |
| return None |
|
|
| schedule_date = parse_iso_date(payload.get("date")).isoformat() |
| start_time = normalize_time_string(str(payload.get("start_time", "")).strip()) |
| end_time = normalize_time_string(str(payload.get("end_time", "")).strip()) |
|
|
| day_start = parse_time_to_minutes(settings["day_start"]) |
| day_end = parse_time_to_minutes(settings["day_end"]) |
| start_minutes = parse_time_to_minutes(start_time) |
| end_minutes = parse_time_to_minutes(end_time) |
|
|
| if not (day_start <= start_minutes < end_minutes <= day_end): |
| raise ValueError(MSG_INVALID_TIME_RANGE) |
|
|
| return { |
| "date": schedule_date, |
| "start_time": start_time, |
| "end_time": end_time, |
| } |
|
|
|
|
| def get_week_start(target_date: date) -> date: |
| return target_date - timedelta(days=target_date.isoweekday() - 1) |
|
|
|
|
| def get_academic_week(target_date: date, semester_start: date) -> int: |
| delta_days = (target_date - semester_start).days |
| return (delta_days // 7) + 1 if delta_days >= 0 else 0 |
|
|
|
|
| def course_occurs_on(course: dict, current_week: int, selected_date: date) -> bool: |
| if current_week < 1: |
| return False |
| if course["day_of_week"] != selected_date.isoweekday(): |
| return False |
| if current_week < course["start_week"] or current_week > course["end_week"]: |
| return False |
| if course["week_pattern"] == "odd" and current_week % 2 == 0: |
| return False |
| if course["week_pattern"] == "even" and current_week % 2 == 1: |
| return False |
| return True |
|
|
|
|
| def schedules_overlap( |
| left_start_minutes: int, |
| left_end_minutes: int, |
| right_start_minutes: int, |
| right_end_minutes: int, |
| ) -> bool: |
| return left_start_minutes < right_end_minutes and right_start_minutes < left_end_minutes |
|
|
|
|
| def find_schedule_conflict(task_id: str, schedule: dict | None) -> str | None: |
| if not schedule: |
| return None |
|
|
| schedule_date = date.fromisoformat(schedule["date"]) |
| start_minutes = parse_time_to_minutes(schedule["start_time"]) |
| end_minutes = parse_time_to_minutes(schedule["end_time"]) |
|
|
| settings = store.get_schedule_settings() |
| semester_start = date.fromisoformat(settings["semester_start"]) |
| current_week = get_academic_week(schedule_date, semester_start) |
|
|
| for task in store.list_tasks(): |
| if task["id"] == task_id: |
| continue |
| existing_schedule = task.get("schedule") |
| if not existing_schedule or existing_schedule.get("date") != schedule["date"]: |
| continue |
| existing_start = parse_time_to_minutes(existing_schedule["start_time"]) |
| existing_end = parse_time_to_minutes(existing_schedule["end_time"]) |
| if schedules_overlap(start_minutes, end_minutes, existing_start, existing_end): |
| return task["title"] |
|
|
| for course in store.list_courses(): |
| if not course_occurs_on(course, current_week, schedule_date): |
| continue |
| course_start = parse_time_to_minutes(course["start_time"]) |
| course_end = parse_time_to_minutes(course["end_time"]) |
| if schedules_overlap(start_minutes, end_minutes, course_start, course_end): |
| return course["title"] |
|
|
| return None |
|
|
|
|
| def build_planner_payload(selected_date: date) -> dict: |
| settings = store.get_schedule_settings() |
| time_slots = get_configured_time_slots(settings) |
| major_blocks = build_major_blocks_from_time_slots(time_slots) |
| semester_start = date.fromisoformat(settings["semester_start"]) |
| delta_days = (selected_date - semester_start).days |
| academic_week = (delta_days // 7) + 1 if delta_days >= 0 else 0 |
| task_list = [serialize_task(task) for task in store.list_tasks()] |
|
|
| scheduled_items: list[dict] = [] |
| for task in task_list: |
| schedule = task.get("schedule") |
| if schedule and schedule.get("date") == selected_date.isoformat(): |
| scheduled_items.append( |
| { |
| "id": f"planner_{task['id']}", |
| "kind": "task", |
| "task_id": task["id"], |
| "title": task["title"], |
| "category_name": task["category_name"], |
| "completed": task.get("completed", False), |
| "due_at": task["due_at"], |
| "progress_percent": task["progress_percent"], |
| "start_time": schedule["start_time"], |
| "end_time": schedule["end_time"], |
| "locked": False, |
| } |
| ) |
|
|
| for course in store.list_courses(): |
| if course_occurs_on(course, academic_week, selected_date): |
| scheduled_items.append( |
| { |
| "id": f"planner_{course['id']}", |
| "kind": "course", |
| "course_id": course["id"], |
| "title": course["title"], |
| "location": course.get("location", ""), |
| "start_time": course["start_time"], |
| "end_time": course["end_time"], |
| "locked": True, |
| "week_pattern": course["week_pattern"], |
| "color": course["color"], |
| "start_week": course["start_week"], |
| "end_week": course["end_week"], |
| } |
| ) |
|
|
| scheduled_items.sort( |
| key=lambda item: ( |
| item["start_time"], |
| item["end_time"], |
| item["kind"], |
| ) |
| ) |
|
|
| return { |
| "selected_date": selected_date.isoformat(), |
| "weekday": WEEKDAYS[selected_date.weekday()], |
| "academic_week": academic_week, |
| "academic_label": f"第 {academic_week} 周" if academic_week > 0 else "开学前", |
| "settings": settings, |
| "time_slots": time_slots, |
| "major_blocks": major_blocks, |
| "tasks": task_list, |
| "scheduled_items": scheduled_items, |
| } |
|
|
|
|
| def build_week_planner_payload(selected_date: date) -> dict: |
| settings = store.get_schedule_settings() |
| time_slots = get_configured_time_slots(settings) |
| major_blocks = build_major_blocks_from_time_slots(time_slots) |
| semester_start = date.fromisoformat(settings["semester_start"]) |
| week_start = get_week_start(selected_date) |
| week_end = week_start + timedelta(days=6) |
| academic_week = get_academic_week(selected_date, semester_start) |
| task_list = [serialize_task(task) for task in store.list_tasks()] |
|
|
| week_days = [] |
| week_day_set = set() |
| today = beijing_now().date() |
| for offset in range(7): |
| current_day = week_start + timedelta(days=offset) |
| iso_day = current_day.isoformat() |
| week_day_set.add(iso_day) |
| week_days.append( |
| { |
| "iso": iso_day, |
| "label": WEEKDAYS[current_day.weekday()], |
| "short_label": WEEKDAY_SHORT[current_day.weekday()], |
| "month_day": current_day.strftime("%m/%d"), |
| "day_of_month": current_day.day, |
| "is_today": current_day == today, |
| } |
| ) |
|
|
| scheduled_items: list[dict] = [] |
| for task in task_list: |
| schedule = task.get("schedule") |
| if schedule and schedule.get("date") in week_day_set: |
| scheduled_items.append( |
| { |
| "id": f"planner_{task['id']}", |
| "kind": "task", |
| "task_id": task["id"], |
| "title": task["title"], |
| "category_name": task["category_name"], |
| "completed": task.get("completed", False), |
| "due_at": task["due_at"], |
| "progress_percent": task["progress_percent"], |
| "date": schedule["date"], |
| "start_time": schedule["start_time"], |
| "end_time": schedule["end_time"], |
| "locked": False, |
| } |
| ) |
|
|
| courses = store.list_courses() |
| for offset in range(7): |
| current_day = week_start + timedelta(days=offset) |
| current_week = get_academic_week(current_day, semester_start) |
| for course in courses: |
| if course_occurs_on(course, current_week, current_day): |
| scheduled_items.append( |
| { |
| "id": f"planner_{course['id']}_{current_day.isoformat()}", |
| "kind": "course", |
| "course_id": course["id"], |
| "title": course["title"], |
| "location": course.get("location", ""), |
| "date": current_day.isoformat(), |
| "start_time": course["start_time"], |
| "end_time": course["end_time"], |
| "locked": True, |
| "week_pattern": course["week_pattern"], |
| "color": course["color"], |
| "start_week": course["start_week"], |
| "end_week": course["end_week"], |
| } |
| ) |
|
|
| scheduled_items.sort( |
| key=lambda item: ( |
| item.get("date", ""), |
| item["start_time"], |
| item["end_time"], |
| item["kind"], |
| ) |
| ) |
|
|
| return { |
| "selected_date": selected_date.isoformat(), |
| "weekday": WEEKDAYS[selected_date.weekday()], |
| "week_start": week_start.isoformat(), |
| "week_end": week_end.isoformat(), |
| "week_days": week_days, |
| "week_range_label": f"{week_start.strftime('%m/%d')} - {week_end.strftime('%m/%d')}", |
| "academic_week": academic_week, |
| "academic_label": f"第 {academic_week} 周" if academic_week > 0 else "开学前", |
| "settings": settings, |
| "time_slots": time_slots, |
| "major_blocks": major_blocks, |
| "tasks": task_list, |
| "scheduled_items": scheduled_items, |
| } |
|
|
|
|
| @app.context_processor |
| def inject_globals(): |
| return { |
| "authenticated": is_authed(), |
| } |
|
|
|
|
| def build_admin_tabs(active_page: str) -> list[dict[str, str | bool]]: |
| tabs = [ |
| ("schedule", "时间表设置", "admin_schedule"), |
| ("lists", "清单管理", "admin_lists"), |
| ("courses", "课程管理", "admin_courses"), |
| ] |
| return [ |
| { |
| "key": key, |
| "label": label, |
| "href": url_for(endpoint), |
| "active": key == active_page, |
| } |
| for key, label, endpoint in tabs |
| ] |
|
|
|
|
| def render_admin_page(active_page: str): |
| if not is_authed(): |
| return redirect(url_for("index", login="required", next=request.path)) |
|
|
| schedule_settings = store.get_schedule_settings() |
| return render_template( |
| "admin.html", |
| admin_page=active_page, |
| admin_tabs=build_admin_tabs(active_page), |
| categories=store.list_categories(), |
| courses=store.list_courses(), |
| schedule_settings=schedule_settings, |
| default_time_slots=build_clean_default_time_slots(), |
| ) |
|
|
|
|
| @app.get("/") |
| def index(): |
| login_required = request.args.get("login") == "required" |
| next_path = safe_next_path(request.args.get("next")) |
| planner_payload = build_week_planner_payload(beijing_now().date()) |
| return render_template( |
| "index.html", |
| categories=store.list_categories(), |
| clock_meta=current_date_payload(), |
| planner_payload=planner_payload, |
| login_required=login_required, |
| next_path=next_path, |
| ) |
|
|
|
|
| @app.get("/admin") |
| def admin(): |
| return redirect(url_for("admin_schedule")) |
|
|
|
|
| @app.get("/admin/schedule") |
| def admin_schedule(): |
| return render_admin_page("schedule") |
|
|
|
|
| @app.get("/admin/lists") |
| def admin_lists(): |
| return render_admin_page("lists") |
|
|
|
|
| @app.get("/admin/courses") |
| def admin_courses(): |
| return render_admin_page("courses") |
|
|
|
|
| @app.get("/api/planner") |
| def planner(): |
| try: |
| selected_date = parse_iso_date(request.args.get("date")) |
| except ValueError as exc: |
| return jsonify({"ok": False, "error": str(exc)}), 400 |
| return jsonify({"ok": True, "planner": build_week_planner_payload(selected_date)}) |
|
|
|
|
| @app.post("/api/login") |
| def login(): |
| payload = request.get_json(silent=True) or {} |
| password = str(payload.get("password", "")) |
| next_path = safe_next_path(payload.get("next")) |
|
|
| if hmac.compare_digest(password, get_password()): |
| session["authenticated"] = True |
| return jsonify({"ok": True, "next": next_path}) |
|
|
| return jsonify({"ok": False, "error": MSG_WRONG_PASSWORD}), 401 |
|
|
|
|
| @app.post("/api/logout") |
| def logout(): |
| session.clear() |
| return jsonify({"ok": True}) |
|
|
|
|
| @app.post("/api/categories") |
| def create_category(): |
| auth_error = require_auth() |
| if auth_error: |
| return auth_error |
|
|
| payload = request.get_json(silent=True) or {} |
| name = str(payload.get("name", "")).strip() |
| if len(name) < 2: |
| return jsonify({"ok": False, "error": MSG_CATEGORY_NAME}), 400 |
|
|
| category = store.create_category(name) |
| return jsonify({"ok": True, "category": category}) |
|
|
|
|
| @app.patch("/api/categories/<category_id>") |
| def rename_category(category_id: str): |
| auth_error = require_auth() |
| if auth_error: |
| return auth_error |
|
|
| payload = request.get_json(silent=True) or {} |
| name = str(payload.get("name", "")).strip() |
| if len(name) < 2: |
| return jsonify({"ok": False, "error": MSG_CATEGORY_NAME}), 400 |
|
|
| try: |
| category = store.rename_category(category_id, name) |
| except KeyError: |
| return jsonify({"ok": False, "error": MSG_CATEGORY_NOT_FOUND}), 404 |
|
|
| return jsonify({"ok": True, "category": category}) |
|
|
|
|
| @app.delete("/api/categories/<category_id>") |
| def delete_category(category_id: str): |
| auth_error = require_auth() |
| if auth_error: |
| return auth_error |
|
|
| try: |
| store.delete_category(category_id) |
| except KeyError: |
| return jsonify({"ok": False, "error": MSG_CATEGORY_NOT_FOUND}), 404 |
|
|
| return jsonify({"ok": True}) |
|
|
|
|
| @app.post("/api/categories/<category_id>/tasks") |
| def create_task(category_id: str): |
| auth_error = require_auth() |
| if auth_error: |
| return auth_error |
|
|
| payload = request.get_json(silent=True) or {} |
| title = str(payload.get("title", "")).strip() |
| if len(title) < 2: |
| return jsonify({"ok": False, "error": MSG_TASK_NAME}), 400 |
|
|
| try: |
| due_at = parse_due_at(str(payload.get("due_at", ""))) |
| except ValueError as exc: |
| return jsonify({"ok": False, "error": str(exc)}), 400 |
|
|
| try: |
| task = store.add_task(category_id, title, due_at.isoformat()) |
| except KeyError: |
| return jsonify({"ok": False, "error": MSG_CATEGORY_NOT_FOUND}), 404 |
|
|
| return jsonify({"ok": True, "task": serialize_task(task)}) |
|
|
|
|
| @app.patch("/api/tasks/<task_id>") |
| def update_task(task_id: str): |
| auth_error = require_auth() |
| if auth_error: |
| return auth_error |
|
|
| payload = request.get_json(silent=True) or {} |
| completed = bool(payload.get("completed")) |
|
|
| try: |
| task = store.toggle_task(task_id, completed) |
| except KeyError: |
| return jsonify({"ok": False, "error": MSG_TASK_NOT_FOUND}), 404 |
|
|
| return jsonify({"ok": True, "task": serialize_task(task)}) |
|
|
|
|
| @app.patch("/api/tasks/<task_id>/schedule") |
| def update_task_schedule(task_id: str): |
| auth_error = require_auth() |
| if auth_error: |
| return auth_error |
|
|
| payload = request.get_json(silent=True) or {} |
| settings = store.get_schedule_settings() |
| try: |
| schedule = parse_task_schedule_payload(payload, settings) |
| except ValueError as exc: |
| return jsonify({"ok": False, "error": str(exc)}), 400 |
|
|
| conflict_title = find_schedule_conflict(task_id, schedule) |
| if conflict_title: |
| return jsonify( |
| { |
| "ok": False, |
| "error": f"该时间段与“{conflict_title}”冲突,请换一个时间", |
| } |
| ), 400 |
|
|
| try: |
| task = store.schedule_task(task_id, schedule) |
| except KeyError: |
| return jsonify({"ok": False, "error": MSG_TASK_NOT_FOUND}), 404 |
|
|
| return jsonify({"ok": True, "task": serialize_task(task)}) |
|
|
|
|
| @app.delete("/api/tasks/<task_id>") |
| def delete_task(task_id: str): |
| auth_error = require_auth() |
| if auth_error: |
| return auth_error |
|
|
| try: |
| store.delete_task(task_id) |
| except KeyError: |
| return jsonify({"ok": False, "error": MSG_TASK_NOT_FOUND}), 404 |
|
|
| return jsonify({"ok": True}) |
|
|
|
|
| @app.patch("/api/settings/schedule") |
| def update_schedule_settings(): |
| auth_error = require_auth() |
| if auth_error: |
| return auth_error |
|
|
| payload = request.get_json(silent=True) or {} |
| try: |
| settings = parse_schedule_settings(payload) |
| except ValueError as exc: |
| return jsonify({"ok": False, "error": str(exc)}), 400 |
|
|
| updated = store.update_schedule_settings(settings) |
| return jsonify({"ok": True, "settings": updated}) |
|
|
|
|
| @app.post("/api/courses") |
| def create_course(): |
| auth_error = require_auth() |
| if auth_error: |
| return auth_error |
|
|
| payload = request.get_json(silent=True) or {} |
| try: |
| course = store.create_course(parse_course_payload(payload)) |
| except ValueError as exc: |
| return jsonify({"ok": False, "error": str(exc)}), 400 |
|
|
| return jsonify({"ok": True, "course": course}) |
|
|
|
|
| @app.patch("/api/courses/<course_id>") |
| def update_course(course_id: str): |
| auth_error = require_auth() |
| if auth_error: |
| return auth_error |
|
|
| payload = request.get_json(silent=True) or {} |
| try: |
| update_payload = parse_course_payload(payload) |
| except ValueError as exc: |
| return jsonify({"ok": False, "error": str(exc)}), 400 |
|
|
| try: |
| course = store.update_course(course_id, update_payload) |
| except KeyError: |
| return jsonify({"ok": False, "error": MSG_COURSE_NOT_FOUND}), 404 |
|
|
| return jsonify({"ok": True, "course": course}) |
|
|
|
|
| @app.delete("/api/courses/<course_id>") |
| def delete_course(course_id: str): |
| auth_error = require_auth() |
| if auth_error: |
| return auth_error |
|
|
| try: |
| store.delete_course(course_id) |
| except KeyError: |
| return jsonify({"ok": False, "error": MSG_COURSE_NOT_FOUND}), 404 |
|
|
| return jsonify({"ok": True}) |
|
|
|
|
| if __name__ == "__main__": |
| port = int(os.getenv("PORT", "7860")) |
| app.run(host="0.0.0.0", port=port, debug=False) |
|
|