feat: add personal ics feeds for assigned tasks

This commit is contained in:
2026-04-13 11:34:14 +02:00
parent 96b979a878
commit 07ab0461e9
8 changed files with 129 additions and 2 deletions

View File

@@ -4,7 +4,7 @@
"author": "hnzio <mail@example.com>", "author": "hnzio <mail@example.com>",
"description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.", "description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.",
"tagline": "Haushalt mit Liga-Gefühl", "tagline": "Haushalt mit Liga-Gefühl",
"version": "0.5.1", "version": "0.6.0",
"manifestVersion": 2, "manifestVersion": 2,
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 8000, "httpPort": 8000,

View File

@@ -343,6 +343,13 @@ Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Ne
## Release Notes ## 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 ### 0.5.1
- Footer mit automatischer Versionsanzeige und Links zu Releases und hnz.io - Footer mit automatischer Versionsanzeige und Links zu Releases und hnz.io

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import secrets
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from flask_login import UserMixin from flask_login import UserMixin
@@ -24,6 +25,7 @@ class User(UserMixin, TimestampMixin, db.Model):
password_hash = db.Column(db.String(255), nullable=False) password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, nullable=False, default=False) is_admin = db.Column(db.Boolean, nullable=False, default=False)
avatar_path = db.Column(db.String(255), nullable=True) 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_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True)
notification_monthly_winner_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: def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password) 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 @property
def display_avatar(self) -> str: def display_avatar(self) -> str:
return self.avatar_path or "images/avatars/default.svg" return self.avatar_path or "images/avatars/default.svg"

View File

@@ -3,9 +3,11 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from pathlib import Path 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 flask_login import current_user
from ..models import User
from ..services.calendar_feeds import build_calendar_feed
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
@@ -39,6 +41,16 @@ def uploads(filename: str):
return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename) return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename)
@bp.route("/calendar-feed/<token>.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) @lru_cache(maxsize=64)
def load_icon_svg(name: str, static_folder: str) -> str: def load_icon_svg(name: str, static_folder: str) -> str:
path = Path(static_folder) / "icons" / f"{name}.svg" path = Path(static_folder) / "icons" / f"{name}.svg"

View File

@@ -45,6 +45,7 @@ def _save_avatar(file_storage) -> str:
@bp.route("", methods=["GET", "POST"]) @bp.route("", methods=["GET", "POST"])
@login_required @login_required
def index(): def index():
current_user.ensure_calendar_feed_token()
form = SettingsProfileForm(original_email=current_user.email, obj=current_user) form = SettingsProfileForm(original_email=current_user.email, obj=current_user)
admin_form = AdminUserForm(prefix="admin") admin_form = AdminUserForm(prefix="admin")
quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig") quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig")
@@ -80,6 +81,7 @@ def index():
quick_task_config=quick_task_config, quick_task_config=quick_task_config,
users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(), users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(),
earned_badges=earned_badges_for_user(current_user.id), 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(), push_ready=push_enabled(),
has_subscription=bool(subscriptions), has_subscription=bool(subscriptions),
settings_tabs=_settings_tabs(), 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") @bp.route("/badges")
@login_required @login_required
def badges(): def badges():

View File

@@ -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.execute(text("ALTER TABLE user ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit() 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() 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() admin_exists = User.query.filter_by(is_admin=True).first()
if admin_exists: if admin_exists:
return return

View File

@@ -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"

View File

@@ -95,6 +95,25 @@
{% endif %} {% endif %}
</section> </section>
<section class="panel">
<p class="eyebrow">Kalender-Abo</p>
<h2>Persönlicher ICS-Feed</h2>
<p class="muted">Dieser Read-only-Link zeigt in externen Kalendern nur Aufgaben an, die dir zugewiesen sind. Exportiert werden nur Titel, Beschreibung und Fälligkeitsdatum.</p>
<div class="push-box">
<div class="field">
<label for="calendarFeedUrl">Persönliche Kalender-URL</label>
<input id="calendarFeedUrl" type="text" value="{{ calendar_feed_url }}" readonly>
</div>
<div class="form-actions">
<a class="button button--secondary" href="{{ calendar_feed_url }}" target="_blank" rel="noreferrer">ICS öffnen</a>
<form method="post" action="{{ url_for('settings.regenerate_calendar_feed') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="button button--ghost">Link neu erzeugen</button>
</form>
</div>
</div>
</section>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<section class="panel"> <section class="panel">
<p class="eyebrow">Admin</p> <p class="eyebrow">Admin</p>