first commit

This commit is contained in:
2026-04-13 08:32:28 +02:00
commit 1074a91487
72 changed files with 4078 additions and 0 deletions

2
app/routes/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from . import auth, main, scoreboard, settings, tasks

53
app/routes/auth.py Normal file
View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from flask import Blueprint, flash, redirect, render_template, url_for
from flask_login import current_user, login_required, login_user, logout_user
from ..extensions import db
from ..forms import LoginForm, RegisterForm
from ..models import User
bp = Blueprint("auth", __name__)
@bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("tasks.my_tasks"))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.lower().strip()).first()
if user and user.check_password(form.password.data):
login_user(user, remember=form.remember_me.data)
flash(f"Willkommen zurück, {user.name}.", "success")
return redirect(url_for("tasks.my_tasks"))
flash("Die Kombination aus E-Mail und Passwort passt leider nicht.", "error")
return render_template("auth/login.html", form=form)
@bp.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("tasks.my_tasks"))
form = RegisterForm()
if form.validate_on_submit():
user = User(name=form.name.data.strip(), email=form.email.data.lower().strip())
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
login_user(user)
flash("Dein Konto ist bereit. Willkommen in der Putzliga.", "success")
return redirect(url_for("tasks.my_tasks"))
return render_template("auth/register.html", form=form)
@bp.route("/logout")
@login_required
def logout():
logout_user()
flash("Du bist jetzt abgemeldet.", "info")
return redirect(url_for("auth.login"))

45
app/routes/main.py Normal file
View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from flask import Blueprint, current_app, redirect, send_from_directory, url_for
from flask_login import current_user
bp = Blueprint("main", __name__)
@bp.route("/")
def index():
if current_user.is_authenticated:
return redirect(url_for("tasks.my_tasks"))
return redirect(url_for("auth.login"))
@bp.route("/healthz")
def healthz():
return {"status": "ok"}, 200
@bp.route("/manifest.json")
def manifest():
return send_from_directory(current_app.static_folder, "manifest.json", mimetype="application/manifest+json")
@bp.route("/service-worker.js")
def service_worker():
response = send_from_directory(current_app.static_folder, "service-worker.js", mimetype="application/javascript")
response.headers["Service-Worker-Allowed"] = "/"
return response
@bp.route("/uploads/<path:filename>")
def uploads(filename: str):
return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename)
@lru_cache(maxsize=64)
def load_icon_svg(name: str, static_folder: str) -> str:
path = Path(static_folder) / "icons" / f"{name}.svg"
return path.read_text(encoding="utf-8") if path.exists() else ""

43
app/routes/scoreboard.py Normal file
View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from flask import Blueprint, render_template, request
from flask_login import login_required
from ..services.dates import local_now, month_label
from ..services.monthly import archive_months_missing_up_to_previous, compute_monthly_scores, get_archived_months, get_snapshot_rows
bp = Blueprint("scoreboard", __name__, url_prefix="/scoreboard")
@bp.route("")
@login_required
def index():
archive_months_missing_up_to_previous()
now = local_now()
current_rows = compute_monthly_scores(now.year, now.month)
archive_options = get_archived_months(limit=18)
selected = request.args.get("archive")
selected_archive = selected
selected_year = selected_month = None
archived_rows = []
if selected:
year_str, month_str = selected.split("-")
selected_year, selected_month = int(year_str), int(month_str)
archived_rows = get_snapshot_rows(selected_year, selected_month)
elif archive_options:
selected_year, selected_month = archive_options[0]
selected_archive = f"{selected_year}-{selected_month:02d}"
archived_rows = get_snapshot_rows(selected_year, selected_month)
return render_template(
"scoreboard/index.html",
current_rows=current_rows,
current_label=month_label(now.year, now.month),
archive_options=archive_options,
selected_archive=selected_archive,
archived_rows=archived_rows,
archive_label=month_label(selected_year, selected_month) if selected_year and selected_month else None,
max_points=max([row["total_points"] for row in current_rows], default=1),
)

106
app/routes/settings.py Normal file
View File

