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:
@@ -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"])
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
77
backend/routers/birthdays_router.py
Normal file
77
backend/routers/birthdays_router.py
Normal 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
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user