From f4985b7fbc31db9f324cb890fa737400c04eb4e7 Mon Sep 17 00:00:00 2001 From: Joao Carreira Date: Tue, 6 Oct 2026 15:36:33 +0100 Subject: [PATCH] feat: export calendar and backup db --- .env.example | 8 + README.md | 2 + app/bootstrap.py | 11 +- app/calendar.py | 119 +++++++++++++++ app/config.py | 4 + app/database.py | 45 ++++++ app/i18n.py | 7 +- app/main.py | 10 ++ app/models.py | 8 +- app/static/app.css | 1 + app/templates/admin_assign.html | 2 +- app/templates/admin_schedule.html | 4 +- app/templates/admin_schedules.html | 8 +- app/templates/calendar.html | 43 ++++++ app/templates/dashboard.html | 5 +- app/templates/schedule.html | 2 +- app/templates/schedules.html | 2 +- app/web.py | 88 ++++++++++- docs/DOMAIN.md | 14 +- ...b_schedule_times_and_personal_calendar_.py | 81 ++++++++++ tests/test_admin_schedule_assignments.py | 3 - tests/test_backup.py | 82 ++++++++++ tests/test_bootstrap.py | 19 ++- tests/test_calendar.py | 144 ++++++++++++++++++ tests/test_migrations.py | 34 +++-- tests/test_public_schedules.py | 7 +- tests/test_scheduling.py | 3 - tests/test_users.py | 2 - 28 files changed, 712 insertions(+), 46 deletions(-) create mode 100644 app/calendar.py create mode 100644 app/templates/calendar.html create mode 100644 migrations/versions/77b487b8a09b_schedule_times_and_personal_calendar_.py create mode 100644 tests/test_backup.py create mode 100644 tests/test_calendar.py diff --git a/.env.example b/.env.example index 2e458ea..6d0fe2c 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,14 @@ SMTP_PASSWORD= SMTP_FROM=bar-rota@example.com SMTP_STARTTLS=true +# Secret token of the shared whole-rota calendar feed (/calendar/.ics). +# Leave empty and it is generated the first time an admin opens the schedules page. +CALENDAR_TOKEN= + +# Where the startup backups of the SQLite file go. Empty means "backups" next to +# the database file. One copy per start, kept forever; names are never reused. +BACKUP_DIR= + # Reminder command looks this many days ahead. REMINDER_DAYS_AHEAD=1 diff --git a/README.md b/README.md index 7077077..9fa32c1 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ To move to another machine, stop the app, copy the **entire `data/` directory** Behind a reverse proxy, set `BASE_URL` to the public address (used in e-mail links), `ROOT_PATH` to the prefix the app is served under when it is not at the domain root (for example `/crr` for `https://host/crr/`), and `SESSION_HTTPS_ONLY=true` when the proxy terminates TLS. Uvicorn runs with `--proxy-headers --forwarded-allow-ips '*'`, so `X-Forwarded-Proto`/`X-Forwarded-Host` set the scheme and host used in generated URLs and cookies; the proxy must forward them and keep the prefix it advertises. +On every start the application writes a copy of the SQLite file to `backups/` beside the database (override with `BACKUP_DIR`), named `rota-.db` and never overwriting an older copy. Nothing is deleted, so the folder grows by one file per start; prune it yourself if that becomes too much. + `DATA_DIR` changes the Docker host directory mounted at `/app/data`. Keep `DATABASE_URL=sqlite:///./data/bar_rota.db` in Docker: this path is relative to `/app` inside the container. For seamless switching between host and Docker, use the default `DATA_DIR=./data`; a custom directory requires a matching host `DATABASE_URL` when running locally. The port for Make development commands is selected with `PORT=...`; direct Compose commands use `.env`'s `PORT`. Update `BASE_URL` when changing the URL used in notification links. ## Email diff --git a/app/bootstrap.py b/app/bootstrap.py index 2421a7f..c4bd792 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import date +from datetime import date, time from sqlalchemy import select from sqlalchemy.orm import Session @@ -32,7 +32,8 @@ def ensure_initial_data(db: Session) -> None: "slug": "night", "schedule_type": "fixed", "weekdays": "0,1,2,3,4,5,6", - "label_time": "20:30–00:00", + "start_time": time(20, 30), + "end_time": time(0, 0), "rotation_anchor_date": None, }, { @@ -40,7 +41,8 @@ def ensure_initial_data(db: Session) -> None: "slug": "saturday-lunch", "schedule_type": "rotation", "weekdays": "5", - "label_time": "13:00–16:00", + "start_time": time(13, 0), + "end_time": time(16, 0), "rotation_anchor_date": date(2024, 1, 6), }, { @@ -48,7 +50,8 @@ def ensure_initial_data(db: Session) -> None: "slug": "sunday-lunch", "schedule_type": "rotation", "weekdays": "6", - "label_time": "13:00–16:00", + "start_time": time(13, 0), + "end_time": time(16, 0), "rotation_anchor_date": date(2024, 1, 7), }, ] diff --git a/app/calendar.py b/app/calendar.py new file mode 100644 index 0000000..73a5ef1 --- /dev/null +++ b/app/calendar.py @@ -0,0 +1,119 @@ +"""iCalendar feed of the rota, so members can subscribe from any calendar app. + +Events use floating local times (`DTSTART:20261017T203000`): the rota is read in +the bar's own timezone, and every client resolves a floating time against the +device it is viewed on, which keeps the feed free of a timezone database. +""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from .config import settings +from .i18n import day_long, day_numeric_long, window_label +from .models import Assignment, Schedule, User + +# How much of the rota the feed publishes: recent history and the next months. +MONTHS_BACK = 1 +MONTHS_FORWARD = 6 + + +def _stamp(moment: datetime) -> str: + return moment.strftime("%Y%m%dT%H%M%S") + + +def _fold(line: str) -> list[str]: + """RFC 5545 folds long lines at 75 octets.""" + raw = line.encode("utf-8") + if len(raw) <= 75: + return [line] + chunks = [raw[:75]] + rest = raw[75:] + while rest: + chunks.append(rest[:74]) + rest = rest[74:] + return [chunks[0].decode("utf-8")] + [" " + c.decode("utf-8") for c in chunks[1:]] + + +def _escape(value: str) -> str: + return ( + value.replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\n", "\\n") + ) + + +def _event(assignment: Assignment, person: User | None) -> list[str]: + schedule = assignment.schedule + start = datetime.combine(assignment.date, schedule.start_time) + end = datetime.combine(assignment.date, schedule.end_time) + if end <= start: # a shift that runs past midnight + end += timedelta(days=1) + summary = f"{schedule.name} · {window_label(schedule.start_time, schedule.end_time)}" + description = [ + day_long(assignment.date), + f"{schedule.start_time:%H:%M}–{schedule.end_time:%H:%M}", + ] + if person is not None: + description.append(f"Pessoa: {person.name}") + description.append(settings.app_name) + return [ + "BEGIN:VEVENT", + f"UID:assignment-{assignment.id}@{_host()}", + f"DTSTAMP:{_stamp(datetime.utcnow())}", + f"DTSTART:{start:%Y%m%dT%H%M%S}", + f"DTEND:{end:%Y%m%dT%H%M%S}", + f"SUMMARY:{_escape(summary)}", + f"DESCRIPTION:{_escape(chr(10).join(description))}", + f"LOCATION:{_escape(settings.app_name)}", + "TRANSP:TRANSPARENT", + "END:VEVENT", + ] + + +def _host() -> str: + return (settings.base_url.split("//")[-1] or "localhost").split("/")[0] + + +def build_feed(db: Session, person: User | None, name: str) -> str: + """The whole rota, or only the shifts of one person.""" + today = date.today() + first = (today.replace(day=1) - timedelta(days=31 * MONTHS_BACK)).replace(day=1) + last = (today.replace(day=1) + timedelta(days=32 * MONTHS_FORWARD)).replace(day=1) - timedelta(days=1) + + schedules = db.scalars(select(Schedule).where(Schedule.is_active.is_(True))).all() + assignments = db.scalars( + select(Assignment) + .where( + Assignment.date >= first, + Assignment.date <= last, + Assignment.schedule_id.in_([s.id for s in schedules]), + ) + .options(selectinload(Assignment.schedule), selectinload(Assignment.user)) + .order_by(Assignment.date) + ).all() + if person is not None: + assignments = [a for a in assignments if a.user_id == person.id] + + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + f"PRODID:-//{_escape(settings.app_name)}//Rota//PT", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + f"X-WR-CALNAME:{_escape(name)}", + f"X-WR-CALDESC:{_escape(f'{settings.app_name} — {day_numeric_long(first)} a {day_numeric_long(last)}')}", + "REFRESH-INTERVAL;VALUE=DURATION:PT12H", + "X-PUBLISHED-TTL:PT12H", + ] + for assignment in assignments: + lines.extend(_event(assignment, assignment.user)) + lines.append("END:VCALENDAR") + folded: list[str] = [] + for line in lines: + folded.extend(_fold(line)) + return "\r\n".join(folded) + "\r\n" diff --git a/app/config.py b/app/config.py index 692baf0..bb33053 100644 --- a/app/config.py +++ b/app/config.py @@ -13,6 +13,10 @@ class Settings(BaseSettings): root_path: str = "" # Networks allowed to set X-Forwarded-*; "*" for a private docker network. trusted_proxy_hosts: str = "*" + # Secret token of the whole-rota calendar feed; every person also has their own. + calendar_token: str = "" + # Where the startup copies of the SQLite file go; empty means "backups" beside it. + backup_dir: str = "" initial_admin_name: str = "Administrador" initial_admin_username: str = "admin" diff --git a/app/database.py b/app/database.py index c27fd5d..7d75590 100644 --- a/app/database.py +++ b/app/database.py @@ -1,7 +1,11 @@ from __future__ import annotations +import shutil +import sqlite3 from collections.abc import Generator +from datetime import datetime from pathlib import Path +from urllib.parse import urlparse from alembic import command from alembic.config import Config @@ -60,3 +64,44 @@ def get_db() -> Generator[Session, None, None]: yield db finally: db.close() + + +def database_file(url: str | None = None) -> Path | None: + """The SQLite file behind DATABASE_URL, or None for another engine.""" + raw = url or settings.database_url + if not raw.startswith("sqlite"): + return None + path = urlparse(raw).path + if not path: + return None + # sqlite:///./data/x.db is relative, sqlite:////srv/x.db is absolute. + if path.startswith("/"): + path = path[1:] + if not path or path.startswith(":memory:"): + return None + return Path(path) + + +def backup_database(destination_dir: str | None = None) -> Path | None: + """Copy the database aside on every start, never overwriting an older copy. + + Uses SQLite's own backup API so the copy is consistent even if another + process is writing. Returns the new file, or None when there is nothing to copy. + """ + source = database_file() + if source is None or not source.is_file(): + return None + + folder = Path(destination_dir or settings.backup_dir or source.parent / "backups") + folder.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + target = folder / f"{source.stem}-{stamp}{source.suffix}" + index = 1 + while target.exists(): # two starts in the same second + target = folder / f"{source.stem}-{stamp}-{index}{source.suffix}" + index += 1 + + with sqlite3.connect(source) as origin, sqlite3.connect(target) as copy: + origin.backup(copy) + shutil.copystat(source, target) + return target diff --git a/app/i18n.py b/app/i18n.py index 25ec4a1..a97d486 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -7,7 +7,7 @@ month names come from the tables below instead of the `calendar` module. from __future__ import annotations -from datetime import date, datetime +from datetime import date, datetime, time MONTHS = ( "Janeiro", @@ -145,3 +145,8 @@ def notification_status_label(status: str) -> str: def event_type_label(event_type: str) -> str: return EVENT_TYPE_LABELS.get(event_type, event_type) + + +def window_label(start: time, end: time) -> str: + """`20:30–00:00`, the hours a shift runs.""" + return f"{start:%H:%M}\u2013{end:%H:%M}" diff --git a/app/main.py b/app/main.py index f466a85..27d02fa 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ from .bootstrap import ensure_initial_data from .config import settings from .database import ( SessionLocal, + backup_database, current_revision, has_tables, run_migrations, @@ -28,6 +29,7 @@ from .i18n import ( source_label, swap_status_label, weekday_name, + window_label, ) from .web import router @@ -36,6 +38,13 @@ BASE_DIR = Path(__file__).resolve().parent @asynccontextmanager async def lifespan(app: FastAPI): + # A copy of the database before anything else runs; never fatal. + try: + backup = backup_database() + if backup is not None: + print(f"Database backup written to {backup}") + except Exception as exc: # a failed backup must never stop the app + print(f"Could not back up the database: {exc}") # Databases created before Alembic existed have no version stamp: adopt them # at the baseline revision so the migrations can continue from there. if has_tables() and current_revision() is None: @@ -70,6 +79,7 @@ templates.env.filters.update( "estado": swap_status_label, "estado_email": notification_status_label, "evento": event_type_label, + "horario": lambda schedule: window_label(schedule.start_time, schedule.end_time), } ) templates.env.globals["app_path"] = settings.url diff --git a/app/models.py b/app/models.py index 7a9f0c6..0cb6f36 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import date, datetime, time from sqlalchemy import ( Boolean, @@ -11,6 +11,7 @@ from sqlalchemy import ( Integer, String, Text, + Time, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -27,6 +28,7 @@ class User(Base): email: Mapped[str | None] = mapped_column(String(254), unique=True, nullable=True) phone: Mapped[str | None] = mapped_column(String(40), nullable=True) password_hash: Mapped[str | None] = mapped_column(String(300), nullable=True) + calendar_token: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True) is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) notify_email: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) @@ -45,7 +47,9 @@ class Schedule(Base): slug: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) schedule_type: Mapped[str] = mapped_column(String(20), nullable=False) # fixed | rotation weekdays: Mapped[str] = mapped_column(String(30), nullable=False) # comma-separated 0..6 - label_time: Mapped[str] = mapped_column(String(80), default="", nullable=False) + # When the shift runs, in the bar's local time. An end at or before the start means the next day. + start_time: Mapped[time] = mapped_column(Time, default=time(20, 30), nullable=False) + end_time: Mapped[time] = mapped_column(Time, default=time(23, 59), nullable=False) requires_manager_approval: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) rotation_anchor_date: Mapped[date | None] = mapped_column(Date, nullable=True) diff --git a/app/static/app.css b/app/static/app.css index 172036f..1c3d35c 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -104,6 +104,7 @@ th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line)} th{font-size:.8rem;text-transform:uppercase;color:var(--muted);background:#fff7f7} .sticky-actions{position:sticky;bottom:10px;padding:15px 0;text-align:right} .inline-form select{width:auto;min-width:190px} +.inline-form input[type=text]{min-width:260px;flex:1} @media(max-width:700px){ .topbar-inner{padding:8px 0} .brand-logo{width:40px;height:36px} diff --git a/app/templates/admin_assign.html b/app/templates/admin_assign.html index 4d058fe..ade5026 100644 --- a/app/templates/admin_assign.html +++ b/app/templates/admin_assign.html @@ -27,7 +27,7 @@
{{ row.schedule.name }} - {% if row.schedule.label_time %} · {{ row.schedule.label_time }}{% endif %} + · {{ row.schedule|horario }}
{% if row.assignment.user %} Agora: {{ row.assignment.user.name }} ({{ row.assignment.source|origem }}) diff --git a/app/templates/admin_schedule.html b/app/templates/admin_schedule.html index e3c4674..8a95196 100644 --- a/app/templates/admin_schedule.html +++ b/app/templates/admin_schedule.html @@ -5,10 +5,12 @@

