206 lines
6.5 KiB
Python
206 lines
6.5 KiB
Python
"""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/<int:project_id>/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/<int:task_id>/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/<int:task_id>/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")
|