from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from dotenv import load_dotenv load_dotenv() # Before agent import so MINIMAX_API_KEY is available from fastapi import Depends, FastAPI, Form, Request # noqa: E402 from fastapi.responses import HTMLResponse # noqa: E402 from fastapi.staticfiles import StaticFiles # noqa: E402 from fastapi.templating import Jinja2Templates # noqa: E402 from sqlalchemy.orm import Session # noqa: E402 from agent import generate_micro_steps # noqa: E402 from database import create_all, get_db # noqa: E402 from models import Deadline, MicroStep, MicroStepStatus, StudySession, Subject # noqa: E402 SESSION_INACTIVITY_MINUTES = 30 def _utcnow() -> datetime: """Naive UTC datetime — matches SQLite's naive storage.""" return datetime.now(timezone.utc).replace(tzinfo=None) @asynccontextmanager async def lifespan(app: FastAPI): create_all() yield app = FastAPI(lifespan=lifespan) app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") def _get_current_session(db: Session): """Returns the most recent open session if within inactivity window.""" cutoff = _utcnow() - timedelta(minutes=SESSION_INACTIVITY_MINUTES) return ( db.query(StudySession) .filter(StudySession.ended_at == None, StudySession.started_at >= cutoff) # noqa: E711 .order_by(StudySession.started_at.desc()) .first() ) def _get_last_ended_session(db: Session): """Returns the most recent completed session for the welcome-back banner.""" return ( db.query(StudySession) .filter(StudySession.ended_at != None) # noqa: E711 .order_by(StudySession.ended_at.desc()) .first() ) def _active_subjects(db: Session): return db.query(Subject).filter(Subject.is_active == True, Subject.is_parked == False).all() # noqa: E712 def _upcoming_deadlines(db: Session, limit: int = 5): # Include overdue deadlines from last 7 days so they show as "Ueberfaellig" cutoff = _utcnow() - timedelta(days=7) return ( db.query(Deadline) .join(Subject) .filter(Subject.is_parked == False, Deadline.due_date >= cutoff) # noqa: E712 .order_by(Deadline.due_date) .limit(limit) .all() ) def _next_micro_step(db: Session): return ( db.query(MicroStep) .filter(MicroStep.status == MicroStepStatus.pending) .join(Deadline) .join(Subject) .filter(Subject.is_parked == False) # noqa: E712 .order_by(Deadline.due_date, MicroStep.order_num) .first() ) def _expire_stale_sessions(db: Session): """Auto-close sessions older than the inactivity window.""" cutoff = _utcnow() - timedelta(minutes=SESSION_INACTIVITY_MINUTES) stale = ( db.query(StudySession) .filter(StudySession.ended_at == None, StudySession.started_at < cutoff) # noqa: E711 .all() ) for s in stale: s.ended_at = _utcnow() if stale: db.commit() @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request, db: Session = Depends(get_db)): _expire_stale_sessions(db) # Auto-start a session if none is open; attach to the first active subject open_session = _get_current_session(db) if not open_session: active_list = _active_subjects(db) if active_list: open_session = StudySession(subject_id=active_list[0].id) db.add(open_session) db.commit() active_subjects = _active_subjects(db) upcoming_deadlines = _upcoming_deadlines(db) next_step = _next_micro_step(db) last_session = _get_last_ended_session(db) # Build per-deadline progress stats for dashboard mini-bars deadline_stats = {} for dl in upcoming_deadlines: all_steps = db.query(MicroStep).filter(MicroStep.deadline_id == dl.id).all() total = len(all_steps) done = sum(1 for s in all_steps if s.status == MicroStepStatus.completed) deadline_stats[dl.id] = { "total": total, "completed": done, "pct": int(done / total * 100) if total else 0, "panic": is_panic_mode(dl), "overdue": is_overdue(dl), } return templates.TemplateResponse( "index.html", { "request": request, "active_subjects": active_subjects, "upcoming_deadlines": upcoming_deadlines, "next_step": next_step, "last_session": last_session, "deadline_stats": deadline_stats, "now": _utcnow(), }, ) @app.post("/subjects", response_class=HTMLResponse) async def create_subject( request: Request, name: str = Form(...), semester: str = Form(""), db: Session = Depends(get_db), ): active_count = len(_active_subjects(db)) if active_count >= 2: return HTMLResponse( '
Deadline nicht gefunden.
') hours_until_due = (deadline.due_date - _utcnow()).total_seconds() / 3600 steps_data = await generate_micro_steps( deadline.title, deadline.description or "", deadline.due_date, hours_until_due, ) # Delete old pending steps for this deadline and recreate db.query(MicroStep).filter( MicroStep.deadline_id == deadline_id, MicroStep.status == MicroStepStatus.pending, ).delete() db.commit() for i, s in enumerate(steps_data): step = MicroStep( deadline_id=deadline_id, title=s["title"], context=s.get("context", ""), order_num=i, duration_minutes=s.get("duration_minutes", 5), ) db.add(step) db.commit() # Return all new step cards new_steps = ( db.query(MicroStep) .filter(MicroStep.deadline_id == deadline_id, MicroStep.status == MicroStepStatus.pending) .order_by(MicroStep.order_num) .all() ) html_parts = [] for step in new_steps: rendered = templates.TemplateResponse( "partials/micro_step_card.html", {"request": request, "step": step}, ) html_parts.append(rendered.body.decode()) return HTMLResponse("".join(html_parts)) @app.delete("/subjects/{subject_id}", response_class=HTMLResponse) async def park_subject(subject_id: int, db: Session = Depends(get_db)): subject = db.query(Subject).filter(Subject.id == subject_id).first() if subject: subject.is_parked = True subject.is_active = False db.commit() return HTMLResponse("") @app.post("/steps/{step_id}/complete", response_class=HTMLResponse) async def complete_step(step_id: int, request: Request, db: Session = Depends(get_db)): step = db.query(MicroStep).filter(MicroStep.id == step_id).first() if step: step.status = MicroStepStatus.completed db.commit() open_session = _get_current_session(db) if open_session: open_session.steps_completed += 1 db.commit() # Return next pending step next_step = _next_micro_step(db) if next_step: return templates.TemplateResponse( "partials/micro_step_card.html", {"request": request, "step": next_step}, ) return HTMLResponse('') @app.post("/steps/{step_id}/skip", response_class=HTMLResponse) async def skip_step(step_id: int, request: Request, db: Session = Depends(get_db)): step = db.query(MicroStep).filter(MicroStep.id == step_id).first() if step: step.status = MicroStepStatus.skipped db.commit() next_step = _next_micro_step(db) if next_step: buddy_msg = 'Kein Stress, machen wir anders.
' card = templates.TemplateResponse( "partials/micro_step_card.html", {"request": request, "step": next_step}, ) return HTMLResponse(buddy_msg + card.body.decode()) return HTMLResponse('') @app.get("/steps/next", response_class=HTMLResponse) async def next_step_partial(request: Request, db: Session = Depends(get_db)): step = _next_micro_step(db) if step: return templates.TemplateResponse( "partials/micro_step_card.html", {"request": request, "step": step}, ) return HTMLResponse('') # ── session endpoints ────────────────────────────────────────────────────── @app.get("/session/current", response_class=HTMLResponse) async def session_current(request: Request, db: Session = Depends(get_db)): _expire_stale_sessions(db) last_session = _get_last_ended_session(db) next_step = _next_micro_step(db) return templates.TemplateResponse( "partials/session_banner.html", {"request": request, "last_session": last_session, "next_step": next_step}, ) @app.get("/session/history", response_class=HTMLResponse) async def session_history(request: Request, db: Session = Depends(get_db)): sessions = ( db.query(StudySession) .filter(StudySession.ended_at != None) # noqa: E711 .order_by(StudySession.ended_at.desc()) .limit(5) .all() ) return templates.TemplateResponse( "partials/progress_journey.html", {"request": request, "sessions": sessions}, ) @app.post("/session/start", response_class=HTMLResponse) async def session_start( request: Request, subject_id: int = Form(...), db: Session = Depends(get_db), ): _expire_stale_sessions(db) existing = _get_current_session(db) if existing and existing.subject_id == subject_id: return HTMLResponse("") # already open session = StudySession(subject_id=subject_id) db.add(session) db.commit() return HTMLResponse("") @app.post("/session/end", response_class=HTMLResponse) async def session_end(db: Session = Depends(get_db)): open_session = _get_current_session(db) if open_session: open_session.ended_at = _utcnow() db.commit() return HTMLResponse("") # ── Elastic Plan + Panic Mode ─────────────────────────────────────────────── def is_panic_mode(deadline: Deadline) -> bool: """True when fewer than 48 hours remain or deadline is overdue.""" return (deadline.due_date - _utcnow()).total_seconds() < 48 * 3600 def is_overdue(deadline: Deadline) -> bool: """True when the deadline is in the past.""" return deadline.due_date < _utcnow() def _deadline_steps(db: Session, deadline_id: int): """All non-skipped steps ordered by position.""" return ( db.query(MicroStep) .filter( MicroStep.deadline_id == deadline_id, MicroStep.status != MicroStepStatus.skipped, ) .order_by(MicroStep.order_num) .all() ) def _should_auto_compress(deadline: Deadline, pending_count: int) -> bool: """True when pending steps take longer than remaining time (with 20% buffer). Overdue deadlines with pending steps always trigger a replan. """ hours_left = (deadline.due_date - _utcnow()).total_seconds() / 3600 if hours_left <= 0: return pending_count > 0 # overdue with pending steps → always compress return pending_count * 5 > hours_left * 60 * 0.8 @app.get("/deadlines/{deadline_id}/plan", response_class=HTMLResponse) async def deadline_plan(deadline_id: int, request: Request, db: Session = Depends(get_db)): deadline = db.query(Deadline).filter(Deadline.id == deadline_id).first() if not deadline: return HTMLResponse('Deadline nicht gefunden.
') steps = _deadline_steps(db, deadline_id) pending = [s for s in steps if s.status == MicroStepStatus.pending] buddy_message = None # Auto-compress when pending steps exceed available time if pending and _should_auto_compress(deadline, len(pending)): hours_until_due = (deadline.due_date - _utcnow()).total_seconds() / 3600 try: new_steps_data = await generate_micro_steps( deadline.title, deadline.description or "", deadline.due_date, hours_until_due, ) db.query(MicroStep).filter( MicroStep.deadline_id == deadline_id, MicroStep.status == MicroStepStatus.pending, ).delete(synchronize_session=False) db.commit() completed_count = db.query(MicroStep).filter( MicroStep.deadline_id == deadline_id, MicroStep.status == MicroStepStatus.completed, ).count() for i, s in enumerate(new_steps_data): db.add(MicroStep( deadline_id=deadline_id, title=s["title"], context=s.get("context", ""), order_num=completed_count + i, duration_minutes=s.get("duration_minutes", 5), )) db.commit() steps = _deadline_steps(db, deadline_id) buddy_message = "Ich hab den Plan etwas gestrafft — passt besser zur verbleibenden Zeit." except Exception: pass return templates.TemplateResponse( "partials/elastic_plan.html", { "request": request, "deadline": deadline, "steps": steps, "buddy_message": buddy_message, "now": _utcnow(), }, ) @app.post("/deadlines/{deadline_id}/replan", response_class=HTMLResponse) async def replan_deadline(deadline_id: int, request: Request, db: Session = Depends(get_db)): deadline = db.query(Deadline).filter(Deadline.id == deadline_id).first() if not deadline: return HTMLResponse('Deadline nicht gefunden.
') hours_until_due = (deadline.due_date - _utcnow()).total_seconds() / 3600 # Remove skipped + pending; keep completed db.query(MicroStep).filter( MicroStep.deadline_id == deadline_id, MicroStep.status.in_([MicroStepStatus.pending, MicroStepStatus.skipped]), ).delete(synchronize_session=False) db.commit() new_steps_data = await generate_micro_steps( deadline.title, deadline.description or "", deadline.due_date, hours_until_due, ) completed_count = db.query(MicroStep).filter( MicroStep.deadline_id == deadline_id, MicroStep.status == MicroStepStatus.completed, ).count() for i, s in enumerate(new_steps_data): db.add(MicroStep( deadline_id=deadline_id, title=s["title"], context=s.get("context", ""), order_num=completed_count + i, duration_minutes=s.get("duration_minutes", 5), )) db.commit() steps = _deadline_steps(db, deadline_id) return templates.TemplateResponse( "partials/elastic_plan.html", { "request": request, "deadline": deadline, "steps": steps, "buddy_message": "Plan angepasst! Hier ist dein neuer Fahrplan.", "now": _utcnow(), }, )