Add birthday calendar backend support
Birthday calendars are ordinary local calendars flagged is_birthday, so they
flow to all clients via the merge read and inherit sharing/colors/reminders.
- models: LocalCalendar.is_birthday + birthday_notify_days_before;
LocalEvent.external_uid (Contacts dedup) + birth_year
- build_local_event_dict: server-computed display_title "Name (age)" per
occurrence, is_birthday flag for the client cake icon, and a reminder injected
from birthday_notify_days_before so mobile schedulers fire it
- groups combined view keeps the birthday display_title instead of overwriting it
- local_router: calendar flags + event fields on create/update, plus
GET /calendars/{id}/birthdays for importer reconcile by external_uid
- additive SQLite migrations
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,25 @@ def build_local_event_dict(
|
||||
"private": bool(ev.is_private),
|
||||
"reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()],
|
||||
}
|
||||
# Birthday calendars: the server owns the presentation. It flags the event so
|
||||
# clients can show a cake icon, appends the age computed for THIS occurrence
|
||||
# (so it stays correct as years pass), and — when the calendar defines a
|
||||
# "notify N days before" — injects a reminder so the mobile schedulers fire
|
||||
# it. Web has no notification delivery, so the reminder is display-only there.
|
||||
if getattr(cal, "is_birthday", False):
|
||||
d["is_birthday"] = True
|
||||
display = ev.title
|
||||
if ev.birth_year:
|
||||
try:
|
||||
occ_year = int(str(d["start"])[:4])
|
||||
age = occ_year - int(ev.birth_year)
|
||||
if age >= 0:
|
||||
display = f"{ev.title} ({age})"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
d["display_title"] = display
|
||||
if not d["reminders"] and cal.birthday_notify_days_before is not None:
|
||||
d["reminders"] = [int(cal.birthday_notify_days_before) * 1440]
|
||||
if owner is not None:
|
||||
d["owner"] = owner
|
||||
if is_group_event:
|
||||
|
||||
@@ -259,6 +259,29 @@ def _migrate():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Birthday calendars: flag + per-calendar "notify N days before".
|
||||
for col, ddl in (
|
||||
("is_birthday", "ALTER TABLE local_calendars ADD COLUMN is_birthday BOOLEAN DEFAULT 0"),
|
||||
("birthday_notify_days_before", "ALTER TABLE local_calendars ADD COLUMN birthday_notify_days_before INTEGER"),
|
||||
):
|
||||
try:
|
||||
conn.execute(text(ddl))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to local_calendars", col)
|
||||
except Exception:
|
||||
pass
|
||||
# Birthday events: stable external id (Contacts dedup) + birth year.
|
||||
for col, ddl in (
|
||||
("external_uid", "ALTER TABLE local_events ADD COLUMN external_uid VARCHAR(255)"),
|
||||
("birth_year", "ALTER TABLE local_events ADD COLUMN birth_year INTEGER"),
|
||||
):
|
||||
try:
|
||||
conn.execute(text(ddl))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to local_events", col)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_migrate()
|
||||
|
||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
||||
|
||||
@@ -153,6 +153,12 @@ class LocalCalendar(Base):
|
||||
caldav_published = Column(Boolean, default=False, nullable=False)
|
||||
dav_token = Column(String(64), nullable=True, unique=True)
|
||||
dav_ctag = Column(String(32), nullable=True)
|
||||
# Birthday calendar: events are all-day, yearly-recurring; the server adds the
|
||||
# age suffix ("Anna (30)") and an is_birthday flag so clients show a cake icon.
|
||||
is_birthday = Column(Boolean, default=False, nullable=False)
|
||||
# How many days before a birthday to remind (0 = on the day). NULL = no reminder.
|
||||
# Injected as an event reminder on read so the mobile schedulers fire it.
|
||||
birthday_notify_days_before = Column(Integer, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="local_calendars")
|
||||
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
|
||||
@@ -183,6 +189,11 @@ class LocalEvent(Base):
|
||||
is_private = Column(Boolean, default=False)
|
||||
# CalDAV entity tag — changes on every write so CalDAV clients detect updates.
|
||||
etag = Column(String(32), nullable=True)
|
||||
# Stable external identity for imported entries (e.g. a Contacts birthday keyed
|
||||
# by "contact:<id>"), so a re-sync can mirror the address book without dupes.
|
||||
external_uid = Column(String(255), nullable=True, index=True)
|
||||
# Birth year for birthday events; NULL = year unknown (no age shown).
|
||||
birth_year = Column(Integer, nullable=True)
|
||||
|
||||
calendar = relationship("LocalCalendar", back_populates="events")
|
||||
creator = relationship("User")
|
||||
|
||||
@@ -420,10 +420,13 @@ def combined_events(
|
||||
b["display_color"] = group_cal_color if is_group else member_color.get(owner_id)
|
||||
# Decorated title (group icon / owner name) computed server-side
|
||||
# so all clients render identically; raw `title` kept for editing.
|
||||
b["display_title"] = _decorate_title(
|
||||
b.get("title", ""), is_group=is_group, creator=b.get("creator"),
|
||||
owner=owner, me_id=current_user.id,
|
||||
)
|
||||
# A birthday event already carries an age display_title from
|
||||
# build_local_event_dict — keep it rather than clobber the age.
|
||||
if not b.get("is_birthday"):
|
||||
b["display_title"] = _decorate_title(
|
||||
b.get("title", ""), is_group=is_group, creator=b.get("creator"),
|
||||
owner=owner, me_id=current_user.id,
|
||||
)
|
||||
all_events.append(b)
|
||||
|
||||
# Each member shares exactly one calendar into their groups, chosen in their
|
||||
|
||||
@@ -25,6 +25,8 @@ def _now_iso() -> str:
|
||||
class CalendarCreate(BaseModel):
|
||||
name: str
|
||||
color: str = "#34a853"
|
||||
is_birthday: bool = False
|
||||
birthday_notify_days_before: Optional[int] = None
|
||||
|
||||
|
||||
class CalendarUpdate(BaseModel):
|
||||
@@ -33,6 +35,8 @@ class CalendarUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
reminders_enabled: Optional[bool] = None
|
||||
caldav_published: Optional[bool] = None
|
||||
is_birthday: Optional[bool] = None
|
||||
birthday_notify_days_before: Optional[int] = None
|
||||
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
@@ -47,6 +51,8 @@ class EventCreate(BaseModel):
|
||||
rrule: Optional[str] = None
|
||||
private: bool = False
|
||||
reminders: Optional[List[int]] = None # minutes before start (0 = at start)
|
||||
external_uid: Optional[str] = None # stable id for imported entries (Contacts dedup)
|
||||
birth_year: Optional[int] = None # birthday events; NULL = year unknown
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
@@ -61,6 +67,8 @@ class EventUpdate(BaseModel):
|
||||
exdate: Optional[str] = None
|
||||
private: Optional[bool] = None
|
||||
reminders: Optional[List[int]] = None
|
||||
external_uid: Optional[str] = None
|
||||
birth_year: Optional[int] = None
|
||||
|
||||
|
||||
class ShareCreate(BaseModel):
|
||||
@@ -80,6 +88,8 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
||||
"enabled": cal.enabled,
|
||||
"reminders_enabled": bool(cal.reminders_enabled),
|
||||
"caldav_published": bool(cal.caldav_published),
|
||||
"is_birthday": bool(cal.is_birthday),
|
||||
"birthday_notify_days_before": cal.birthday_notify_days_before,
|
||||
"type": "local",
|
||||
"owned": owned,
|
||||
}
|
||||
@@ -205,6 +215,8 @@ def create_calendar(
|
||||
user_id=current_user.id,
|
||||
name=data.name,
|
||||
color=data.color,
|
||||
is_birthday=data.is_birthday,
|
||||
birthday_notify_days_before=data.birthday_notify_days_before,
|
||||
)
|
||||
db.add(cal)
|
||||
db.commit()
|
||||
@@ -238,6 +250,14 @@ def update_calendar(
|
||||
cal.enabled = data.enabled
|
||||
if data.reminders_enabled is not None:
|
||||
cal.reminders_enabled = data.reminders_enabled
|
||||
if data.is_birthday is not None:
|
||||
cal.is_birthday = data.is_birthday
|
||||
if data.birthday_notify_days_before is not None:
|
||||
# -1 is the client's sentinel for "clear the reminder" (JSON has no way to
|
||||
# send SQL NULL through an Optional that also means "unchanged").
|
||||
cal.birthday_notify_days_before = (
|
||||
None if data.birthday_notify_days_before < 0 else data.birthday_notify_days_before
|
||||
)
|
||||
if data.caldav_published is not None:
|
||||
cal.caldav_published = data.caldav_published
|
||||
if data.caldav_published:
|
||||
@@ -368,6 +388,8 @@ def create_event(
|
||||
rrule=data.rrule,
|
||||
is_private=data.private,
|
||||
reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None),
|
||||
external_uid=data.external_uid,
|
||||
birth_year=data.birth_year,
|
||||
creator_id=current_user.id, # server-side, never from the client
|
||||
)
|
||||
db.add(ev)
|
||||
@@ -420,6 +442,11 @@ def update_event(
|
||||
ev.exdate = ",".join(dates)
|
||||
if data.reminders is not None:
|
||||
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
|
||||
if data.external_uid is not None:
|
||||
ev.external_uid = data.external_uid
|
||||
if data.birth_year is not None:
|
||||
# -1 is the client's sentinel for "clear" (year became unknown).
|
||||
ev.birth_year = None if data.birth_year < 0 else data.birth_year
|
||||
dav_util.bump_dav(ev.calendar, ev)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -438,6 +465,42 @@ def delete_event(
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/calendars/{calendar_id}/birthdays")
|
||||
def list_birthday_entries(
|
||||
calendar_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Raw (unexpanded) rows of a birthday calendar so a client importer can
|
||||
reconcile against the address book by ``external_uid``. Contact-sourced rows
|
||||
carry an ``external_uid``; manually added birthdays have ``None`` and must be
|
||||
left untouched by the importer."""
|
||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
|
||||
events = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(models.LocalEvent.calendar_id == cal.id)
|
||||
.all()
|
||||
)
|
||||
out = []
|
||||
for ev in events:
|
||||
month = day = None
|
||||
try:
|
||||
parts = (ev.start or "")[:10].split("-")
|
||||
if len(parts) == 3:
|
||||
month, day = int(parts[1]), int(parts[2])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
out.append({
|
||||
"uid": ev.uid,
|
||||
"external_uid": ev.external_uid,
|
||||
"title": ev.title,
|
||||
"month": month,
|
||||
"day": day,
|
||||
"birth_year": ev.birth_year,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── Sharing (owner only) ──────────────────────────────────
|
||||
|
||||
@router.get("/calendars/{calendar_id}/shares")
|
||||
|
||||
Reference in New Issue
Block a user