diff --git a/backend/main.py b/backend/main.py index c5e18fe..59b2d49 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate" sys.path.insert(0, str(Path(__file__).parent)) 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) @@ -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(profile_router.router, prefix="/api/profile", tags=["profile"]) 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(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"]) diff --git a/backend/models.py b/backend/models.py index 41cd8bf..483cdff 100644 --- a/backend/models.py +++ b/backend/models.py @@ -375,6 +375,26 @@ class GroupMember(Base): 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): """1:1 link between a group and its shared local calendar.""" diff --git a/backend/routers/birthdays_router.py b/backend/routers/birthdays_router.py new file mode 100644 index 0000000..e43ec8e --- /dev/null +++ b/backend/routers/birthdays_router.py @@ -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 + ] diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index 049b279..a317846 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -95,6 +95,21 @@ def update_settings( 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'") + # 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 # ("reset to default") and must be persisted as NULL. All other fields # keep the previous behaviour where a null/missing value is ignored. diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index e6d43a4..f66dfa1 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -3237,7 +3237,9 @@ function renderGroupVisibleList(selectedId) { const el = document.getElementById('cfg-group-visible-list'); if (!el) return; 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 row = (id, name, color) => { const val = (id == null) ? '' : String(id); @@ -3631,7 +3633,26 @@ function renderBirthdaySettings() { const colorHint = document.createElement('div'); colorHint.className = 'form-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() { diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 90d835b..620edfe 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -297,6 +297,8 @@ const translations = { birthday_year_ph: 'Jahr', birthday_settings_title: 'Geburtstage', 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', ical_subscribed: '"{name}" abonniert', google_synced: 'Kalender synchronisiert', @@ -639,6 +641,8 @@ const translations = { birthday_year_ph: 'Year', birthday_settings_title: 'Birthdays', 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', ical_subscribed: '"{name}" subscribed', google_synced: 'Calendar synced',