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/") 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/") 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//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/") 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//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/") 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/") 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/") 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)