@@ -0,0 +1,106 @@
from __future__ import annotations
from pathlib import Path
from uuid import uuid4
from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from ..extensions import csrf, db
from ..forms import SettingsProfileForm
from ..models import BadgeDefinition, PushSubscription
from ..services.notifications import push_enabled
bp = Blueprint("settings", __name__, url_prefix="/settings")
def _save_avatar(file_storage) -> str:
filename = secure_filename(file_storage.filename or "")
ext = Path(filename).suffix.lower() or ".png"
relative_path = Path("avatars") / f"{uuid4().hex}{ext}"
absolute_path = Path(current_app.config["UPLOAD_FOLDER"]) / relative_path
absolute_path.parent.mkdir(parents=True, exist_ok=True)
file_storage.save(absolute_path)
return relative_path.as_posix()
@bp.route("", methods=["GET", "POST"])
@login_required
def index():
form = SettingsProfileForm(original_email=current_user.email, obj=current_user)
if form.validate_on_submit():
current_user.name = form.name.data.strip()
current_user.email = form.email.data.lower().strip()
current_user.notification_task_due_enabled = form.notification_task_due_enabled.data
current_user.notification_monthly_winner_enabled = form.notification_monthly_winner_enabled.data
if form.password.data:
current_user.set_password(form.password.data)
if form.avatar.data:
current_user.avatar_path = _save_avatar(form.avatar.data)
db.session.commit()
flash("Deine Einstellungen wurden gespeichert.", "success")
return redirect(url_for("settings.index"))
badges = BadgeDefinition.query.order_by(BadgeDefinition.name.asc()).all()
subscriptions = PushSubscription.query.filter_by(user_id=current_user.id).all()
return render_template(
"settings/index.html",
form=form,
badges=badges,
push_ready=push_enabled(),
vapid_public_key=current_app.config["VAPID_PUBLIC_KEY"],
has_subscription=bool(subscriptions),
)
@bp.route("/badges/<int:badge_id>", methods=["POST"])
@login_required
def update_badge(badge_id: int):
badge = BadgeDefinition.query.get_or_404(badge_id)
badge.threshold = max(1, request.form.get("threshold", type=int, default=badge.threshold))
badge.bonus_points = max(0, request.form.get("bonus_points", type=int, default=badge.bonus_points))
badge.active = request.form.get("active") == "on"
db.session.commit()
flash(f"Badge „{badge.name}“ wurde aktualisiert.", "success")
return redirect(url_for("settings.index"))
@bp.route("/push/subscribe", methods=["POST"])
@login_required
@csrf.exempt
def push_subscribe():
if not push_enabled():
return jsonify({"ok": False, "message": "VAPID ist nicht konfiguriert."}), 400
data = request.get_json(silent=True) or {}
endpoint = data.get("endpoint")
keys = data.get("keys", {})
if not endpoint or not keys.get("p256dh") or not keys.get("auth"):
return jsonify({"ok": False, "message": "Subscription unvollständig."}), 400
subscription = PushSubscription.query.filter_by(endpoint=endpoint).first()
if not subscription:
subscription = PushSubscription(user_id=current_user.id, endpoint=endpoint, p256dh=keys["p256dh"], auth=keys["auth"])
db.session.add(subscription)
else:
subscription.user_id = current_user.id
subscription.p256dh = keys["p256dh"]
subscription.auth = keys["auth"]
db.session.commit()
return jsonify({"ok": True})
@bp.route("/push/unsubscribe", methods=["POST"])
@login_required
@csrf.exempt
def push_unsubscribe():
data = request.get_json(silent=True) or {}
endpoint = data.get("endpoint")
if endpoint:
subscription = PushSubscription.query.filter_by(endpoint=endpoint, user_id=current_user.id).first()
if subscription:
db.session.delete(subscription)
db.session.commit()
return jsonify({"ok": True})

174
app/routes/tasks.py Normal file
View File

