diff --git a/CloudronManifest.json b/CloudronManifest.json index 65378e9..88f590c 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,7 +4,7 @@ "author": "hnzio ", "description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.", "tagline": "Haushalt mit Liga-Gefühl", - "version": "0.5.1", + "version": "0.6.0", "manifestVersion": 2, "healthCheckPath": "/healthz", "httpPort": 8000, diff --git a/README.md b/README.md index c68d18f..31e3fba 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,13 @@ Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Ne ## Release Notes +### 0.6.0 + +- Persönlicher read-only ICS-Feed pro Nutzer für externe Kalender ergänzt +- Export enthält nur eigene zugewiesene Aufgaben mit Titel, Beschreibung und Fälligkeitsdatum +- Kalender-Link in den Optionen sichtbar gemacht und Token-Neugenerierung ergänzt +- Cloudron-Version auf `0.6.0` angehoben + ### 0.5.1 - Footer mit automatischer Versionsanzeige und Links zu Releases und hnz.io diff --git a/app/models.py b/app/models.py index dc9dbc3..ddafcbb 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +import secrets from datetime import UTC, date, datetime, timedelta from flask_login import UserMixin @@ -24,6 +25,7 @@ class User(UserMixin, TimestampMixin, db.Model): password_hash = db.Column(db.String(255), nullable=False) is_admin = db.Column(db.Boolean, nullable=False, default=False) avatar_path = db.Column(db.String(255), nullable=True) + calendar_feed_token = db.Column(db.String(255), nullable=True, unique=True, index=True) notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True) notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True) @@ -60,6 +62,11 @@ class User(UserMixin, TimestampMixin, db.Model): def check_password(self, password: str) -> bool: return check_password_hash(self.password_hash, password) + def ensure_calendar_feed_token(self) -> str: + if not self.calendar_feed_token: + self.calendar_feed_token = secrets.token_urlsafe(32) + return self.calendar_feed_token + @property def display_avatar(self) -> str: return self.avatar_path or "images/avatars/default.svg" diff --git a/app/routes/main.py b/app/routes/main.py index 87a7101..8d4570e 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -3,9 +3,11 @@ 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 import Blueprint, Response, current_app, redirect, send_from_directory, url_for from flask_login import current_user +from ..models import User +from ..services.calendar_feeds import build_calendar_feed bp = Blueprint("main", __name__) @@ -39,6 +41,16 @@ def uploads(filename: str): return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename) +@bp.route("/calendar-feed/.ics") +def calendar_feed(token: str): + user = User.query.filter_by(calendar_feed_token=token).first_or_404() + body = build_calendar_feed(user, url_for("tasks.my_tasks", _external=True)) + response = Response(body, content_type="text/calendar; charset=utf-8") + response.headers["Content-Disposition"] = 'inline; filename="putzliga.ics"' + response.headers["Cache-Control"] = "private, max-age=300" + return response + + @lru_cache(maxsize=64) def load_icon_svg(name: str, static_folder: str) -> str: path = Path(static_folder) / "icons" / f"{name}.svg" diff --git a/app/routes/settings.py b/app/routes/settings.py index 93b7430..7beac44 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -45,6 +45,7 @@ def _save_avatar(file_storage) -> str: @bp.route("", methods=["GET", "POST"]) @login_required def index(): + current_user.ensure_calendar_feed_token() form = SettingsProfileForm(original_email=current_user.email, obj=current_user) admin_form = AdminUserForm(prefix="admin") quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig") @@ -80,6 +81,7 @@ def index(): quick_task_config=quick_task_config, users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(), earned_badges=earned_badges_for_user(current_user.id), + calendar_feed_url=url_for("main.calendar_feed", token=current_user.calendar_feed_token, _external=True), push_ready=push_enabled(), has_subscription=bool(subscriptions), settings_tabs=_settings_tabs(), @@ -87,6 +89,16 @@ def index(): ) +@bp.route("/calendar-feed/regenerate", methods=["POST"]) +@login_required +def regenerate_calendar_feed(): + current_user.calendar_feed_token = None + current_user.ensure_calendar_feed_token() + db.session.commit() + flash("Dein persönlicher Kalender-Link wurde neu erzeugt.", "success") + return redirect(url_for("settings.index")) + + @bp.route("/badges") @login_required def badges(): diff --git a/app/services/bootstrap.py b/app/services/bootstrap.py index 28028b6..532cd36 100644 --- a/app/services/bootstrap.py +++ b/app/services/bootstrap.py @@ -17,8 +17,18 @@ def ensure_schema_and_admins() -> None: db.session.execute(text("ALTER TABLE user ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0")) db.session.commit() + if "calendar_feed_token" not in column_names: + db.session.execute(text("ALTER TABLE user ADD COLUMN calendar_feed_token VARCHAR(255)")) + db.session.commit() + ensure_app_settings() + users_without_feed = User.query.filter(User.calendar_feed_token.is_(None)).all() + if users_without_feed: + for user in users_without_feed: + user.ensure_calendar_feed_token() + db.session.commit() + admin_exists = User.query.filter_by(is_admin=True).first() if admin_exists: return diff --git a/app/services/calendar_feeds.py b/app/services/calendar_feeds.py new file mode 100644 index 0000000..4a116b3 --- /dev/null +++ b/app/services/calendar_feeds.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from datetime import UTC, datetime, time, timedelta + +from ..models import TaskInstance, User + + +def _ics_escape(value: str | None) -> str: + text = (value or "").replace("\\", "\\\\") + text = text.replace(";", "\\;").replace(",", "\\,") + text = text.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n") + return text + + +def _format_date(value) -> str: + return value.strftime("%Y%m%d") + + +def _format_timestamp(value: datetime | None) -> str: + timestamp = value or datetime.now(UTC).replace(tzinfo=None) + return timestamp.strftime("%Y%m%dT%H%M%SZ") + + +def build_calendar_feed(user: User, base_url: str) -> str: + tasks = ( + TaskInstance.query.filter_by(assigned_user_id=user.id) + .order_by(TaskInstance.due_date.asc(), TaskInstance.id.asc()) + .all() + ) + + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//hnz.io//Putzliga//DE", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + f"X-WR-CALNAME:{_ics_escape(f'Putzliga - {user.name}')}", + f"X-WR-CALDESC:{_ics_escape('Persoenlicher Aufgabenfeed aus Putzliga')}", + ] + + for task in tasks: + due_end = task.due_date + timedelta(days=1) + description = _ics_escape(task.description) + lines.extend( + [ + "BEGIN:VEVENT", + f"UID:taskinstance-{task.id}@putzliga", + f"DTSTAMP:{_format_timestamp(task.updated_at)}", + f"LAST-MODIFIED:{_format_timestamp(task.updated_at)}", + f"SUMMARY:{_ics_escape(task.title)}", + f"DESCRIPTION:{description}", + f"DTSTART;VALUE=DATE:{_format_date(task.due_date)}", + f"DTEND;VALUE=DATE:{_format_date(due_end)}", + f"URL:{_ics_escape(base_url)}", + "END:VEVENT", + ] + ) + + lines.append("END:VCALENDAR") + return "\r\n".join(lines) + "\r\n" diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html index 0af9685..5db99a7 100644 --- a/app/templates/settings/index.html +++ b/app/templates/settings/index.html @@ -95,6 +95,25 @@ {% endif %} +
+

Kalender-Abo

+

Persönlicher ICS-Feed

+

Dieser Read-only-Link zeigt in externen Kalendern nur Aufgaben an, die dir zugewiesen sind. Exportiert werden nur Titel, Beschreibung und Fälligkeitsdatum.

+
+
+ + +
+
+ ICS öffnen +
+ + +
+
+
+
+ {% if current_user.is_admin %}

Admin