new file: .env.example
new file: .gitignore new file: Dockerfile new file: README.md new file: app.py new file: auth.py new file: config.py new file: db.py new file: db_helpers.py new file: docker-compose.yml new file: requirements.txt new file: routes/__init__.py new file: routes/admin_routes.py new file: routes/auth_routes.py new file: routes/journal_routes.py new file: static/css/style.css new file: static/js/main.js new file: templates/admin/dashboard.html new file: templates/admin/users.html new file: templates/base.html new file: templates/day.html new file: templates/future.html new file: templates/index.html new file: templates/login.html new file: templates/week.html
This commit is contained in:
0
routes/__init__.py
Normal file
0
routes/__init__.py
Normal file
112
routes/admin_routes.py
Normal file
112
routes/admin_routes.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Admin-Routen: Dashboard, Benutzerverwaltung (erstellen, löschen, Passwort zurücksetzen)."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
|
||||
from auth import admin_required, hash_password
|
||||
from db import get_connection
|
||||
from db_helpers import get_user_by_id
|
||||
|
||||
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
@admin_bp.route("/")
|
||||
@admin_required
|
||||
def dashboard():
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) AS total FROM users")
|
||||
total_users = cur.fetchone()["total"]
|
||||
cur.execute("SELECT COUNT(*) AS total FROM notes")
|
||||
total_notes = cur.fetchone()["total"]
|
||||
cur.execute("SELECT COUNT(*) AS c FROM users WHERE is_admin = 1")
|
||||
total_admins = cur.fetchone()["c"]
|
||||
cur.execute(
|
||||
"SELECT username, email, is_admin, created_at FROM users "
|
||||
"ORDER BY created_at DESC LIMIT 5"
|
||||
)
|
||||
recent = cur.fetchall()
|
||||
return render_template(
|
||||
"admin/dashboard.html",
|
||||
total_users=total_users,
|
||||
total_notes=total_notes,
|
||||
total_admins=total_admins,
|
||||
recent=recent,
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route("/users", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def users():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
email = request.form.get("email", "").strip()
|
||||
is_admin = 1 if request.form.get("is_admin") else 0
|
||||
|
||||
if not username or not password:
|
||||
flash("Benutzername und Passwort sind Pflichtfelder.", "danger")
|
||||
else:
|
||||
try:
|
||||
salt, password_hash = hash_password(password)
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, email, salt, "
|
||||
"password_hash, is_admin) VALUES (%s, %s, %s, %s, %s)",
|
||||
(username, email, salt, password_hash, is_admin),
|
||||
)
|
||||
flash(f"Benutzer '{username}' angelegt.", "success")
|
||||
except Exception as exc: # Duplicate Key etc.
|
||||
flash(f"Benutzer konnte nicht angelegt werden: {exc}", "danger")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT u.*, (SELECT COUNT(*) FROM notes n WHERE n.user_id = u.id) "
|
||||
"AS note_count FROM users u ORDER BY u.username ASC"
|
||||
)
|
||||
user_list = cur.fetchall()
|
||||
return render_template("admin/users.html", users=user_list)
|
||||
|
||||
|
||||
@admin_bp.route("/users/<int:user_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def delete_user(user_id: int):
|
||||
# Admin darf sich nicht selbst löschen
|
||||
target = get_user_by_id(user_id)
|
||||
if not target:
|
||||
flash("Benutzer nicht gefunden.", "danger")
|
||||
return redirect(url_for("admin.users"))
|
||||
if target["id"] == session.get("user_id"):
|
||||
flash("Du kannst dich nicht selbst löschen.", "danger")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||||
flash(f"Benutzer '{target['username']}' gelöscht.", "success")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
@admin_bp.route("/users/<int:user_id>/reset-password", methods=["POST"])
|
||||
@admin_required
|
||||
def reset_password(user_id: int):
|
||||
new_password = request.form.get("new_password", "")
|
||||
if not new_password:
|
||||
flash("Neues Passwort fehlt.", "danger")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
target = get_user_by_id(user_id)
|
||||
if not target:
|
||||
flash("Benutzer nicht gefunden.", "danger")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
salt, password_hash = hash_password(new_password)
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE users SET salt = %s, password_hash = %s WHERE id = %s",
|
||||
(salt, password_hash, user_id),
|
||||
)
|
||||
flash(f"Passwort von '{target['username']}' zurückgesetzt.", "success")
|
||||
return redirect(url_for("admin.users"))
|
||||
35
routes/auth_routes.py
Normal file
35
routes/auth_routes.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Auth-Routen: Login und Logout."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
|
||||
from auth import verify_password
|
||||
from db_helpers import get_user_by_username
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
|
||||
user = get_user_by_username(username)
|
||||
if user and verify_password(password, user["salt"], user["password_hash"]):
|
||||
session.clear()
|
||||
session["user_id"] = user["id"]
|
||||
session["username"] = user["username"]
|
||||
session["is_admin"] = bool(user["is_admin"])
|
||||
flash(f"Willkommen, {user['username']}!", "success")
|
||||
if user["is_admin"]:
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
return redirect(url_for("journal.index"))
|
||||
flash("Ungültiger Benutzername oder Passwort.", "danger")
|
||||
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
session.clear()
|
||||
flash("Du wurdest abgemeldet.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
325
routes/journal_routes.py
Normal file
325
routes/journal_routes.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Journal-Routen: Index/Dashboard, Future Log, Wochenübersicht, Tagesansicht.
|
||||
|
||||
Enthält auch Helfer für Wochenberechnung und Notiz-Aktionen
|
||||
(erledigt markieren, in anderen Tag / Future Log verschieben).
|
||||
"""
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
|
||||
from auth import login_required
|
||||
from db import get_connection
|
||||
|
||||
journal_bp = Blueprint("journal", __name__)
|
||||
|
||||
# Deutsche Wochentage / Monate
|
||||
WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||
MONTHS = [
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
]
|
||||
BULLET_LABELS = {
|
||||
".": "Aufgabe",
|
||||
"x": "Termin",
|
||||
"-": "Notiz",
|
||||
"?": "Frage",
|
||||
}
|
||||
|
||||
|
||||
def _user_id() -> int:
|
||||
return session["user_id"]
|
||||
|
||||
|
||||
def monday_of_week(d: date) -> date:
|
||||
"""Liefert den Montag der Woche, in der d liegt."""
|
||||
return d - timedelta(days=d.weekday())
|
||||
|
||||
|
||||
def get_week_days(monday: date) -> list[date]:
|
||||
return [monday + timedelta(days=i) for i in range(7)]
|
||||
|
||||
|
||||
def parse_date(s: str) -> date | None:
|
||||
try:
|
||||
return datetime.strptime(s, "%Y-%m-%d").date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_notes(user_id: int, note_date: date) -> list[dict]:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM notes WHERE user_id = %s AND note_date = %s "
|
||||
"ORDER BY id ASC",
|
||||
(user_id, note_date),
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def _active_notes(user_id: int, note_date: date) -> list[dict]:
|
||||
return [n for n in _fetch_notes(user_id, note_date) if n["status"] != "moved"]
|
||||
|
||||
|
||||
@journal_bp.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
"""Dashboard: aktuelle Woche + offene Aufgaben."""
|
||||
today = date.today()
|
||||
monday = monday_of_week(today)
|
||||
week_days = get_week_days(monday)
|
||||
|
||||
# Detaillierte Info pro Tag
|
||||
day_summary = []
|
||||
for d in week_days:
|
||||
notes = _active_notes(_user_id(), d)
|
||||
day_summary.append(
|
||||
{
|
||||
"date": d,
|
||||
"weekday": WEEKDAYS[d.weekday()],
|
||||
"open": sum(1 for n in notes if n["status"] == "open"),
|
||||
"done": sum(1 for n in notes if n["status"] == "done"),
|
||||
"total": len(notes),
|
||||
}
|
||||
)
|
||||
|
||||
# Offene Aufgaben außerhalb dieser Woche
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM notes WHERE user_id = %s AND status = 'open' "
|
||||
"AND note_date < %s ORDER BY note_date ASC LIMIT 50",
|
||||
(_user_id(), monday),
|
||||
)
|
||||
overdue = cur.fetchall()
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
today=today,
|
||||
monday=monday,
|
||||
day_summary=day_summary,
|
||||
overdue=overdue,
|
||||
weekdays=WEEKDAYS,
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.route("/future", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def future():
|
||||
if request.method == "POST":
|
||||
note_text = request.form.get("note_text", "").strip()
|
||||
month_str = request.form.get("month", "")
|
||||
month = parse_date(month_str)
|
||||
if note_text and month:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO future_log (user_id, month_date, note_text) "
|
||||
"VALUES (%s, %s, %s)",
|
||||
(_user_id(), month, note_text),
|
||||
)
|
||||
flash("Eintrag ins Future Log hinzugefügt.", "success")
|
||||
else:
|
||||
flash("Bitte Text und Monat angeben.", "danger")
|
||||
return redirect(url_for("journal.future"))
|
||||
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM future_log WHERE user_id = %s ORDER BY month_date ASC, id ASC",
|
||||
(_user_id(),),
|
||||
)
|
||||
entries = cur.fetchall()
|
||||
|
||||
# Gruppieren nach Monat
|
||||
grouped = {}
|
||||
for e in entries:
|
||||
key = e["month_date"].strftime("%Y-%m")
|
||||
grouped.setdefault(key, []).append(e)
|
||||
|
||||
return render_template("future.html", grouped=grouped, months=MONTHS)
|
||||
|
||||
|
||||
@journal_bp.route("/woche")
|
||||
@login_required
|
||||
def week():
|
||||
"""Wochenübersicht: Mon–So mit Status je Tag + offene Aufgaben."""
|
||||
week_param = request.args.get("week", "")
|
||||
if week_param:
|
||||
try:
|
||||
iso_year, iso_week = week_param.split("-W")
|
||||
monday = date.fromisocalendar(int(iso_year), int(iso_week), 1)
|
||||
except (ValueError, TypeError):
|
||||
monday = monday_of_week(date.today())
|
||||
else:
|
||||
monday = monday_of_week(date.today())
|
||||
|
||||
week_days = get_week_days(monday)
|
||||
|
||||
days_detail = []
|
||||
for d in week_days:
|
||||
notes = _active_notes(_user_id(), d)
|
||||
days_detail.append(
|
||||
{
|
||||
"date": d,
|
||||
"weekday": WEEKDAYS[d.weekday()],
|
||||
"open": sum(1 for n in notes if n["status"] == "open"),
|
||||
"done": sum(1 for n in notes if n["status"] == "done"),
|
||||
"moved": sum(1 for n in notes if n["status"] == "moved"),
|
||||
"total": len(notes),
|
||||
}
|
||||
)
|
||||
|
||||
# Offene Aufgaben der ausgewählten Woche
|
||||
sunday = monday + timedelta(days=6)
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM notes WHERE user_id = %s AND status = 'open' "
|
||||
"AND note_date BETWEEN %s AND %s ORDER BY note_date ASC, id ASC",
|
||||
(_user_id(), monday, sunday),
|
||||
)
|
||||
open_notes = cur.fetchall()
|
||||
|
||||
prev_monday = monday - timedelta(days=7)
|
||||
next_monday = monday + timedelta(days=7)
|
||||
|
||||
return render_template(
|
||||
"week.html",
|
||||
monday=monday,
|
||||
days_detail=days_detail,
|
||||
open_notes=open_notes,
|
||||
weekdays=WEEKDAYS,
|
||||
prev_monday=prev_monday,
|
||||
next_monday=next_monday,
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.route("/tag/<date_str>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def day(date_str: str):
|
||||
"""Tagesansicht mit 3 Spalten (Bullet | Notiz | Priorität)."""
|
||||
target = parse_date(date_str)
|
||||
if target is None:
|
||||
flash("Ungültiges Datum.", "danger")
|
||||
return redirect(url_for("journal.index"))
|
||||
|
||||
if request.method == "POST":
|
||||
bullet = request.form.get("bullet", "-")
|
||||
note_text = request.form.get("note_text", "").strip()
|
||||
priority = request.form.get("priority", "0")
|
||||
try:
|
||||
priority = int(priority)
|
||||
except ValueError:
|
||||
priority = 0
|
||||
|
||||
if bullet not in (".", "x", "-", "?"):
|
||||
bullet = "-"
|
||||
if note_text:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO notes (user_id, note_date, bullet_type, "
|
||||
"note_text, priority) VALUES (%s, %s, %s, %s, %s)",
|
||||
(_user_id(), target, bullet, note_text, priority),
|
||||
)
|
||||
flash("Notiz hinzugefügt.", "success")
|
||||
else:
|
||||
flash("Notiz darf nicht leer sein.", "danger")
|
||||
return redirect(url_for("journal.day", date_str=date_str))
|
||||
|
||||
notes = _active_notes(_user_id(), target)
|
||||
|
||||
prev = target - timedelta(days=1)
|
||||
nxt = target + timedelta(days=1)
|
||||
|
||||
return render_template(
|
||||
"day.html",
|
||||
target=target,
|
||||
weekday=WEEKDAYS[target.weekday()],
|
||||
notes=notes,
|
||||
bullet_labels=BULLET_LABELS,
|
||||
prev=prev,
|
||||
nxt=nxt,
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.route("/note/<int:note_id>/mark", methods=["POST"])
|
||||
@login_required
|
||||
def mark_done(note_id: int):
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM notes WHERE id = %s AND user_id = %s",
|
||||
(note_id, _user_id()),
|
||||
)
|
||||
note = cur.fetchone()
|
||||
if not note:
|
||||
flash("Notiz nicht gefunden.", "danger")
|
||||
return redirect(url_for("journal.index"))
|
||||
new_status = "done" if note["status"] != "done" else "open"
|
||||
cur.execute(
|
||||
"UPDATE notes SET status = %s WHERE id = %s",
|
||||
(new_status, note_id),
|
||||
)
|
||||
flash("Status aktualisiert.", "success")
|
||||
return redirect(request.referrer or url_for("journal.index"))
|
||||
|
||||
|
||||
@journal_bp.route("/note/<int:note_id>/move", methods=["POST"])
|
||||
@login_required
|
||||
def move_note(note_id: int):
|
||||
"""Verschiebt eine Notiz in einen anderen Tag oder ins Future Log.
|
||||
|
||||
Formularfelder: target_date (YYYY-MM-DD) oder target=future
|
||||
"""
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM notes WHERE id = %s AND user_id = %s",
|
||||
(note_id, _user_id()),
|
||||
)
|
||||
note = cur.fetchone()
|
||||
if not note:
|
||||
flash("Notiz nicht gefunden.", "danger")
|
||||
return redirect(url_for("journal.index"))
|
||||
|
||||
target = request.form.get("target", "")
|
||||
if target == "future":
|
||||
# In Future Log übernehmen
|
||||
cur.execute(
|
||||
"INSERT INTO future_log (user_id, month_date, note_text) "
|
||||
"VALUES (%s, %s, %s)",
|
||||
(_user_id(), note["note_date"], note["note_text"]),
|
||||
)
|
||||
cur.execute(
|
||||
"UPDATE notes SET status = 'moved', moved_to_future = 1 "
|
||||
"WHERE id = %s",
|
||||
(note_id,),
|
||||
)
|
||||
flash("In Future Log verschoben.", "success")
|
||||
else:
|
||||
target_date = parse_date(target)
|
||||
if target_date:
|
||||
cur.execute(
|
||||
"UPDATE notes SET status = 'moved', moved_to_date = %s "
|
||||
"WHERE id = %s",
|
||||
(target_date, note_id),
|
||||
)
|
||||
# Am Zieltag als offene Notiz neu anlegen
|
||||
cur.execute(
|
||||
"INSERT INTO notes (user_id, note_date, bullet_type, "
|
||||
"note_text, priority) VALUES (%s, %s, %s, %s, %s)",
|
||||
(
|
||||
_user_id(),
|
||||
target_date,
|
||||
note["bullet_type"],
|
||||
note["note_text"],
|
||||
note["priority"],
|
||||
),
|
||||
)
|
||||
flash(f"Verschoben nach {target_date}.", "success")
|
||||
else:
|
||||
flash("Kein gültiges Ziel angegeben.", "danger")
|
||||
|
||||
return redirect(request.referrer or url_for("journal.index"))
|
||||
Reference in New Issue
Block a user