Birthday sync-device list + guard group-visible against birthday calendar

- new BirthdaySyncDevice table + /api/birthdays/sync-report & /devices so the
  web can show "birthdays come from these devices" (iOS reports its device on
  each Contacts sync)
- settings: reject setting a birthday calendar as the group-visible calendar
  (it can still be shared directly)
- web: Settings > Calendars > Birthdays shows the device list; exclude birthday
  calendars from the group-visible picker

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-13 18:52:02 +02:00
parent 19258096e2
commit aa12b83302
6 changed files with 141 additions and 3 deletions

View File

@@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine from database import Base, engine
from routers import auth_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router from routers import auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -325,6 +325,7 @@ app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"]) app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
app.include_router(local_router.router, prefix="/api/local", tags=["local"]) app.include_router(local_router.router, prefix="/api/local", tags=["local"])
app.include_router(birthdays_router.router, prefix="/api/birthdays", tags=["birthdays"])
app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"]) app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
app.include_router(google_router.router, prefix="/api/google", tags=["google"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"])

View File

@@ -375,6 +375,26 @@ class GroupMember(Base):
user = relationship("User") user = relationship("User")
class BirthdaySyncDevice(Base):
"""A device that has synced Contacts birthdays into the user's birthday
calendar. Powers the web "birthdays come from these devices" list. One row
per (user, device); the client sends a stable device_id + human name."""
__tablename__ = "birthday_sync_devices"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_birthday_sync_device"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
device_id = Column(String(64), nullable=False)
device_name = Column(String(120), nullable=False)
last_sync = Column(String(50), nullable=True) # ISO 8601
count = Column(Integer, default=0)
user = relationship("User")
class GroupCalendar(Base): class GroupCalendar(Base):
"""1:1 link between a group and its shared local calendar.""" """1:1 link between a group and its shared local calendar."""

View File

@@ -0,0 +1,77 @@
"""Birthday sync device tracking.
The iOS app reports, after each Contacts birthday sync, which device it was and
how many birthdays it manages. The web shows this as a "birthdays come from
these devices" list. Birthday events themselves are ordinary local events
(see local_router); this router only tracks the sync sources.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
router = APIRouter()
class SyncReport(BaseModel):
device_id: str
device_name: str
count: int = 0
@router.post("/sync-report")
def report_sync(
data: SyncReport,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Upsert the (user, device) sync record after a Contacts birthday sync."""
row = (
db.query(models.BirthdaySyncDevice)
.filter(
models.BirthdaySyncDevice.user_id == current_user.id,
models.BirthdaySyncDevice.device_id == data.device_id,
)
.first()
)
now = datetime.now(timezone.utc).isoformat()
name = (data.device_name or "Gerät")[:120]
if row is None:
db.add(models.BirthdaySyncDevice(
user_id=current_user.id, device_id=data.device_id,
device_name=name, last_sync=now, count=data.count,
))
else:
row.device_name = name
row.last_sync = now
row.count = data.count
db.commit()
return {"ok": True}
@router.get("/devices")
def list_devices(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
rows = (
db.query(models.BirthdaySyncDevice)
.filter(models.BirthdaySyncDevice.user_id == current_user.id)
.order_by(models.BirthdaySyncDevice.last_sync.desc())
.all()
)
return [
{
"device_id": r.device_id,
"device_name": r.device_name,
"last_sync": r.last_sync,
"count": r.count,
}
for r in rows
]

View File

@@ -95,6 +95,21 @@ def update_settings(
if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"): if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"):
raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'") raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'")
# A birthday calendar must never become the group-visible ("personal")
# calendar — it may be shared directly, but not stand in as your calendar in
# group views. Clients filter it out of the picker; this is the safety net.
if data.group_visible_calendar_id:
bcal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == data.group_visible_calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if bcal is not None and bcal.is_birthday:
raise HTTPException(422, "A birthday calendar can't be your group-visible calendar")
# For these three override colours, an explicit null is meaningful # For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields # ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored. # keep the previous behaviour where a null/missing value is ignored.

View File

@@ -3237,7 +3237,9 @@ function renderGroupVisibleList(selectedId) {
const el = document.getElementById('cfg-group-visible-list'); const el = document.getElementById('cfg-group-visible-list');
if (!el) return; if (!el) return;
el.dataset.selected = (selectedId == null) ? '' : String(selectedId); el.dataset.selected = (selectedId == null) ? '' : String(selectedId);
const own = state.localCalendars.filter(c => c.owned !== false && !c.group); // A birthday calendar may be shared directly, but never stand in as the
// group-visible personal calendar.
const own = state.localCalendars.filter(c => c.owned !== false && !c.group && !c.is_birthday);
const selVal = el.dataset.selected; const selVal = el.dataset.selected;
const row = (id, name, color) => { const row = (id, name, color) => {
const val = (id == null) ? '' : String(id); const val = (id == null) ? '' : String(id);
@@ -3631,7 +3633,26 @@ function renderBirthdaySettings() {
const colorHint = document.createElement('div'); const colorHint = document.createElement('div');
colorHint.className = 'form-hint'; colorHint.className = 'form-hint';
colorHint.textContent = t('birthday_color_hint'); colorHint.textContent = t('birthday_color_hint');
container.append(status, notifyWrap, colorHint); const devicesWrap = document.createElement('div');
devicesWrap.style.marginTop = '12px';
container.append(status, notifyWrap, colorHint, devicesWrap);
// Which devices contribute birthdays (from Contacts sync).
api.get('/birthdays/devices').then(devs => {
if (!Array.isArray(devs) || !devs.length) return;
const title = document.createElement('div');
title.className = 'panel-desc';
title.style.margin = '0 0 4px';
title.textContent = t('birthday_devices_title');
devicesWrap.appendChild(title);
devs.forEach(d => {
const rowEl = document.createElement('div');
rowEl.className = 'form-hint';
rowEl.style.margin = '0 0 2px';
const when = d.last_sync ? new Date(d.last_sync).toLocaleDateString() : '';
rowEl.textContent = `${d.device_name}${t('birthday_devices_count', { n: d.count })}${when ? ' · ' + when : ''}`;
devicesWrap.appendChild(rowEl);
});
}).catch(() => {});
} }
function renderHiddenCalendars() { function renderHiddenCalendars() {

View File

@@ -297,6 +297,8 @@ const translations = {
birthday_year_ph: 'Jahr', birthday_year_ph: 'Jahr',
birthday_settings_title: 'Geburtstage', birthday_settings_title: 'Geburtstage',
birthday_color_hint: 'Farbe und Sichtbarkeit änderst du in der Seitenleiste.', birthday_color_hint: 'Farbe und Sichtbarkeit änderst du in der Seitenleiste.',
birthday_devices_title: 'Geburtstage kommen von diesen Geräten:',
birthday_devices_count: '{n} Geburtstage',
error_name_url: 'Bitte Name und URL eingeben', error_name_url: 'Bitte Name und URL eingeben',
ical_subscribed: '"{name}" abonniert', ical_subscribed: '"{name}" abonniert',
google_synced: 'Kalender synchronisiert', google_synced: 'Kalender synchronisiert',
@@ -639,6 +641,8 @@ const translations = {
birthday_year_ph: 'Year', birthday_year_ph: 'Year',
birthday_settings_title: 'Birthdays', birthday_settings_title: 'Birthdays',
birthday_color_hint: 'Change colour and visibility in the sidebar.', birthday_color_hint: 'Change colour and visibility in the sidebar.',
birthday_devices_title: 'Birthdays come from these devices:',
birthday_devices_count: '{n} birthdays',
error_name_url: 'Please enter a name and URL', error_name_url: 'Please enter a name and URL',
ical_subscribed: '"{name}" subscribed', ical_subscribed: '"{name}" subscribed',
google_synced: 'Calendar synced', google_synced: 'Calendar synced',