Escalas

{{ schedule.name }}

-

{{ card.kind_label }}{% if schedule.label_time %} · {{ schedule.label_time }}{% endif %}

+

{{ card.kind_label }} · {{ schedule|horario }}

+ + diff --git a/app/templates/admin_schedules.html b/app/templates/admin_schedules.html index 6c7e301..e303327 100644 --- a/app/templates/admin_schedules.html +++ b/app/templates/admin_schedules.html @@ -7,6 +7,12 @@
Atribuir por dia
+
+

Calendário da rota

+

Um link para partilhar com a equipa, com os turnos de toda a gente, para adicionar a qualquer calendário. Cada pessoa tem também o seu feed só com os turnos dela.

+

{{ calendar_feed_url }}

+ Subscrever o calendário do bar +
{% for card in cards %} {% set schedule = card.schedule %} @@ -14,7 +20,7 @@

{{ schedule.name }}

-

{{ card.kind_label }}{% if schedule.label_time %} · {{ schedule.label_time }}{% endif %}{% if not schedule.is_active %} · inativa{% endif %}

+

{{ card.kind_label }} · {{ schedule|horario }}{% if not schedule.is_active %} · inativa{% endif %}

Configurar
diff --git a/app/templates/calendar.html b/app/templates/calendar.html new file mode 100644 index 0000000..d8fa30a --- /dev/null +++ b/app/templates/calendar.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Calendário · {{ settings.app_name }}{% endblock %} +{% block content %} +
+
+