@@ -0,0 +1,174 @@
from __future__ import annotations
import calendar
from collections import defaultdict
from datetime import date
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from ..forms import TaskForm
from ..models import TaskInstance, User
from ..services.dates import month_label, today_local
from ..services.tasks import complete_task, create_task_template_and_instance, refresh_task_statuses, update_template_and_instance
bp = Blueprint("tasks", __name__, url_prefix="")
def _user_choices() -> list[tuple[int, str]]:
return [(user.id, user.name) for user in User.query.order_by(User.name.asc()).all()]
@bp.route("/my-tasks")
@login_required
def my_tasks():
tasks = (
TaskInstance.query.filter_by(assigned_user_id=current_user.id)
.order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc())
.all()
)
refresh_task_statuses(tasks)
sections = {"open": [], "soon": [], "overdue": [], "completed": []}
for task in tasks:
sections[task.status].append(task)
completed_count = len(sections["completed"])
active_count = len(sections["open"]) + len(sections["soon"]) + len(sections["overdue"])
completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100)
return render_template(
"tasks/my_tasks.html",
sections=sections,
completion_ratio=completion_ratio,
today=today_local(),
)
@bp.route("/tasks")
@login_required
def all_tasks():
query = TaskInstance.query
status = request.args.get("status", "all")
mine = request.args.get("mine")
user_filter = request.args.get("user_id", type=int)
sort = request.args.get("sort", "due")
if mine == "1":
query = query.filter(TaskInstance.assigned_user_id == current_user.id)
elif user_filter:
query = query.filter(TaskInstance.assigned_user_id == user_filter)
if sort == "points":
query = query.order_by(TaskInstance.points_awarded.desc(), TaskInstance.due_date.asc())
elif sort == "user":
query = query.order_by(TaskInstance.assigned_user_id.asc(), TaskInstance.due_date.asc())
else:
query = query.order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc())
tasks = query.all()
refresh_task_statuses(tasks)
if status != "all":
status_map = {"completed": "completed", "overdue": "overdue", "open": "open", "soon": "soon"}
selected = status_map.get(status)
if selected:
tasks = [task for task in tasks if task.status == selected]
return render_template(
"tasks/all_tasks.html",
tasks=tasks,
users=User.query.order_by(User.name.asc()).all(),
filters={"status": status, "mine": mine, "user_id": user_filter, "sort": sort},
)
@bp.route("/tasks/new", methods=["GET", "POST"])
@login_required
def create():
form = TaskForm()
form.assigned_user_id.choices = _user_choices()
if request.method == "GET" and not form.due_date.data:
form.due_date.data = today_local()
if form.validate_on_submit():
task = create_task_template_and_instance(form)
flash(f"Aufgabe „{task.title}“ wurde angelegt.", "success")
return redirect(url_for("tasks.my_tasks"))
return render_template("tasks/task_form.html", form=form, mode="create", task=None)
@bp.route("/tasks/<int:task_id>/edit", methods=["GET", "POST"])
@login_required
def edit(task_id: int):
task = TaskInstance.query.get_or_404(task_id)
form = TaskForm(obj=task.task_template)
form.assigned_user_id.choices = _user_choices()
if request.method == "GET":
form.title.data = task.title
form.description.data = task.description
form.default_points.data = task.points_awarded
form.assigned_user_id.data = task.assigned_user_id or _user_choices()[0][0]
form.due_date.data = task.due_date
form.recurrence_interval_value.data = task.task_template.recurrence_interval_value or 1
form.recurrence_interval_unit.data = task.task_template.recurrence_interval_unit
form.active.data = task.task_template.active
if form.validate_on_submit():
update_template_and_instance(task, form)
flash("Aufgabe und Vorlage wurden aktualisiert.", "success")
return redirect(url_for("tasks.all_tasks"))
return render_template("tasks/task_form.html", form=form, mode="edit", task=task)
@bp.route("/tasks/<int:task_id>/complete", methods=["POST"])
@login_required
def complete(task_id: int):
task = TaskInstance.query.get_or_404(task_id)
choice = request.form.get("completed_for", "me")
if task.is_completed:
flash("Diese Aufgabe ist bereits erledigt.", "info")
return redirect(request.referrer or url_for("tasks.my_tasks"))
completed_by_id = current_user.id
if task.assigned_user_id and task.assigned_user_id != current_user.id and choice == "assigned":
completed_by_id = task.assigned_user_id
complete_task(task, completed_by_id)
flash("Punkte verbucht. Gute Arbeit.", "success")
return redirect(request.referrer or url_for("tasks.my_tasks"))
@bp.route("/calendar")
@login_required
def calendar_view():
today = today_local()
year = request.args.get("year", type=int) or today.year
month = request.args.get("month", type=int) or today.month
view = request.args.get("view", "calendar")
tasks = TaskInstance.query.filter(
TaskInstance.due_date >= date(year, month, 1),
TaskInstance.due_date <= date(year, month, calendar.monthrange(year, month)[1]),
).order_by(TaskInstance.due_date.asc()).all()
refresh_task_statuses(tasks)
tasks_by_day: dict[int, list[TaskInstance]] = defaultdict(list)
for task in tasks:
tasks_by_day[task.due_date.day].append(task)
month_calendar = calendar.Calendar(firstweekday=0).monthdayscalendar(year, month)
return render_template(
"tasks/calendar.html",
current_year=year,
current_month=month,
current_label=month_label(year, month),
month_calendar=month_calendar,
tasks_by_day=tasks_by_day,
view=view,
tasks=tasks,
)