modified: Dockerfile
new file: pdf_export.py modified: requirements.txt modified: routes/journal_routes.py modified: static/css/style.css modified: templates/base.html modified: templates/day.html new file: templates/edit_future.html new file: templates/edit_note.html modified: templates/future.html modified: templates/monat.html new file: templates/report.html modified: templates/week.html
This commit is contained in:
@@ -111,6 +111,122 @@ def bullet_label(bullet_type: str) -> str:
|
||||
return BULLET_LABELS.get(bullet_type, "Notiz")
|
||||
|
||||
|
||||
PRIORITY_LABELS = {0: "Niedrig", 1: "Mittel", 2: "Hoch"}
|
||||
STATUS_LABELS = {"open": "Offen", "done": "Erledigt", "moved": "Verschoben"}
|
||||
|
||||
|
||||
def _report_filters() -> dict:
|
||||
"""Liest die Report-Filter aus request.args (GET-Formular/Export-Links)."""
|
||||
args = request.args
|
||||
range_type = args.get("range", "week")
|
||||
|
||||
if range_type == "month":
|
||||
month_param = args.get("month", "")
|
||||
try:
|
||||
y, mo = (int(x) for x in month_param.split("-"))
|
||||
start = date(y, mo, 1)
|
||||
except (ValueError, TypeError):
|
||||
start = date.today().replace(day=1)
|
||||
end = date(start.year, start.month,
|
||||
calendar.monthrange(start.year, start.month)[1])
|
||||
label = start.strftime("%B %Y")
|
||||
elif range_type == "custom":
|
||||
start = parse_date(args.get("from", ""))
|
||||
end = parse_date(args.get("to", ""))
|
||||
if not start or not end or end < start:
|
||||
start = monday_of_week(date.today())
|
||||
end = start + timedelta(days=6)
|
||||
label = f"{start.strftime('%d.%m.%Y')} – {end.strftime('%d.%m.%Y')}"
|
||||
range_type = "custom"
|
||||
else: # week
|
||||
week_param = args.get("week", "")
|
||||
try:
|
||||
iso_year, iso_week = week_param.split("-W")
|
||||
start = date.fromisocalendar(int(iso_year), int(iso_week), 1)
|
||||
except (ValueError, TypeError):
|
||||
start = monday_of_week(date.today())
|
||||
end = start + timedelta(days=6)
|
||||
label = f"Woche {start.strftime('%d.%m.%Y')} – {end.strftime('%d.%m.%Y')}"
|
||||
|
||||
# Filter (leere Auswahl = alle zulassen)
|
||||
weekdays = [int(x) for x in args.getlist("wd")]
|
||||
bullets = [b for b in args.getlist("bullet") if b in BULLET_LABELS]
|
||||
statuses = [s for s in args.getlist("status") if s in STATUS_LABELS]
|
||||
priorities = [int(p) for p in args.getlist("priority") if p.isdigit()]
|
||||
|
||||
if not weekdays:
|
||||
weekdays = list(range(7))
|
||||
if not bullets:
|
||||
bullets = list(BULLET_LABELS.keys())
|
||||
if not statuses:
|
||||
statuses = ["open", "done"]
|
||||
if not priorities:
|
||||
priorities = [0, 1, 2]
|
||||
|
||||
return {
|
||||
"range_type": range_type,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"label": label,
|
||||
"weekdays": weekdays,
|
||||
"bullets": bullets,
|
||||
"statuses": statuses,
|
||||
"priorities": priorities,
|
||||
}
|
||||
|
||||
|
||||
def _run_report(uid: int, f: dict) -> tuple[list[dict], dict]:
|
||||
"""Führt den Report aus und liefert (rows, summary)."""
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM notes WHERE user_id = %s "
|
||||
"AND note_date BETWEEN %s AND %s "
|
||||
"ORDER BY note_date ASC, id ASC",
|
||||
(uid, f["start"], f["end"]),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
filtered = []
|
||||
for n in rows:
|
||||
if n["note_date"].weekday() not in f["weekdays"]:
|
||||
continue
|
||||
if n["bullet_type"] not in f["bullets"]:
|
||||
continue
|
||||
if n["status"] not in f["statuses"]:
|
||||
continue
|
||||
if n["priority"] not in f["priorities"]:
|
||||
continue
|
||||
filtered.append(n)
|
||||
|
||||
rows_out = []
|
||||
for n in filtered:
|
||||
refs = ", ".join(
|
||||
f"{ref_type_label(r.get('type', ''))} {r.get('value', '')}"
|
||||
for r in parse_refs(n.get("refs"))
|
||||
)
|
||||
rows_out.append({
|
||||
"id": n["id"],
|
||||
"date_str": n["note_date"].strftime("%d.%m.%Y"),
|
||||
"iso": n["note_date"].strftime("%Y-%m-%d"),
|
||||
"weekday": WEEKDAYS[n["note_date"].weekday()],
|
||||
"bullet": n["bullet_type"],
|
||||
"bullet_label": bullet_label(n["bullet_type"]),
|
||||
"note_text": n["note_text"],
|
||||
"priority": PRIORITY_LABELS.get(n.get("priority", 0), str(n.get("priority", ""))),
|
||||
"status": STATUS_LABELS.get(n.get("status"), n.get("status", "")),
|
||||
"status_code": n.get("status"),
|
||||
"refs": refs,
|
||||
})
|
||||
|
||||
summary = {
|
||||
"total": len(rows_out),
|
||||
"open": sum(1 for r in rows_out if r["status_code"] == "open"),
|
||||
"done": sum(1 for r in rows_out if r["status_code"] == "done"),
|
||||
}
|
||||
return rows_out, summary
|
||||
|
||||
|
||||
@journal_bp.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
@@ -409,6 +525,233 @@ def export_month():
|
||||
return _csv_response("Monatsübersicht", filename, rows)
|
||||
|
||||
|
||||
@journal_bp.route("/report")
|
||||
@login_required
|
||||
def report():
|
||||
"""Report-Generator: dynamischer Wochen-/Monatsbericht mit Filtern."""
|
||||
filters = _report_filters()
|
||||
rows, summary = _run_report(_user_id(), filters)
|
||||
today = date.today()
|
||||
|
||||
# Für Export-Links: aktuelle Filter als Query-String aufbauen
|
||||
def _qs(fmt):
|
||||
params = [
|
||||
("range", filters["range_type"]),
|
||||
("format", fmt),
|
||||
]
|
||||
for wd in filters["weekdays"]:
|
||||
params.append(("wd", str(wd)))
|
||||
for b in filters["bullets"]:
|
||||
params.append(("bullet", b))
|
||||
for s in filters["statuses"]:
|
||||
params.append(("status", s))
|
||||
for p in filters["priorities"]:
|
||||
params.append(("priority", str(p)))
|
||||
if filters["range_type"] == "month":
|
||||
params.append(("month", filters["start"].strftime("%Y-%m")))
|
||||
elif filters["range_type"] == "custom":
|
||||
params.append(("from", filters["start"].strftime("%Y-%m-%d")))
|
||||
params.append(("to", filters["end"].strftime("%Y-%m-%d")))
|
||||
else:
|
||||
params.append(("week", filters["start"].strftime("%G-W%V")))
|
||||
return url_for("journal.report_export", **dict(params))
|
||||
|
||||
export_csv_url = _qs("csv")
|
||||
export_pdf_url = _qs("pdf")
|
||||
|
||||
current_week = monday_of_week(today).strftime("%G-W%V")
|
||||
current_month = today.strftime("%Y-%m")
|
||||
|
||||
filters["week_value"] = current_week
|
||||
filters["from_value"] = filters["start"].strftime("%Y-%m-%d") if filters["range_type"] == "custom" else ""
|
||||
filters["to_value"] = filters["end"].strftime("%Y-%m-%d") if filters["range_type"] == "custom" else ""
|
||||
|
||||
return render_template(
|
||||
"report.html",
|
||||
f=filters,
|
||||
rows=rows,
|
||||
summary=summary,
|
||||
weekdays=WEEKDAYS,
|
||||
bullet_labels=BULLET_LABELS,
|
||||
current_week=current_week,
|
||||
current_month=current_month,
|
||||
today_str=today.strftime("%Y-%m-%d"),
|
||||
export_csv_url=export_csv_url,
|
||||
export_pdf_url=export_pdf_url,
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.route("/report/export")
|
||||
@login_required
|
||||
def report_export():
|
||||
"""Export des Report-Generators als CSV oder PDF (format=pdf|csv)."""
|
||||
filters = _report_filters()
|
||||
rows, summary = _run_report(_user_id(), filters)
|
||||
fmt = request.args.get("format", "csv")
|
||||
|
||||
safe_name = filters["label"].replace(" ", "_").replace("–", "-")
|
||||
filename = f"report_{safe_name}"
|
||||
|
||||
if fmt == "pdf":
|
||||
from pdf_export import build_pdf
|
||||
pdf_bytes = build_pdf(
|
||||
title="Bullet-Journal-Report",
|
||||
subtitle=filters["label"],
|
||||
rows=rows,
|
||||
summary=summary,
|
||||
)
|
||||
return Response(
|
||||
pdf_bytes,
|
||||
mimetype="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}.pdf"
|
||||
},
|
||||
)
|
||||
|
||||
# CSV
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
"Datum", "Wochentag", "Bullet", "Typ", "Notiz", "Priorität",
|
||||
"Status", "Verweise",
|
||||
])
|
||||
for r in rows:
|
||||
writer.writerow([
|
||||
r["date_str"], r["weekday"], r["bullet"], r["bullet_label"],
|
||||
r["note_text"], r["priority"], r["status"], r["refs"],
|
||||
])
|
||||
output.seek(0)
|
||||
return Response(
|
||||
"\ufeff" + output.getvalue(),
|
||||
mimetype="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}.csv"},
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.route("/note/<int:note_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_note(note_id: int):
|
||||
"""Bearbeitet eine Notiz (Text, Bullet, Priorität, Verweise)."""
|
||||
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"))
|
||||
|
||||
if request.method == "POST":
|
||||
bullet = request.form.get("bullet", "-")
|
||||
note_text = request.form.get("note_text", "").strip()
|
||||
try:
|
||||
priority = int(request.form.get("priority", "0"))
|
||||
except ValueError:
|
||||
priority = 0
|
||||
ref_type = request.form.get("ref_type", "").strip()
|
||||
ref_value = request.form.get("ref_value", "").strip()
|
||||
refs = None
|
||||
if ref_value:
|
||||
refs = json.dumps([{"type": ref_type or "link", "value": ref_value}])
|
||||
|
||||
if bullet not in (".", "x", "-", "?"):
|
||||
bullet = "-"
|
||||
if not note_text:
|
||||
flash("Notiz darf nicht leer sein.", "danger")
|
||||
else:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE notes SET bullet_type = %s, note_text = %s, "
|
||||
"priority = %s, refs = %s WHERE id = %s AND user_id = %s",
|
||||
(bullet, note_text, priority, refs, note_id, _user_id()),
|
||||
)
|
||||
flash("Notiz aktualisiert.", "success")
|
||||
return redirect(url_for("journal.day", date_str=note["note_date"].strftime("%Y-%m-%d")))
|
||||
|
||||
refs = parse_refs(note.get("refs"))
|
||||
current_ref = refs[0] if refs else {}
|
||||
|
||||
return render_template(
|
||||
"edit_note.html",
|
||||
note=note,
|
||||
bullet_labels=BULLET_LABELS,
|
||||
current_ref=current_ref,
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.route("/note/<int:note_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_note(note_id: int):
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT note_date 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"))
|
||||
cur.execute(
|
||||
"DELETE FROM notes WHERE id = %s AND user_id = %s",
|
||||
(note_id, _user_id()),
|
||||
)
|
||||
flash("Notiz gelöscht.", "success")
|
||||
return redirect(request.referrer or url_for("journal.index"))
|
||||
|
||||
|
||||
@journal_bp.route("/future/<int:entry_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_future(entry_id: int):
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM future_log WHERE id = %s AND user_id = %s",
|
||||
(entry_id, _user_id()),
|
||||
)
|
||||
entry = cur.fetchone()
|
||||
|
||||
if not entry:
|
||||
flash("Eintrag nicht gefunden.", "danger")
|
||||
return redirect(url_for("journal.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(
|
||||
"UPDATE future_log SET note_text = %s, month_date = %s "
|
||||
"WHERE id = %s AND user_id = %s",
|
||||
(note_text, month, entry_id, _user_id()),
|
||||
)
|
||||
flash("Eintrag aktualisiert.", "success")
|
||||
else:
|
||||
flash("Bitte Text und Monat angeben.", "danger")
|
||||
return redirect(url_for("journal.future"))
|
||||
|
||||
return render_template("edit_future.html", entry=entry)
|
||||
|
||||
|
||||
@journal_bp.route("/future/<int:entry_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_future(entry_id: int):
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM future_log WHERE id = %s AND user_id = %s",
|
||||
(entry_id, _user_id()),
|
||||
)
|
||||
flash("Eintrag aus dem Future Log gelöscht.", "success")
|
||||
return redirect(url_for("journal.future"))
|
||||
|
||||
|
||||
@journal_bp.route("/tag/<date_str>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def day(date_str: str):
|
||||
|
||||
Reference in New Issue
Block a user