"""Routen des Projekte-Moduls — voller Funktionsumfang aus dem HTML-Prototyp. Alles ist login_required und strikt auf current_user gescoped. Schreibende Aktionen laufen als POST + Redirect (PRG-Muster), damit kein Reload doppelt ausführt. """ from datetime import date, datetime, timedelta from urllib.parse import urlparse from flask import render_template, request, redirect, url_for, flash, Response from flask_login import login_required, current_user from ..extensions import db from .models import Project, Task from .helpers import next_color, due_badge, sort_tasks, today_label from .pdf import build_todo_pdf from . import bp VALID_FILTERS = {"all", "open", "overdue", "soon", "done"} def _redirect_back(): """Zur vorigen Ansicht zurück (erhält z.B. den Filter), sonst zur Übersicht.""" ref = request.referrer if ref: parsed = urlparse(ref) if not parsed.netloc or parsed.netloc == urlparse(request.host_url).netloc: return redirect(ref) return redirect(url_for("projects.index")) def _owned_project_or_none(project_id): """Projekt des aktuellen Users per (string) ID holen — oder None.""" try: pid = int(project_id) except (TypeError, ValueError): return None return Project.query.filter_by(id=pid, user_id=current_user.id).first() @bp.route("/") @login_required def index(): flt = request.args.get("filter", "all") if flt not in VALID_FILTERS: flt = "all" today = date.today() week = today + timedelta(days=7) projects = ( Project.query.filter_by(user_id=current_user.id) .order_by(Project.created_at) .all() ) tasks = Task.query.filter_by(user_id=current_user.id).all() projects_by_id = {p.id: p for p in projects} # Statistik-Leiste stats = { "open": sum(1 for t in tasks if not t.done), "done": sum(1 for t in tasks if t.done), "overdue": sum(1 for t in tasks if not t.done and t.due_date and t.due_date < today), "projects": len(projects), } # Ansicht "Nach Projekt" project_views = [] for p in projects: ptasks = sort_tasks([t for t in tasks if t.project_id == p.id]) project_views.append( { "project": p, "tasks": ptasks, "open": sum(1 for t in ptasks if not t.done), "total": len(ptasks), } ) proj_open_total = sum(v["open"] for v in project_views) # Ansicht "Alle Aufgaben" (gefiltert) def keep(t): if flt == "open": return not t.done if flt == "done": return t.done if flt == "overdue": return not t.done and t.due_date and t.due_date < today if flt == "soon": return not t.done and t.due_date and today <= t.due_date <= week return True all_tasks = sort_tasks([t for t in tasks if keep(t)]) return render_template( "projects/index.html", projects=projects, projects_by_id=projects_by_id, project_views=project_views, proj_open_total=proj_open_total, all_tasks=all_tasks, stats=stats, active_filter=flt, today_label=today_label(), ) # ── Projekte ───────────────────────────────────────────────────────────── @bp.route("/projects", methods=["POST"]) @login_required def create_project(): name = (request.form.get("name") or "").strip() if not name: flash("Projektname fehlt.", "error") return _redirect_back() count = Project.query.filter_by(user_id=current_user.id).count() project = Project(user_id=current_user.id, name=name[:120], color=next_color(count)) db.session.add(project) db.session.commit() return _redirect_back() @bp.route("/projects//delete", methods=["POST"]) @login_required def delete_project(project_id): project = Project.query.filter_by(id=project_id, user_id=current_user.id).first_or_404() # Aufgaben bleiben erhalten, verlieren nur die Projektzuordnung. for t in project.tasks: t.project_id = None db.session.delete(project) db.session.commit() flash("Projekt gelöscht. Aufgaben bleiben erhalten.", "info") return _redirect_back() # ── Aufgaben ───────────────────────────────────────────────────────────── @bp.route("/tasks", methods=["POST"]) @login_required def create_task(): text = (request.form.get("text") or "").strip() if not text: flash("Aufgabentext fehlt.", "error") return _redirect_back() project = _owned_project_or_none(request.form.get("project_id")) due_raw = (request.form.get("due_date") or "").strip() due = None if due_raw: try: due = datetime.strptime(due_raw, "%Y-%m-%d").date() except ValueError: due = None task = Task( user_id=current_user.id, text=text[:500], project_id=project.id if project else None, due_date=due, ) db.session.add(task) db.session.commit() return _redirect_back() @bp.route("/tasks//toggle", methods=["POST"]) @login_required def toggle_task(task_id): task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404() task.done = not task.done db.session.commit() return _redirect_back() @bp.route("/tasks//delete", methods=["POST"]) @login_required def delete_task(task_id): task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404() db.session.delete(task) db.session.commit() return _redirect_back() # ── PDF-Export (druckbare To-Do-Liste) ────────────────────────────────── @bp.route("/export.pdf") @login_required def export_pdf(): projects = ( Project.query.filter_by(user_id=current_user.id) .order_by(Project.created_at) .all() ) tasks = Task.query.filter_by(user_id=current_user.id).all() pdf_bytes = build_todo_pdf(projects, tasks) filename = f"aufgaben_{date.today().isoformat()}.pdf" return Response( pdf_bytes, mimetype="application/pdf", headers={"Content-Disposition": f"inline; filename={filename}"}, ) # Fälligkeits-Badge als Template-Global verfügbar machen. bp.add_app_template_global(due_badge, "due_badge")