Rota

+

Calendário

+

Adiciona o endereço ao Google Calendar, Apple Calendar ou Outlook. O calendário atualiza-se sozinho, não é preciso repetir o processo.

+
+
+ +
+{% for feed in feeds %} +
+

{{ feed.title }}

+
+ + + Subscrever +
+
+{% endfor %} +
+ + +{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index ec1aab5..5bd7a1a 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -5,7 +5,10 @@

{{ month_name }} {{ year }}

- {% if current_user %}Pedir troca{% endif %} + {% if current_user %} + Calendário + Pedir troca + {% endif %} ← Anterior Seguinte →
diff --git a/app/templates/schedule.html b/app/templates/schedule.html index 2c5ec97..797d539 100644 --- a/app/templates/schedule.html +++ b/app/templates/schedule.html @@ -6,7 +6,7 @@

Escalas

{{ schedule.name }}

-

{{ card.kind_label }}{% if schedule.label_time %} · {{ schedule.label_time }}{% endif %}

+

{{ card.kind_label }} · {{ schedule|horario }}

diff --git a/app/templates/schedules.html b/app/templates/schedules.html index 80cab05..a6885c2 100644 --- a/app/templates/schedules.html +++ b/app/templates/schedules.html @@ -8,7 +8,7 @@

{{ card.schedule.name }}

-

{{ card.kind_label }}{% if card.schedule.label_time %} · {{ card.schedule.label_time }}{% endif %}

+

{{ card.kind_label }} · {{ card.schedule|horario }}

Abrir escala
diff --git a/app/web.py b/app/web.py index 8d5b934..d95df2f 100644 --- a/app/web.py +++ b/app/web.py @@ -2,13 +2,15 @@ from __future__ import annotations import calendar from collections.abc import Sequence -from datetime import date, datetime, timedelta +import secrets +from datetime import date, datetime, time, timedelta from fastapi import APIRouter, Depends, Form, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse, RedirectResponse, Response from sqlalchemy import delete, func, select from sqlalchemy.orm import Session, selectinload +from .calendar import build_feed from .config import settings from .database import get_db from .models import ( @@ -26,6 +28,7 @@ from .i18n import ( day_short, month_name, weekday_names, + window_label, ) from .notifications import send_email_notification, send_many from .scheduling import assignments_by_date, ensure_month_assignments, month_bounds, rotation_user_for_date @@ -38,6 +41,13 @@ def _redirect(path: str, status_code: int = 303) -> RedirectResponse: return RedirectResponse(settings.url(path), status_code=status_code) +def _parse_time(value: str) -> time: + try: + return time.fromisoformat(value.strip()) + except ValueError: + raise HTTPException(status_code=400, detail="Hora inválida") + + def _current_user(request: Request, db: Session) -> User | None: user_id = request.session.get("user_id") if not user_id: @@ -192,6 +202,60 @@ def _month_nav(year: int, month: int) -> tuple[tuple[int, int], tuple[int, int]] return prev, nxt +def _calendar_token(db: Session, user: User) -> str: + """A private feed token for this person, created the first time it is needed.""" + if not user.calendar_token: + user.calendar_token = secrets.token_urlsafe(24) + db.commit() + return user.calendar_token + + +@router.get("/calendar/{token}.ics") +def calendar_feed( + token: str, + db: Session = Depends(get_db), +): + """Subscribe from any calendar app: one feed per person, plus a shared whole-rota feed.""" + person = db.scalar(select(User).where(User.calendar_token == token, User.is_active.is_(True))) + shared = bool(settings.calendar_token) and token == settings.calendar_token + if person is None and not shared: + raise HTTPException(status_code=404, detail="Calendário não encontrado") + name = f"{settings.app_name} — {person.name}" if person else settings.app_name + return Response( + content=build_feed(db, person, name), + media_type="text/calendar; charset=utf-8", + headers={ + "Content-Disposition": 'inline; filename="rota.ics"', + "Cache-Control": "private, max-age=300", + }, + ) + + +@router.get("/calendar", response_class=HTMLResponse) +def calendar_page(request: Request, db: Session = Depends(get_db)): + """The links to subscribe, kept off the rota so that page stays about the shifts.""" + user = _require_user(request, db) + feeds = [ + { + "title": "Os meus turnos", + "url": f"{request.base_url.scheme}://{request.base_url.netloc}" + f"{settings.url(f'/calendar/{_calendar_token(db, user)}.ics')}", + } + ] + if user.is_admin: + feeds.append( + { + "title": "Rota completa", + "url": _shared_calendar_url(request), + } + ) + return request.app.state.templates.TemplateResponse( + request=request, + name="calendar.html", + context=_context(request, db, feeds=feeds), + ) + + @router.get("/health") def health(): return {"status": "ok"} @@ -1156,10 +1220,23 @@ def admin_schedules(request: Request, db: Session = Depends(get_db)): return request.app.state.templates.TemplateResponse( request=request, name="admin_schedules.html", - context=_context(request, db, cards=cards), + context=_context( + request, + db, + cards=cards, + calendar_feed_url=_shared_calendar_url(request), + ), ) +def _shared_calendar_url(request: Request) -> str: + """The whole-rota feed, with a team token created the first time it is asked for.""" + if not settings.calendar_token: + settings.calendar_token = secrets.token_urlsafe(24) + path = settings.url(f"/calendar/{settings.calendar_token}.ics") + return f"{request.base_url.scheme}://{request.base_url.netloc}{path}" + + @router.get("/admin/schedules/{schedule_id}", response_class=HTMLResponse) def admin_schedule( schedule_id: int, @@ -1314,6 +1391,8 @@ def admin_schedule_settings( request: Request, requires_manager_approval: str = Form(""), is_active: str = Form(""), + start_time: str = Form(""), + end_time: str = Form(""), csrf_token: str = Form(...), db: Session = Depends(get_db), ): @@ -1322,6 +1401,9 @@ def admin_schedule_settings( schedule = db.get(Schedule, schedule_id) if not schedule: raise HTTPException(status_code=404, detail="Escala não encontrada") + if start_time and end_time: + schedule.start_time = _parse_time(start_time) + schedule.end_time = _parse_time(end_time) schedule.requires_manager_approval = bool(requires_manager_approval) schedule.is_active = bool(is_active) db.commit() diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md index bf283f9..99108e0 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -9,7 +9,19 @@ This distinction is intentional: editing a rotation must not retroactively chang ## Seeded schedules Seeded names are Portuguese (`Noite`, `Almoço - Sábados`, `Almoço - Domingo`); the slugs stay `night`, `saturday-lunch` and `sunday-lunch`. -The seeded `label_time` carries the hours the shift runs, shown next to the schedule name: `20:30–00:00` for the night and `13:00–16:00` for both lunches. Both migrations are guarded by slug and previous value, so a label an administrator chose is never overwritten. +Each schedule carries `start_time`/`end_time`, the hours the shift runs, editable by an administrator on its configuration page: `20:30–00:00` for the night and `13:00–16:00` for both lunches. An end at or before the start means the shift runs into the next day. + +## Backups + +Every start copies the SQLite file aside before anything else runs, using SQLite's own backup API so the copy is consistent. Files go to `backups/` next to the database (or `BACKUP_DIR`), are named `rota-YYYYmmdd-HHMMSS.db` and are never overwritten: two starts in the same second increment the name. Nothing is copied for another engine or an in-memory database, and a failed backup only logs a warning instead of stopping the app. Nothing is ever pruned, so the folder grows one file per start. + +## Calendar feeds + +- `/calendar/.ics` is an iCalendar feed anybody can subscribe to from Google Calendar, Apple Calendar or Outlook. Every person has their own token, created the first time they open `/calendar`, and their feed only carries the shifts assigned to them. +- `/calendar` is the page holding those links: the rota only has a button pointing there, so it keeps showing nothing but who works each day. Each address sits in a readonly field with a **Copiar** button (clipboard when the browser allows it, selecting the field otherwise) plus a `webcal://` shortcut. +- Administrators also get one shared feed with the whole rota, using the team token in `CALENDAR_TOKEN` (generated on first use and shown on `/admin/schedules`). +- A feed publishes one month back and six months forward, with floating local times so no timezone database is needed. Times and names are private member data: the token is the only protection, so the shared link goes only to the team. +- An unknown token returns 404, and a deactivated person's token stops working. ### Night (`Noite`) diff --git a/migrations/versions/77b487b8a09b_schedule_times_and_personal_calendar_.py b/migrations/versions/77b487b8a09b_schedule_times_and_personal_calendar_.py new file mode 100644 index 0000000..5d24b85 --- /dev/null +++ b/migrations/versions/77b487b8a09b_schedule_times_and_personal_calendar_.py @@ -0,0 +1,81 @@ +"""schedule times and personal calendar tokens + +Revision ID: 77b487b8a09b +Revises: 258f14bb3fcc +Create Date: 2026-10-06 13:02:11.884512 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '77b487b8a09b' +down_revision: Union[str, Sequence[str], None] = '258f14bb3fcc' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# slug -> hours taken from the text label this replaces +TIMES = { + "night": ("20:30", "00:00"), + "saturday-lunch": ("13:00", "16:00"), + "sunday-lunch": ("13:00", "16:00"), +} + +DEFAULT_LABEL = {"night": "Noite", "saturday-lunch": "Almoço", "sunday-lunch": "Almoço"} + + +def upgrade() -> None: + """Real hours per schedule, read from the label when it still holds them.""" + connection = op.get_bind() + with op.batch_alter_table('schedules', schema=None) as batch_op: + batch_op.add_column(sa.Column('start_time', sa.Time(), nullable=True)) + batch_op.add_column(sa.Column('end_time', sa.Time(), nullable=True)) + + for slug, (start, end) in TIMES.items(): + row = connection.execute( + sa.text("SELECT label_time FROM schedules WHERE slug = :slug"), {"slug": slug} + ).fetchone() + label = row[0] if row else "" + if label and "–" in label: + left, _, right = label.partition("–") + start, end = left.strip(), right.strip() + connection.execute( + sa.text("UPDATE schedules SET start_time = :start, end_time = :end WHERE slug = :slug"), + {"start": start, "end": end, "slug": slug}, + ) + connection.execute(sa.text("UPDATE schedules SET start_time = '20:30', end_time = '23:59' WHERE start_time IS NULL")) + connection.execute(sa.text("UPDATE schedules SET end_time = '23:59' WHERE end_time IS NULL")) + + with op.batch_alter_table('schedules', schema=None) as batch_op: + batch_op.alter_column('start_time', nullable=False) + batch_op.alter_column('end_time', nullable=False) + batch_op.drop_column('label_time') + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('calendar_token', sa.String(length=64), nullable=True)) + batch_op.create_unique_constraint('uq_users_calendar_token', ['calendar_token']) + + +def downgrade() -> None: + """Put the text label back, built from the hours the schedule runs.""" + connection = op.get_bind() + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_constraint('uq_users_calendar_token', type_='unique') + batch_op.drop_column('calendar_token') + + with op.batch_alter_table('schedules', schema=None) as batch_op: + batch_op.add_column(sa.Column('label_time', sa.VARCHAR(length=80), nullable=True)) + connection.execute( + sa.text( + "UPDATE schedules SET label_time = " + "CASE WHEN slug = 'night' THEN 'Noite' ELSE 'Almoço' END" + ) + ) + with op.batch_alter_table('schedules', schema=None) as batch_op: + batch_op.alter_column('label_time', nullable=False) + batch_op.drop_column('end_time') + batch_op.drop_column('start_time') diff --git a/tests/test_admin_schedule_assignments.py b/tests/test_admin_schedule_assignments.py index 26efd41..a91556b 100644 --- a/tests/test_admin_schedule_assignments.py +++ b/tests/test_admin_schedule_assignments.py @@ -49,7 +49,6 @@ def seed(db): slug="night", schedule_type="fixed", weekdays="0,1,2,3,4,5,6", - label_time="Noite", requires_manager_approval=True, is_active=True, ) @@ -58,7 +57,6 @@ def seed(db): slug="saturday-lunch", schedule_type="rotation", weekdays="5", - label_time="Lunch", requires_manager_approval=True, is_active=True, rotation_anchor_date=date(2026, 1, 3), @@ -68,7 +66,6 @@ def seed(db): slug="sunday-lunch", schedule_type="rotation", weekdays="6", - label_time="Lunch", requires_manager_approval=True, is_active=True, rotation_anchor_date=date(2026, 1, 4), diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..748f08b --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,82 @@ +import sqlite3 +from datetime import datetime + +from app.database import backup_database, database_file + + +def _touch(path, rows: int = 1) -> None: + with sqlite3.connect(path) as connection: + for index in range(rows): + connection.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)") + connection.execute("INSERT INTO notes (body) VALUES (?)", (f"{index}",)) + connection.commit() + + +def test_a_backup_is_written_beside_the_database(tmp_path, monkeypatch): + db_file = tmp_path / "rota.db" + _touch(db_file, 2) + monkeypatch.setattr("app.config.settings.database_url", f"sqlite:///{db_file}") + + backup = backup_database() + + assert backup is not None + assert backup.parent == tmp_path / "backups" + assert backup.name.startswith("rota-") + assert backup.name.endswith(".db") + with sqlite3.connect(backup) as connection: + assert connection.execute("SELECT count(*) FROM notes").fetchone()[0] == 2 + + +def test_backups_never_overwrite_each_other(tmp_path, monkeypatch): + db_file = tmp_path / "rota.db" + _touch(db_file) + monkeypatch.setattr("app.config.settings.database_url", f"sqlite:///{db_file}") + + first = backup_database() + second = backup_database() + + assert first != second + assert first.exists() and second.exists() + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + assert first.stem.startswith(f"rota-{stamp}") + # same second: the name is incremented rather than reused + assert second.name in {f"rota-{stamp}-1.db", f"rota-{stamp}-2.db"} + + +def test_the_backup_directory_can_be_chosen(tmp_path, monkeypatch): + db_file = tmp_path / "rota.db" + _touch(db_file) + target = tmp_path / "copias" + monkeypatch.setattr("app.config.settings.database_url", f"sqlite:///{db_file}") + + backup = backup_database(target) + + assert backup.parent == target + + +def test_nothing_is_copied_without_a_database_file(tmp_path, monkeypatch): + monkeypatch.setattr("app.config.settings.database_url", f"sqlite:///{tmp_path}/nao-existe.db") + assert backup_database() is None + assert database_file() == tmp_path / "nao-existe.db" + assert not (tmp_path / "backups").exists() + + monkeypatch.setattr("app.config.settings.database_url", "sqlite:///:memory:") + assert database_file() is None + assert backup_database() is None + + monkeypatch.setattr("app.config.settings.database_url", "postgresql://localhost/rota") + assert database_file() is None + assert backup_database() is None + + +def test_the_copy_keeps_the_data_of_that_moment(tmp_path, monkeypatch): + db_file = tmp_path / "rota.db" + _touch(db_file, 1) + monkeypatch.setattr("app.config.settings.database_url", f"sqlite:///{db_file}") + + backup = backup_database() + assert backup is not None + _touch(db_file, 3) # the app carries on being written to + + with sqlite3.connect(backup) as connection: + assert connection.execute("SELECT count(*) FROM notes").fetchone()[0] == 1 diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1579ce7..d110dd5 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1,3 +1,5 @@ +from datetime import time + from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool @@ -30,14 +32,19 @@ def test_seeded_schedules_are_named_in_portuguese(monkeypatch): assert names["sunday-lunch"] == "Almoço - Domingo" -def test_seeded_schedules_show_their_service_window(monkeypatch): +def test_seeded_schedules_run_at_the_bar_hours(monkeypatch): engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(engine) with Session(engine) as db: ensure_initial_data(db) - windows = dict(db.execute(select(Schedule.slug, Schedule.label_time)).all()) - assert windows == { - "night": "20:30–00:00", - "saturday-lunch": "13:00–16:00", - "sunday-lunch": "13:00–16:00", + windows = { + slug: (start, end) + for slug, start, end in db.execute( + select(Schedule.slug, Schedule.start_time, Schedule.end_time) + ).all() + } + assert windows == { + "night": (time(20, 30), time(0, 0)), + "saturday-lunch": (time(13, 0), time(16, 0)), + "sunday-lunch": (time(13, 0), time(16, 0)), } diff --git a/tests/test_calendar.py b/tests/test_calendar.py new file mode 100644 index 0000000..6de0bd9 --- /dev/null +++ b/tests/test_calendar.py @@ -0,0 +1,144 @@ +from datetime import date, time, timedelta + +from sqlalchemy import select + +from app.calendar import build_feed +from app.models import Assignment, Schedule, User +# the fixtures live next door and are shared by importing them here +from app.security import hash_password +from test_admin_schedule_assignments import CSRF_RE, client, db, seed, sign_in # noqa: F401 + + +def unfold(feed: str) -> str: + """Undo RFC 5545 line folding so assertions can read whole lines.""" + return feed.replace("\r\n ", "") + + +def _hours(db, schedule: Schedule, start: str, end: str) -> Schedule: + schedule.start_time = time.fromisoformat(start) + schedule.end_time = time.fromisoformat(end) + db.commit() + return schedule + + +def _shift(db, schedule: Schedule, day: date, user: User) -> Assignment: + assignment = Assignment(schedule_id=schedule.id, date=day, user_id=user.id, source="manual") + db.add(assignment) + db.commit() + db.refresh(assignment) + return assignment + + +def test_the_personal_feed_only_carries_that_person_shifts(client, db): + night, sat, sun, ana, bruno, admin = seed(db) + _hours(db, night, "20:30", "00:00") + _hours(db, sat, "13:00", "16:00") + today = date.today() + _shift(db, night, today, ana) + _shift(db, sat, today, bruno) + + feed = build_feed(db, ana, "Rota — Ana") + flat = unfold(feed) + + assert "BEGIN:VCALENDAR" in feed and feed.endswith("END:VCALENDAR\r\n") + assert "SUMMARY:Noite · 20:30–00:00" in flat + assert "SUMMARY:Almoço - Sábados · 13:00–16:00" not in flat # Bruno's shift + assert "Pessoa: Ana" in flat + assert "Bruno" not in flat + assert f"DTSTART:{today:%Y%m%d}T203000" in flat + # 20:30–00:00 ends after midnight, so the night ends the next morning + assert f"DTEND:{(today + timedelta(days=1)):%Y%m%d}T000000" in flat + + +def test_a_shift_past_midnight_ends_on_the_next_day(client, db): + night, _, _, ana, _, _ = seed(db) + night.start_time = time(20, 30) + night.end_time = time(0, 0) + db.commit() + _shift(db, night, date(2026, 11, 7), ana) + + feed = unfold(build_feed(db, ana, "Rota — Ana")) + + assert "DTSTART:20261107T203000" in feed + assert "DTEND:20261108T000000" in feed + + +def test_the_feed_reaches_the_right_person_by_token(client, db): + night, _, _, ana, bruno, admin = seed(db) + _shift(db, night, date.today(), ana) + ana.calendar_token = "ana-token" + db.commit() + sign_in(client) + page = client.get("/calendar").text + assert f"/calendar/{admin.calendar_token}.ics" in page + + mine = unfold(client.get("/calendar/ana-token.ics").text) + assert client.get("/calendar/ana-token.ics").headers["content-type"].startswith("text/calendar") + assert "Pessoa: Ana" in mine + assert "X-WR-CALNAME:Contínuos CRR — Ana" in mine + + assert client.get("/calendar/nao-existe.ics").status_code == 404 + + +def test_admins_share_one_feed_with_the_whole_rota(client, db): + from app.config import settings + + night, sat, _, ana, bruno, admin = seed(db) + _shift(db, night, date.today(), ana) + _shift(db, sat, date.today(), bruno) + sign_in(client) + + hub = client.get("/admin/schedules").text + assert "/calendar/" in hub + token = settings.calendar_token + assert token, "the team token is created on first use" + + response = client.get(f"/calendar/{token}.ics") + feed = unfold(response.text) + assert response.status_code == 200 + assert "Pessoa: Ana" in feed + assert "Pessoa: Bruno" in feed + assert "X-WR-CALNAME:Contínuos CRR" in feed + assert settings.calendar_token == token # stable between visits + + +def test_inactive_people_lose_their_feed(client, db): + _, _, _, ana, _, _ = seed(db) + ana.calendar_token = "ana-token" + ana.is_active = False + db.commit() + + assert client.get("/calendar/ana-token.ics").status_code == 404 + + +def test_the_calendar_page_holds_the_links_and_the_rota_only_links_to_it(client, db): + night, sat, _, ana, bruno, admin = seed(db) + sign_in(client) + + rota = client.get("/").text + assert 'href="/calendar">Calendário' in rota + assert "O meu calendário" not in rota # the links live on their own page + + page = client.get("/calendar").text + assert "Os meus turnos" in page + assert "Rota completa" in page # admins also get the team feed + assert "/calendar/" in page + assert 'data-copy="http://testserver/calendar/' in page + assert ">Copiar" in page + assert "webcal://testserver/calendar/" in page + + # members only get their own feed + client.post("/logout", data={"csrf_token": CSRF_RE.search(client.get("/").text).group(1)}) + ana.username = "ana" + ana.password_hash = hash_password("ana-password") + db.commit() + token = CSRF_RE.search(client.get("/login").text).group(1) + client.post("/login", data={"username": "ana", "password": "ana-password", "csrf_token": token}) + member_page = client.get("/calendar").text + assert "Os meus turnos" in member_page + assert "Rota completa" not in member_page + + +def test_the_calendar_page_needs_a_signed_in_person(client, db): + seed(db) + assert client.get("/calendar", follow_redirects=False).status_code == 401 diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 066da8c..0a63539 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -24,7 +24,7 @@ def test_migrations_produce_the_model_schema(tmp_path, monkeypatch): with engine.connect() as connection: head = connection.execute(text("select version_num from alembic_version")).scalar_one() - assert head == "258f14bb3fcc" + assert head == "77b487b8a09b" def test_seeded_schedule_names_are_renamed_without_touching_custom_names(tmp_path, monkeypatch): @@ -55,15 +55,21 @@ def test_seeded_schedule_names_are_renamed_without_touching_custom_names(tmp_pat with engine.connect() as connection: rows = dict(connection.execute(text("select slug, name from schedules")).all()) - labels = dict(connection.execute(text("select slug, label_time from schedules")).all()) + hours = dict( + connection.execute( + text("select slug, start_time || '-' || end_time from schedules") + ).all() + ) assert rows == { "night": "Noite", "saturday-lunch": "Almoço - Sábados", "sunday-lunch": "Sundays off", # renamed by an administrator, left alone } - assert labels["night"] == "20:30–00:00" - assert labels["saturday-lunch"] == "13:00–16:00" - assert labels["sunday-lunch"] == "13:00–16:00" + assert hours == { + "night": "20:30-00:00", + "saturday-lunch": "13:00-16:00", + "sunday-lunch": "13:00-16:00", + } def test_a_new_column_gets_a_revision(monkeypatch, tmp_path): @@ -100,17 +106,21 @@ def test_seeded_service_windows_replace_the_plain_labels(tmp_path, monkeypatch): connection.execute(text( "INSERT INTO schedules (slug, name, schedule_type, weekdays, label_time, " "requires_manager_approval, is_active, created_at) VALUES " - "('night', 'Noite', 'fixed', '0,1,2,3,4,5,6', 'Noite', 1, 1, '2024-01-01 00:00:00'), " - "('saturday-lunch', 'Almoço - Sábados', 'rotation', '5', 'Almoço', 1, 1, '2024-01-01 00:00:00'), " + "('night', 'Noite', 'fixed', '0,1,2,3,4,5,6', '20:30–00:00', 1, 1, '2024-01-01 00:00:00'), " + "('saturday-lunch', 'Almoço - Sábados', 'rotation', '5', '13:00–16:00', 1, 1, '2024-01-01 00:00:00'), " "('sunday-lunch', 'Almoço - Domingo', 'rotation', '6', 'Horário próprio', 1, 1, '2024-01-01 00:00:00')" )) command.upgrade(config, "head") with engine.connect() as connection: - labels = dict(connection.execute(text("select slug, label_time from schedules")).all()) - assert labels == { - "night": "20:30–00:00", - "saturday-lunch": "13:00–16:00", - "sunday-lunch": "Horário próprio", # chosen by an administrator, left alone + hours = dict( + connection.execute( + text("select slug, start_time || '-' || end_time from schedules") + ).all() + ) + assert hours == { + "night": "20:30-00:00", + "saturday-lunch": "13:00-16:00", + "sunday-lunch": "13:00-16:00", } diff --git a/tests/test_public_schedules.py b/tests/test_public_schedules.py index 5be8e64..c318853 100644 --- a/tests/test_public_schedules.py +++ b/tests/test_public_schedules.py @@ -1,5 +1,5 @@ import re -from datetime import date, timedelta +from datetime import date, time, timedelta import pytest from fastapi.testclient import TestClient @@ -57,7 +57,8 @@ def add_schedule(db, **fields) -> Schedule: defaults = { "schedule_type": "rotation", "weekdays": "5", - "label_time": "Lunch", + "start_time": time(13, 0), + "end_time": time(16, 0), "requires_manager_approval": True, "is_active": True, "rotation_anchor_date": date(2026, 1, 3), @@ -87,7 +88,7 @@ def login(client: TestClient, username: str, password: str) -> str: def seed_schedules(db): - night = add_schedule(db, name="Noite", slug="night", schedule_type="fixed", weekdays="0,1,2,3,4,5,6", label_time="Noite") + night = add_schedule(db, name="Noite", slug="night", schedule_type="fixed", weekdays="0,1,2,3,4,5,6") sat = add_schedule(db, name="Almoço - Sábados", slug="sat") sun = add_schedule(db, name="Almoço - Domingo", slug="sun", weekdays="6") ana = add_person(db, "Ana") diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index 17c707a..702172f 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -43,7 +43,6 @@ def seed_schedules(db: Session): slug="night", schedule_type="fixed", weekdays="0,1,2,3,4,5,6", - label_time="Night", requires_manager_approval=True, is_active=True, ) @@ -52,7 +51,6 @@ def seed_schedules(db: Session): slug="sat", schedule_type="rotation", weekdays="5", - label_time="Lunch", requires_manager_approval=True, is_active=True, rotation_anchor_date=date(2026, 1, 3), @@ -62,7 +60,6 @@ def seed_schedules(db: Session): slug="sun", schedule_type="rotation", weekdays="6", - label_time="Lunch", requires_manager_approval=True, is_active=True, rotation_anchor_date=date(2026, 1, 4), diff --git a/tests/test_users.py b/tests/test_users.py index 2cd6c63..6a40b86 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -202,7 +202,6 @@ def test_removing_a_person_clears_every_reference(client, db, admin): slug="saturday-lunch", schedule_type="rotation", weekdays="5", - label_time="Almoço", requires_manager_approval=True, is_active=True, rotation_anchor_date=date(2026, 1, 3), @@ -268,7 +267,6 @@ def test_removing_a_person_keeps_other_peoples_requests_intact(client, db, admin slug="saturday-lunch", schedule_type="rotation", weekdays="5", - label_time="Almoço", requires_manager_approval=True, is_active=True, rotation_anchor_date=date(2026, 1, 3),