feat: export calendar and backup db

This commit is contained in:
2026-10-06 15:44:25 +01:00
parent 306e21fe44
commit f4985b7fbc
28 changed files with 712 additions and 46 deletions
+8
View File
@@ -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/<token>.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
+2
View File
@@ -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-<timestamp>.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
+7 -4
View File
@@ -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),
},
]
+119
View File
@@ -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"
+4
View File
@@ -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"
+45
View File
@@ -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
+6 -1
View File
@@ -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}"
+10
View File
@@ -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
+6 -2
View File
@@ -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)
+1
View File
@@ -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}
+1 -1
View File
@@ -27,7 +27,7 @@
<input type="hidden" name="assignment_id" value="{{ row.assignment.id }}">
<div>
<strong>{{ row.schedule.name }}</strong>
{% if row.schedule.label_time %}<span class="muted"> · {{ row.schedule.label_time }}</span>{% endif %}
<span class="muted"> · {{ row.schedule|horario }}</span>
<div class="muted">
{% if row.assignment.user %}
Agora: {{ row.assignment.user.name }} ({{ row.assignment.source|origem }})
+3 -1
View File
@@ -5,10 +5,12 @@
<div>
<p class="eyebrow"><a href="{{ app_path('/admin/schedules') }}">Escalas</a></p>
<h1>{{ schedule.name }}</h1>
<p class="muted">{{ card.kind_label }}{% if schedule.label_time %} · {{ schedule.label_time }}{% endif %}</p>
<p class="muted">{{ card.kind_label }} · {{ schedule|horario }}</p>
</div>
<form method="post" action="{{ app_path('/admin/schedules/' ~ schedule.id ~ '/settings') }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Das horas<input type="time" name="start_time" value="{{ schedule.start_time.strftime('%H:%M') }}" required></label>
<label>Às horas<input type="time" name="end_time" value="{{ schedule.end_time.strftime('%H:%M') }}" required></label>
<label class="check"><input type="checkbox" name="requires_manager_approval" {% if schedule.requires_manager_approval %}checked{% endif %}> Aprovação da gestão</label>
<label class="check"><input type="checkbox" name="is_active" {% if schedule.is_active %}checked{% endif %}> Ativa</label>
<button class="secondary" type="submit">Guardar</button>
+7 -1
View File
@@ -7,6 +7,12 @@
</div>
<a class="button primary" href="{{ app_path('/admin/assign') }}">Atribuir por dia</a>
</div>
<section class="panel">
<h3>Calendário da rota</h3>
<p class="muted">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.</p>
<p><code>{{ calendar_feed_url }}</code></p>
<a class="button secondary" href="webcal://{{ calendar_feed_url.split('//')[-1] }}">Subscrever o calendário do bar</a>
</section>
<div class="stack gap">
{% for card in cards %}
{% set schedule = card.schedule %}
@@ -14,7 +20,7 @@
<div class="row between wrap gap">
<div>
<h2>{{ schedule.name }}</h2>
<p class="muted">{{ card.kind_label }}{% if schedule.label_time %} · {{ schedule.label_time }}{% endif %}{% if not schedule.is_active %} · inativa{% endif %}</p>
<p class="muted">{{ card.kind_label }} · {{ schedule|horario }}{% if not schedule.is_active %} · inativa{% endif %}</p>
</div>
<a class="button primary" href="{{ app_path('/admin/schedules/' ~ schedule.id ) }}">Configurar</a>
</div>
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Calendário · {{ settings.app_name }}{% endblock %}
{% block content %}
<div class="page-head">
<div>
<p class="eyebrow"><a href="{{ app_path('/') }}">Rota</a></p>
<h1>Calendário</h1>
<p class="muted">Adiciona o endereço ao Google Calendar, Apple Calendar ou Outlook. O calendário atualiza-se sozinho, não é preciso repetir o processo.</p>
</div>
</div>
<div class="stack gap-lg">
{% for feed in feeds %}
<section class="panel">
<h2>{{ feed.title }}</h2>
<div class="inline-form">
<input type="text" readonly value="{{ feed.url }}" aria-label="Endereço do calendário">
<button class="secondary" type="button" data-copy="{{ feed.url }}">Copiar</button>
<a class="button secondary" href="webcal://{{ feed.url.split('//')[-1] }}">Subscrever</a>
</div>
</section>
{% endfor %}
</div>
<script>
// Copy the address without leaving the page; falls back to selecting the field.
document.querySelectorAll("[data-copy]").forEach(function (button) {
button.addEventListener("click", function () {
var url = button.dataset.copy;
var field = button.parentElement.querySelector("input");
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(url);
button.textContent = "Copiado";
setTimeout(function () { button.textContent = "Copiar"; }, 1500);
return;
}
field.focus();
field.select();
button.textContent = "Copie com Ctrl+C";
});
});
</script>
{% endblock %}
+4 -1
View File
@@ -5,7 +5,10 @@
<h1>{{ month_name }} {{ year }}</h1>
</div>
<div class="month-nav">
{% if current_user %}<a class="button primary" href="{{ app_path('/swaps/new?year=' ~ year ~ '&month=' ~ month ) }}">Pedir troca</a>{% endif %}
{% if current_user %}
<a class="button secondary" href="{{ app_path('/calendar') }}">Calendário</a>
<a class="button primary" href="{{ app_path('/swaps/new?year=' ~ year ~ '&month=' ~ month ) }}">Pedir troca</a>
{% endif %}
<a class="button secondary" href="{{ app_path('/?year=' ~ prev[0] ~ '&month=' ~ prev[1] ) }}">← Anterior</a>
<a class="button secondary" href="{{ app_path('/?year=' ~ nxt[0] ~ '&month=' ~ nxt[1] ) }}">Seguinte →</a>
</div>
+1 -1
View File
@@ -6,7 +6,7 @@
<div>
<p class="eyebrow"><a href="{{ app_path('/schedules') }}">Escalas</a></p>
<h1>{{ schedule.name }}</h1>
<p class="muted">{{ card.kind_label }}{% if schedule.label_time %} · {{ schedule.label_time }}{% endif %}</p>
<p class="muted">{{ card.kind_label }} · {{ schedule|horario }}</p>
</div>
</div>
<div class="stack gap-lg">
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="row between wrap gap">
<div>
<h2>{{ card.schedule.name }}</h2>
<p class="muted">{{ card.kind_label }}{% if card.schedule.label_time %} · {{ card.schedule.label_time }}{% endif %}</p>
<p class="muted">{{ card.kind_label }} · {{ card.schedule|horario }}</p>
</div>
<a class="button secondary" href="{{ app_path('/schedules/' ~ card.schedule.id ) }}">Abrir escala</a>
</div>
+85 -3
View File
@@ -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()
+13 -1
View File
@@ -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/<token>.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`)
@@ -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')
-3
View File
@@ -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),
+82
View File
@@ -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
+13 -6
View File
@@ -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)),
}
+144
View File
@@ -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</a>' 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</button>" 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
+22 -12
View File
@@ -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",
}
+4 -3
View File
@@ -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")
-3
View File
@@ -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),
-2
View File
@@ -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),