|
| 1 | +# |
| 2 | +# Gramps Web API - A RESTful API for the Gramps genealogy program |
| 3 | +# |
| 4 | +# Copyright (C) 2026 Gramps Web contributors |
| 5 | +# |
| 6 | +# This program is free software; you can redistribute it and/or modify |
| 7 | +# it under the terms of the GNU Affero General Public License as published by |
| 8 | +# the Free Software Foundation; either version 3 of the License, or |
| 9 | +# (at your option) any later version. |
| 10 | +# |
| 11 | + |
| 12 | +"""Anniversaries ICS resource.""" |
| 13 | + |
| 14 | +import json |
| 15 | +from datetime import datetime, timezone |
| 16 | +from typing import Optional |
| 17 | + |
| 18 | +from flask import Response |
| 19 | +from gramps.gen.lib import Event |
| 20 | +from gramps.gen.lib.date import gregorian |
| 21 | +from marshmallow import Schema |
| 22 | +from webargs import fields, validate |
| 23 | + |
| 24 | +from ...auth import ( |
| 25 | + get_permissions, |
| 26 | + get_user_from_access_token, |
| 27 | + is_tree_disabled, |
| 28 | +) |
| 29 | +from ...auth.const import ACCESS_TOKEN_SCOPE_ANNIVERSARIES_ICS, PERM_VIEW_PRIVATE |
| 30 | +from ..blueprint import api_blueprint |
| 31 | +from ..util import ( |
| 32 | + abort_with_message, |
| 33 | + close_db, |
| 34 | + get_db_outside_request, |
| 35 | + get_tree_id, |
| 36 | +) |
| 37 | +from . import Resource |
| 38 | +from .filters import apply_filter |
| 39 | +from .util import get_backlinks, get_event_summary_from_object |
| 40 | + |
| 41 | + |
| 42 | +def _escape_ics_text(value: str) -> str: |
| 43 | + """Escape text fields according to RFC 5545.""" |
| 44 | + return ( |
| 45 | + value.replace("\\", "\\\\") |
| 46 | + .replace(";", "\\;") |
| 47 | + .replace(",", "\\,") |
| 48 | + .replace("\r\n", "\\n") |
| 49 | + .replace("\n", "\\n") |
| 50 | + ) |
| 51 | + |
| 52 | + |
| 53 | +def _event_matches_type(event: Event, allowed_types: set[str]) -> bool: |
| 54 | + """Check if an event type matches one of the requested filters.""" |
| 55 | + if not allowed_types: |
| 56 | + return True |
| 57 | + event_values = { |
| 58 | + str(event.get_type()).casefold().strip(), |
| 59 | + event.get_type().xml_str().casefold().strip(), |
| 60 | + } |
| 61 | + return not event_values.isdisjoint(allowed_types) |
| 62 | + |
| 63 | + |
| 64 | +def _get_anniversary_date_components(event: Event) -> Optional[tuple[int, int, int]]: |
| 65 | + """Get Gregorian (year, month, day) tuple for an event date.""" |
| 66 | + if event.date is None or not event.date.is_valid(): |
| 67 | + return None |
| 68 | + gdate = gregorian(event.date) |
| 69 | + month = gdate.get_month() |
| 70 | + day = gdate.get_day() |
| 71 | + if month < 1 or day < 1: |
| 72 | + return None |
| 73 | + year = gdate.get_year() |
| 74 | + if year < 1: |
| 75 | + year = 1970 |
| 76 | + if year > 9999: |
| 77 | + year = 9999 |
| 78 | + return year, month, day |
| 79 | + |
| 80 | + |
| 81 | +def _is_event_in_anchor_scope( |
| 82 | + db_handle, |
| 83 | + event: Event, |
| 84 | + allowed_people: set[str], |
| 85 | + allowed_families: set[str], |
| 86 | +) -> bool: |
| 87 | + """Check if an event is linked to people/families in anchor scope.""" |
| 88 | + backlinks = get_backlinks(db_handle, event.handle) |
| 89 | + people = set(backlinks.get("person", [])) |
| 90 | + if not people.isdisjoint(allowed_people): |
| 91 | + return True |
| 92 | + families = set(backlinks.get("family", [])) |
| 93 | + return not families.isdisjoint(allowed_families) |
| 94 | + |
| 95 | + |
| 96 | +def _resolve_anchor_people_handles( |
| 97 | + db_handle, anchor_gramps_id: str, generation_depth: int |
| 98 | +) -> set[str]: |
| 99 | + """Resolve people handles to include for an anchor+generation filter.""" |
| 100 | + anchor = db_handle.get_person_from_gramps_id(anchor_gramps_id) |
| 101 | + if anchor is None: |
| 102 | + abort_with_message(404, "Anchor person not found") |
| 103 | + rules = { |
| 104 | + "function": "or", |
| 105 | + "rules": [ |
| 106 | + { |
| 107 | + "name": "IsLessThanNthGenerationAncestorOf", |
| 108 | + "values": [anchor_gramps_id, generation_depth], |
| 109 | + }, |
| 110 | + { |
| 111 | + "name": "IsLessThanNthGenerationDescendantOf", |
| 112 | + "values": [anchor_gramps_id, generation_depth], |
| 113 | + }, |
| 114 | + ], |
| 115 | + } |
| 116 | + handles = db_handle.get_person_handles(sort_handles=True) |
| 117 | + return set( |
| 118 | + apply_filter( |
| 119 | + db_handle, |
| 120 | + {"rules": json.dumps(rules)}, |
| 121 | + "Person", |
| 122 | + handles, |
| 123 | + ) |
| 124 | + ) |
| 125 | + |
| 126 | + |
| 127 | +def _resolve_family_handles_for_people(db_handle, people_handles: set[str]) -> set[str]: |
| 128 | + """Resolve families attached to in-scope people handles.""" |
| 129 | + family_handles = set() |
| 130 | + for handle in people_handles: |
| 131 | + person = db_handle.get_person_from_handle(handle) |
| 132 | + if person is None: |
| 133 | + continue |
| 134 | + family_handles.update(person.family_list) |
| 135 | + family_handles.update(person.parent_family_list) |
| 136 | + return family_handles |
| 137 | + |
| 138 | + |
| 139 | +def _build_ics(events: list[Event], db_handle, tree_id: str) -> str: |
| 140 | + """Build ICS calendar content for a list of events.""" |
| 141 | + dtstamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") |
| 142 | + lines = [ |
| 143 | + "BEGIN:VCALENDAR", |
| 144 | + "VERSION:2.0", |
| 145 | + "PRODID:-//Gramps Web//Anniversaries//EN", |
| 146 | + "CALSCALE:GREGORIAN", |
| 147 | + "METHOD:PUBLISH", |
| 148 | + "X-WR-CALNAME:Gramps Anniversaries", |
| 149 | + ] |
| 150 | + for event in events: |
| 151 | + date_components = _get_anniversary_date_components(event) |
| 152 | + if date_components is None: |
| 153 | + continue |
| 154 | + year, month, day = date_components |
| 155 | + dtstart = f"{year:04d}{month:02d}{day:02d}" |
| 156 | + summary = _escape_ics_text(get_event_summary_from_object(db_handle, event)) |
| 157 | + description = _escape_ics_text( |
| 158 | + f"Gramps ID: {event.gramps_id or ''}\\nType: {event.get_type().xml_str()}" |
| 159 | + ) |
| 160 | + uid = _escape_ics_text(f"{event.handle}@{tree_id}.anniversaries.gramps-web") |
| 161 | + lines.extend( |
| 162 | + [ |
| 163 | + "BEGIN:VEVENT", |
| 164 | + f"UID:{uid}", |
| 165 | + f"DTSTAMP:{dtstamp}", |
| 166 | + f"DTSTART;VALUE=DATE:{dtstart}", |
| 167 | + "RRULE:FREQ=YEARLY", |
| 168 | + f"SUMMARY:{summary}", |
| 169 | + f"DESCRIPTION:{description}", |
| 170 | + "END:VEVENT", |
| 171 | + ] |
| 172 | + ) |
| 173 | + lines.append("END:VCALENDAR") |
| 174 | + return "\r\n".join(lines) + "\r\n" |
| 175 | + |
| 176 | + |
| 177 | +def _event_sort_key(event: Event) -> tuple[int, int, int, str]: |
| 178 | + """Return stable sort key for anniversary events.""" |
| 179 | + date_components = _get_anniversary_date_components(event) |
| 180 | + if date_components is None: |
| 181 | + return (12, 31, 9999, event.handle) |
| 182 | + year, month, day = date_components |
| 183 | + return (month, day, year, event.handle) |
| 184 | + |
| 185 | + |
| 186 | +class AnniversariesIcsQueryArgs(Schema): |
| 187 | + """Query arguments for GET /anniversaries.ics.""" |
| 188 | + |
| 189 | + token = fields.Str( |
| 190 | + required=True, |
| 191 | + validate=validate.Length(min=1), |
| 192 | + metadata={"description": "Persistent access token value."}, |
| 193 | + ) |
| 194 | + event_types = fields.DelimitedList( |
| 195 | + fields.Str(validate=validate.Length(min=1)), |
| 196 | + metadata={"description": "Comma-delimited event type names to include."}, |
| 197 | + ) |
| 198 | + anchor_gramps_id = fields.Str( |
| 199 | + metadata={"description": "Anchor person Gramps ID for family-scope filtering."}, |
| 200 | + ) |
| 201 | + generation_depth = fields.Integer( |
| 202 | + load_default=4, |
| 203 | + validate=validate.Range(min=1, max=9), |
| 204 | + metadata={"description": "Generation depth around the anchor person."}, |
| 205 | + ) |
| 206 | + |
| 207 | + |
| 208 | +class AnniversariesIcsResource(Resource): |
| 209 | + """Public anniversaries ICS feed resource.""" |
| 210 | + |
| 211 | + @api_blueprint.arguments(AnniversariesIcsQueryArgs, location="query") |
| 212 | + def get(self, args: dict) -> Response: |
| 213 | + """Return anniversaries in ICS format.""" |
| 214 | + user = get_user_from_access_token( |
| 215 | + args["token"], ACCESS_TOKEN_SCOPE_ANNIVERSARIES_ICS |
| 216 | + ) |
| 217 | + if user is None: |
| 218 | + abort_with_message(401, "Invalid access token") |
| 219 | + if user.role is None or user.role < 0: |
| 220 | + abort_with_message(403, "User account is disabled") |
| 221 | + |
| 222 | + tree_id = get_tree_id(str(user.id)) |
| 223 | + if is_tree_disabled(tree=tree_id): |
| 224 | + abort_with_message(503, "This tree is temporarily disabled") |
| 225 | + |
| 226 | + permissions = get_permissions(username=user.name, tree=tree_id) |
| 227 | + view_private = PERM_VIEW_PRIVATE in permissions |
| 228 | + db_handle = get_db_outside_request( |
| 229 | + tree=tree_id, |
| 230 | + view_private=view_private, |
| 231 | + readonly=True, |
| 232 | + user_id=str(user.id), |
| 233 | + ) |
| 234 | + try: |
| 235 | + iter_event_handles = db_handle.method("iter_event_handles") |
| 236 | + get_event_from_handle = db_handle.method("get_event_from_handle") |
| 237 | + assert iter_event_handles is not None |
| 238 | + assert get_event_from_handle is not None |
| 239 | + events = [] |
| 240 | + for handle in iter_event_handles(): |
| 241 | + event = get_event_from_handle(handle) |
| 242 | + if event is not None: |
| 243 | + events.append(event) |
| 244 | + |
| 245 | + allowed_types = { |
| 246 | + event_type.casefold().strip() |
| 247 | + for event_type in args.get("event_types", []) |
| 248 | + if event_type and event_type.strip() |
| 249 | + } |
| 250 | + events = [ |
| 251 | + event for event in events if _event_matches_type(event, allowed_types) |
| 252 | + ] |
| 253 | + events = [ |
| 254 | + event |
| 255 | + for event in events |
| 256 | + if _get_anniversary_date_components(event) is not None |
| 257 | + ] |
| 258 | + |
| 259 | + if args.get("anchor_gramps_id"): |
| 260 | + allowed_people = _resolve_anchor_people_handles( |
| 261 | + db_handle, args["anchor_gramps_id"], args["generation_depth"] |
| 262 | + ) |
| 263 | + allowed_families = _resolve_family_handles_for_people( |
| 264 | + db_handle, allowed_people |
| 265 | + ) |
| 266 | + events = [ |
| 267 | + event |
| 268 | + for event in events |
| 269 | + if _is_event_in_anchor_scope( |
| 270 | + db_handle, event, allowed_people, allowed_families |
| 271 | + ) |
| 272 | + ] |
| 273 | + |
| 274 | + events.sort(key=_event_sort_key) |
| 275 | + payload = _build_ics(events=events, db_handle=db_handle, tree_id=tree_id) |
| 276 | + finally: |
| 277 | + close_db(db_handle) |
| 278 | + |
| 279 | + response = Response(payload, status=200, mimetype="text/calendar") |
| 280 | + response.headers["Content-Disposition"] = "inline; filename=anniversaries.ics" |
| 281 | + return response |
0 commit comments