Selaa lähdekoodia

Add matching local state with an activity

Maarten van den Berg 5 vuotta sitten
vanhempi
commit
d7635b8ba0

+ 1 - 0
piket_server/__init__.py

@@ -10,3 +10,4 @@ import piket_server.routes.consumptions
10 10
 import piket_server.routes.consumption_types
11 11
 import piket_server.routes.settlements
12 12
 import piket_server.routes.exports
13
+import piket_server.routes.aardbei

+ 330 - 15
piket_server/aardbei_sync.py

@@ -3,17 +3,20 @@ from __future__ import annotations
3 3
 import datetime
4 4
 import json
5 5
 import logging
6
+import sys
6 7
 from dataclasses import asdict, dataclass
7 8
 from enum import Enum
8
-from typing import Any, Dict, List, NewType, Optional, Tuple
9
+from typing import Any, Dict, List, NewType, Optional, Tuple, Union
9 10
 
10 11
 import requests
11 12
 
12
-from piket_server.models import Person
13 13
 from piket_server.flask import db
14
+from piket_server.models import Person
15
+from piket_server.util import fmt_datetime
14 16
 
15 17
 # AARDBEI_ENDPOINT = "https://aardbei.app"
16 18
 AARDBEI_ENDPOINT = "http://localhost:3000"
19
+log = logging.getLogger(__name__)
17 20
 
18 21
 ActivityId = NewType("ActivityId", int)
19 22
 PersonId = NewType("PersonId", int)
@@ -23,17 +26,49 @@ ParticipantId = NewType("ParticipantId", int)
23 26
 
24 27
 @dataclass(frozen=True)
25 28
 class AardbeiPerson:
29
+    """
30
+    Contains the data on a Person as exposed by Aardbei.
31
+
32
+    A Person represents a person in the real world, and maps to a Person in the local database.
33
+    """
34
+
26 35
     aardbei_id: PersonId
27 36
     full_name: str
28 37
 
29 38
     @classmethod
30 39
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiPerson:
40
+        """
41
+        Load from a dictionary provided by Aardbei.
42
+
43
+        >>> AardbeiPerson.from_aardbei_dict(
44
+          {"person": {"aardbei_id": 1, "full_name": "Henkie Kraggelwenk"}}
45
+        )
46
+        AardbeiPerson(aardbei_id=AardbeiId(1), full_name="Henkie Kraggelwenk")
47
+        """
48
+
31 49
         d = data["person"]
32 50
         return cls(full_name=d["full_name"], aardbei_id=PersonId(d["id"]))
33 51
 
52
+    @property
53
+    def as_json_dict(self) -> Dict[str, Any]:
54
+        """
55
+        Serialize to a dictionary as provided by Aardbei.
56
+
57
+        >>> AardbeiPerson(aardbei_id=AardbeiId(1), full_name="Henkie Kraggelwenk").as_json_dict
58
+        {"person": {"id": 1, "full_name": "Henkie Kraggelwenk"}}
59
+        """
60
+
61
+        return {"person": {"id": self.aardbei_id, "full_name": self.full_name}}
62
+
34 63
 
35 64
 @dataclass(frozen=True)
36 65
 class AardbeiMember:
66
+    """
67
+    Contains the data on a Member exposed by Aardbei.
68
+
69
+    A Member represents the membership of a Person in a Group in Aardbei.
70
+    """
71
+
37 72
     person: AardbeiPerson
38 73
     aardbei_id: MemberId
39 74
     is_leader: bool
@@ -41,7 +76,28 @@ class AardbeiMember:
41 76
 
42 77
     @classmethod
43 78
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiMember:
44
-        logging.debug("Init with data %s", json.dumps(data))
79
+        """
80
+        Load from a dictionary provided by Aardbei.
81
+
82
+        >>> from_aardbei_dict({
83
+            "member": {
84
+                "person": {
85
+                    "full_name": "Roer Kuggelvork",
86
+                    "id": 2,
87
+                },
88
+                "id": 23,
89
+                "is_leader": False,
90
+                "display_name": "Roer",
91
+            },
92
+        })
93
+        AardbeiMember(
94
+            person=AardbeiPerson(aardbei_id=PersonId(2), full_name="Roer Kuggelvork"),
95
+            aardbei_id=MemberId(23),
96
+            is_leader=False,
97
+            display_name="Roer",
98
+        )
99
+        """
100
+
45 101
         d = data["member"]
46 102
         person = AardbeiPerson.from_aardbei_dict(d)
47 103
         return cls(
@@ -51,9 +107,46 @@ class AardbeiMember:
51 107
             display_name=d["display_name"],
52 108
         )
53 109
 
110
+    @property
111
+    def as_json_dict(self) -> Dict[str, Any]:
112
+        """
113
+        Serialize to a dict as provided by Aardbei.
114
+
115
+        >>> AardbeiMember(
116
+            person=AardbeiPerson(aardbei_id=PersonId(2), full_name="Roer Kuggelvork"),
117
+            aardbei_id=MemberId(23),
118
+            is_leader=False,
119
+            display_name="Roer",
120
+        )
121
+        {
122
+            "member": {
123
+                "person": {
124
+                    "full_name": "Roer Kuggelvork",
125
+                    "id": 2,
126
+                },
127
+                "id": 23,
128
+                "is_leader": False,
129
+                "display_name": "Roer",
130
+            }
131
+        }
132
+        """
133
+        res = {
134
+            "id": self.aardbei_id,
135
+            "is_leader": self.is_leader,
136
+            "display_name": self.display_name,
137
+        }
138
+        res.update(self.person.as_json_dict)
139
+        return res
140
+
54 141
 
55 142
 @dataclass(frozen=True)
56 143
 class AardbeiParticipant:
144
+    """
145
+    Represents a Participant as exposed by Aardbei.
146
+
147
+    A Participant represents the participation of a Person (optionally as a Member in a Group) in an Activity.
148
+    """
149
+
57 150
     person: AardbeiPerson
58 151
     member: Optional[AardbeiMember]
59 152
     aardbei_id: ParticipantId
@@ -63,6 +156,10 @@ class AardbeiParticipant:
63 156
 
64 157
     @property
65 158
     def name(self) -> str:
159
+        """
160
+        Return the name to show for this Participant.
161
+        This is the display_name if a Member is present, else the Participant's Person's full name.
162
+        """
66 163
         if self.member is not None:
67 164
             return self.member.display_name
68 165
 
@@ -70,6 +167,9 @@ class AardbeiParticipant:
70 167
 
71 168
     @classmethod
72 169
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiParticipant:
170
+        """
171
+        Load from a dictionary as provided by Aardbei.
172
+        """
73 173
         d = data["participant"]
74 174
         person = AardbeiPerson.from_aardbei_dict(d)
75 175
 
@@ -88,24 +188,53 @@ class AardbeiParticipant:
88 188
             notes=d["notes"],
89 189
         )
90 190
 
191
+    @property
192
+    def as_json_dict(self) -> Dict[str, Any]:
193
+        """
194
+        Serialize to a dict as provided by Aardbei.
195
+        """
196
+        res = {
197
+            "participant": {
198
+                "id": self.aardbei_id,
199
+                "attending": self.attending,
200
+                "is_organizer": self.is_organizer,
201
+                "notes": self.notes,
202
+            }
203
+        }
204
+        res.update(self.person.as_json_dict)
205
+        if self.member is not None:
206
+            res.update(self.member.as_json_dict)
207
+
208
+        return res
209
+
91 210
 
92 211
 class NoResponseAction(Enum):
212
+    """Represents the "no response action" attribute of Activities in Aardbei."""
213
+
93 214
     Present = "present"
94 215
     Absent = "absent"
95 216
 
96 217
 
97 218
 @dataclass(frozen=True)
98 219
 class ResponseCounts:
220
+    """Represents the "response counts" attribute of Activities in Aardbei."""
221
+
99 222
     present: int
100 223
     absent: int
101 224
     unknown: int
102 225
 
103 226
     @classmethod
104 227
     def from_aardbei_dict(cls, data: Dict[str, int]) -> ResponseCounts:
228
+        """Load from a dict as provided by Aardbei."""
105 229
         return cls(
106 230
             present=data["present"], absent=data["absent"], unknown=data["unknown"]
107 231
         )
108 232
 
233
+    @property
234
+    def as_json_dict(self) -> Dict[str, int]:
235
+        """Serialize to a dict as provided by Aardbei."""
236
+        return {"present": self.present, "absent": self.absent, "unknown": self.unknown}
237
+
109 238
 
110 239
 @dataclass(frozen=True)
111 240
 class SparseAardbeiActivity:
@@ -139,6 +268,7 @@ class SparseAardbeiActivity:
139 268
 
140 269
     @classmethod
141 270
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> SparseAardbeiActivity:
271
+        """Load from a dict as provided by Aardbei."""
142 272
         start: datetime.datetime = datetime.datetime.fromisoformat(
143 273
             data["activity"]["start"]
144 274
         )
@@ -176,31 +306,99 @@ class SparseAardbeiActivity:
176 306
             response_counts=response_counts,
177 307
         )
178 308
 
309
+    @property
310
+    def as_json_dict(self) -> Dict[str, Any]:
311
+        """Serialize to a dict as provided by Aardbei."""
312
+        return {
313
+            "activity": {
314
+                "id": self.aardbei_id,
315
+                "name": self.name,
316
+                "description": self.description,
317
+                "location": self.location,
318
+                "start": fmt_datetime(self.start),
319
+                "end": fmt_datetime(self.end),
320
+                "deadline": fmt_datetime(self.deadline),
321
+                "reminder_at": fmt_datetime(self.reminder_at),
322
+                "no_response_action": self.no_response_action.value,
323
+                "response_counts": self.response_counts.as_json_dict,
324
+            }
325
+        }
326
+
179 327
 
180 328
 @dataclass(frozen=True)
181 329
 class AardbeiActivity(SparseAardbeiActivity):
330
+    """Contains the data of an Activity as exposed by Aardbei."""
331
+
182 332
     participants: List[AardbeiParticipant]
183 333
 
184 334
     @classmethod
185 335
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiActivity:
336
+        """Load from a dict as provided by Aardbei."""
337
+        # Ugly: This is a copy of the Sparse variant with added participants.
338
+        # This is not ideal, but I don't care enough to fix this right now.
186 339
         participants: List[AardbeiParticipant] = [
187 340
             AardbeiParticipant.from_aardbei_dict(x)
188 341
             for x in data["activity"]["participants"]
189 342
         ]
190 343
 
191
-        sparse = super().from_aardbei_dict(data)
344
+        start: datetime.datetime = datetime.datetime.fromisoformat(
345
+            data["activity"]["start"]
346
+        )
347
+        end: Optional[datetime.datetime] = None
348
+
349
+        if data["activity"]["end"] is not None:
350
+            end = datetime.datetime.fromisoformat(data["activity"]["end"])
351
+
352
+        deadline: Optional[datetime.datetime] = None
353
+        if data["activity"]["deadline"] is not None:
354
+            deadline = datetime.datetime.fromisoformat(data["activity"]["deadline"])
355
+
356
+        reminder_at: Optional[datetime.datetime] = None
357
+        if data["activity"]["reminder_at"] is not None:
358
+            reminder_at = datetime.datetime.fromisoformat(
359
+                data["activity"]["reminder_at"]
360
+            )
361
+
362
+        no_response_action = NoResponseAction(data["activity"]["no_response_action"])
363
+
364
+        response_counts = ResponseCounts.from_aardbei_dict(
365
+            data["activity"]["response_counts"]
366
+        )
367
+
368
+        return cls(
369
+            aardbei_id=ActivityId(data["activity"]["id"]),
370
+            name=data["activity"]["name"],
371
+            description=data["activity"]["description"],
372
+            location=data["activity"]["location"],
373
+            start=start,
374
+            end=end,
375
+            deadline=deadline,
376
+            reminder_at=reminder_at,
377
+            no_response_action=no_response_action,
378
+            response_counts=response_counts,
379
+            participants=participants,
380
+        )
192 381
 
193
-        return cls(participants=participants, **asdict(sparse))
382
+    @property
383
+    def as_json_dict(self) -> Dict[str, Any]:
384
+        """Serialize to a dict as provided by Aardbei."""
385
+        res = super().as_json_dict
386
+        res["participants"] = [p.as_json_dict for p in self.participants]
387
+        return res
194 388
 
195 389
 
196 390
 @dataclass(frozen=True)
197 391
 class AardbeiMatch:
392
+    """Represents a match between a local Person and a Person present in Aardbei's data."""
393
+
198 394
     local: Person
199 395
     remote: AardbeiMember
200 396
 
201 397
 
202 398
 @dataclass(frozen=True)
203 399
 class AardbeiLink:
400
+    """Represents a set of differences between the local state and Aardbei's set of people."""
401
+
204 402
     matches: List[AardbeiMatch]
205 403
     """People that exist on both sides, but aren't linked in the people table."""
206 404
     altered_name: List[AardbeiMatch]
@@ -208,12 +406,36 @@ class AardbeiLink:
208 406
     remote_only: List[AardbeiMember]
209 407
     """People that only exist on the remote."""
210 408
 
409
+    @property
410
+    def num_changes(self) -> int:
411
+        """Return the amount of mismatching people between Aardbei and the local state."""
412
+        return len(self.matches) + len(self.altered_name) + len(self.remote_only)
211 413
 
212
-def get_aardbei_people(token: str) -> List[AardbeiMember]:
213
-    resp = requests.get(
214
-        f"{AARDBEI_ENDPOINT}/api/groups/0/", headers={"Authorization": f"Group {token}"}
215
-    )
216
-    resp.raise_for_status()
414
+
415
+class AardbeiSyncError(Enum):
416
+    """Represents errors that might occur when retrieving data from Aardbei."""
417
+
418
+    CantConnect = "connect_fail"
419
+    HTTPError = "http_fail"
420
+
421
+
422
+def get_aardbei_people(
423
+    token: str, endpoint: str = AARDBEI_ENDPOINT
424
+) -> Union[List[AardbeiMember], AardbeiSyncError]:
425
+    """Retrieve the set of People in a Group from Aardbei, and parse this to
426
+    AardbeiPerson objects. Return a AardbeiSyncError if something fails."""
427
+    try:
428
+        resp: requests.Response = requests.get(
429
+            f"{AARDBEI_ENDPOINT}/api/groups/0/",
430
+            headers={"Authorization": f"Group {token}"},
431
+        )
432
+        resp.raise_for_status()
433
+
434
+    except requests.ConnectionError:
435
+        return AardbeiSyncError.CantConnect
436
+
437
+    except requests.HTTPError:
438
+        return AardbeiSyncError.HTTPError
217 439
 
218 440
     members = resp.json()["group"]["members"]
219 441
 
@@ -221,6 +443,10 @@ def get_aardbei_people(token: str) -> List[AardbeiMember]:
221 443
 
222 444
 
223 445
 def match_local_aardbei(aardbei_members: List[AardbeiMember]) -> AardbeiLink:
446
+    """Inspect the local state and compare it with the set of given
447
+    AardbeiMembers (containing AardbeiPersons). Return a AardbeiLink that
448
+    indicates which local people don't match the remote state."""
449
+
224 450
     matches: List[AardbeiMatch] = []
225 451
     altered_name: List[AardbeiMatch] = []
226 452
     remote_only: List[AardbeiMember] = []
@@ -259,6 +485,12 @@ def match_local_aardbei(aardbei_members: List[AardbeiMember]) -> AardbeiLink:
259 485
 
260 486
 
261 487
 def link_matches(matches: List[AardbeiMatch]) -> None:
488
+    """
489
+    Update local people to add the remote ID to the local state.
490
+    This only enqueues the changes in the local SQLAlchemy session, committing
491
+    needs to be done separately.
492
+    """
493
+
262 494
     for match in matches:
263 495
         match.local.aardbei_id = match.remote.aardbei_id
264 496
         match.local.display_name = match.remote.display_name
@@ -274,6 +506,12 @@ def link_matches(matches: List[AardbeiMatch]) -> None:
274 506
 
275 507
 
276 508
 def create_missing(missing: List[AardbeiMember]) -> None:
509
+    """
510
+    Create local people for all remote people that don't exist locally.
511
+    This only enqueues the changes in the local SQLAlchemy session, committing
512
+    needs to be done separately.
513
+    """
514
+
277 515
     for member in missing:
278 516
         pnew = Person(
279 517
             full_name=member.person.full_name,
@@ -291,6 +529,14 @@ def create_missing(missing: List[AardbeiMember]) -> None:
291 529
 
292 530
 
293 531
 def update_names(matches: List[AardbeiMatch]) -> None:
532
+    """
533
+    Update the local full and display names of people that were already linked
534
+    to a remote person, and who changed names on the remote.
535
+
536
+    This only enqueues the changes in the local SQLAlchemy session, committing
537
+    needs to be done separately.
538
+    """
539
+
294 540
     for match in matches:
295 541
         p = match.local
296 542
         member = match.remote
@@ -327,21 +573,79 @@ def update_names(matches: List[AardbeiMatch]) -> None:
327 573
         db.session.add(p)
328 574
 
329 575
 
330
-def get_activities(token: str) -> List[SparseAardbeiActivity]:
576
+def get_activities(
577
+    token: str, endpoint: str = AARDBEI_ENDPOINT
578
+) -> Union[List[SparseAardbeiActivity], AardbeiSyncError]:
579
+    """
580
+    Get the list of activities present on the remote and return these
581
+    activities, ordered by the temporal distance to the current time.
582
+    """
583
+
331 584
     result: List[SparseAardbeiActivity] = []
332 585
 
333 586
     for category in ("upcoming", "current", "previous"):
587
+        try:
588
+            resp = requests.get(
589
+                f"{endpoint}/api/groups/0/{category}_activities",
590
+                headers={"Authorization": f"Group {token}"},
591
+            )
592
+
593
+            resp.raise_for_status()
594
+
595
+        except requests.HTTPError as e:
596
+            log.exception(e)
597
+            return AardbeiSyncError.HTTPError
598
+
599
+        except requests.ConnectionError as e:
600
+            log.exception(e)
601
+            return AardbeiSyncError.CantConnect
602
+
603
+        for item in resp.json():
604
+            result.append(SparseAardbeiActivity.from_aardbei_dict(item))
605
+
606
+    now = datetime.datetime.now()
607
+    result.sort(key=lambda x: SparseAardbeiActivity.distance(x, now))
608
+    return result
609
+
610
+
611
+def get_activity(
612
+    activity_id: ActivityId, token: str, endpoint: str
613
+) -> Union[AardbeiActivity, AardbeiSyncError]:
614
+    """
615
+    Get all data (including participants) from the remote about one activity
616
+    with a given ID.
617
+    """
618
+
619
+    try:
334 620
         resp = requests.get(
335
-            f"{AARDBEI_ENDPOINT}/api/groups/0/{category}_activities",
621
+            f"{endpoint}/api/activities/{activity_id}",
336 622
             headers={"Authorization": f"Group {token}"},
337 623
         )
338 624
 
339 625
         resp.raise_for_status()
340 626
 
341
-        for item in resp.json():
342
-            result.append(SparseAardbeiActivity.from_aardbei_dict(item))
627
+    except requests.HTTPError as e:
628
+        log.exception(e)
629
+        return AardbeiSyncError.HTTPError
343 630
 
344
-    return result
631
+    except requests.ConnectionError as e:
632
+        return AardbeiSyncError.CantConnect
633
+
634
+    return AardbeiActivity.from_aardbei_dict(resp.json())
635
+
636
+
637
+def match_activity(activity: AardbeiActivity) -> None:
638
+    """
639
+    Update the local state to have mark all people present at the given
640
+    activity as active, and all other people as inactive.
641
+    """
642
+    ps = activity.participants
643
+    pids: List[PersonId] = [p.person.aardbei_id for p in ps]
644
+
645
+    Person.query.update(values={"active": False})
646
+    Person.query.filter(Person.aardbei_id.in_(pids)).update(
647
+        values={"active": True}, synchronize_session="fetch"
648
+    )
345 649
 
346 650
 
347 651
 if __name__ == "__main__":
@@ -349,8 +653,19 @@ if __name__ == "__main__":
349 653
 
350 654
     token = input("Token: ")
351 655
     aardbei_people = get_aardbei_people(token)
656
+
657
+    if isinstance(aardbei_people, AardbeiSyncError):
658
+        logging.error("Could not get people: %s", aardbei_people.value)
659
+        sys.exit(1)
660
+
352 661
     activities = get_activities(token)
662
+
663
+    if isinstance(activities, AardbeiSyncError):
664
+        logging.error("Could not get activities: %s", activities.value)
665
+        sys.exit(1)
666
+
353 667
     link = match_local_aardbei(aardbei_people)
668
+
354 669
     link_matches(link.matches)
355 670
     create_missing(link.remote_only)
356 671
     update_names(link.altered_name)

+ 9 - 11
piket_server/alembic/env.py

@@ -12,26 +12,24 @@ config = context.config
12 12
 # This line sets up loggers basically.
13 13
 fileConfig(config.config_file_name)
14 14
 
15
-# add your model's MetaData object here
16
-# for 'autogenerate' support
17
-# from myapp import mymodel
18
-# target_metadata = mymodel.Base.metadata
19
-import piket_server
20
-
21
-target_metadata = piket_server.db.Model.metadata
22
-
23 15
 # other values from the config, defined by the needs of env.py,
24 16
 # can be acquired:
25 17
 # my_important_option = config.get_main_option("my_important_option")
26 18
 # ... etc.
27
-from piket_server import CONFIG_DIR, DB_URL
19
+from piket_server.flask import CONFIG_DIR, DB_URL, db
20
+
21
+# add your model's MetaData object here
22
+# for 'autogenerate' support
23
+# from myapp import mymodel
24
+# target_metadata = mymodel.Base.metadata
25
+target_metadata = db.Model.metadata
28 26
 
29 27
 os.makedirs(os.path.expanduser(CONFIG_DIR), mode=0o744, exist_ok=True)
30 28
 
31 29
 config.file_config["alembic"]["sqlalchemy.url"] = DB_URL
32 30
 
33 31
 
34
-def run_migrations_offline():
32
+def run_migrations_offline() -> None:
35 33
     """Run migrations in 'offline' mode.
36 34
 
37 35
     This configures the context with just a URL
@@ -50,7 +48,7 @@ def run_migrations_offline():
50 48
         context.run_migrations()
51 49
 
52 50
 
53
-def run_migrations_online():
51
+def run_migrations_online() -> None:
54 52
     """Run migrations in 'online' mode.
55 53
 
56 54
     In this scenario we need to create an Engine

+ 121 - 0
piket_server/routes/aardbei.py

@@ -0,0 +1,121 @@
1
+from typing import Any, Dict, List, Tuple, Union
2
+
3
+from flask import request
4
+
5
+from piket_server.aardbei_sync import (
6
+    AARDBEI_ENDPOINT,
7
+    ActivityId,
8
+    get_activity,
9
+    AardbeiLink,
10
+    AardbeiSyncError,
11
+    create_missing,
12
+    get_aardbei_people,
13
+    match_activity,
14
+    get_activities,
15
+    link_matches,
16
+    match_local_aardbei,
17
+    update_names,
18
+)
19
+from piket_server.flask import app, db
20
+
21
+
22
+def common_prepare_aardbei_sync(
23
+    token: str, endpoint: str
24
+) -> Union[AardbeiSyncError, AardbeiLink]:
25
+    aardbei_people = get_aardbei_people(token, endpoint)
26
+
27
+    if isinstance(aardbei_people, AardbeiSyncError):
28
+        return aardbei_people
29
+
30
+    aardbei_activities = get_activities(token, endpoint)
31
+
32
+    if isinstance(aardbei_activities, AardbeiSyncError):
33
+        return aardbei_activities
34
+
35
+    return match_local_aardbei(aardbei_people)
36
+
37
+
38
+@app.route("/people/aardbei_diff", methods=["POST"])
39
+def aardbei_diff() -> Tuple[Dict[str, Any], int]:
40
+    data: Dict[str, str] = request.json
41
+    link = common_prepare_aardbei_sync(
42
+        data["token"], data.get("endpoint", AARDBEI_ENDPOINT)
43
+    )
44
+
45
+    if isinstance(link, AardbeiSyncError):
46
+        return {"error": link.value}, 503
47
+
48
+    return (
49
+        {
50
+            "num_changes": link.num_changes,
51
+            "new_people": [member.person.full_name for member in link.remote_only],
52
+            "link_existing": [match.local.name for match in link.matches],
53
+            "altered_name": [match.local.name for match in link.matches],
54
+        },
55
+        200,
56
+    )
57
+
58
+
59
+@app.route("/people/aardbei_apply", methods=["POST"])
60
+def aardbei_apply() -> Union[Tuple[Dict[str, Any], int]]:
61
+    data: Dict[str, str] = request.json
62
+    link = common_prepare_aardbei_sync(
63
+        data["token"], data.get("endpoint", AARDBEI_ENDPOINT)
64
+    )
65
+
66
+    if isinstance(link, AardbeiSyncError):
67
+        return {"error": link.value}, 503
68
+
69
+    link_matches(link.matches)
70
+    create_missing(link.remote_only)
71
+    update_names(link.altered_name)
72
+
73
+    db.session.commit()
74
+
75
+    return (
76
+        {
77
+            "num_changes": link.num_changes,
78
+            "new_people": [member.person.full_name for member in link.remote_only],
79
+            "link_existing": [match.local.name for match in link.matches],
80
+            "altered_name": [match.local.name for match in link.matches],
81
+        },
82
+        200,
83
+    )
84
+
85
+
86
+@app.route("/aardbei/get_activities", methods=["POST"])
87
+def aardbei_get_activities() -> Tuple[Dict[str, object], int]:
88
+    data: Dict[str, str] = request.json
89
+    activities = get_activities(data["token"], data.get("endpoint", AARDBEI_ENDPOINT))
90
+
91
+    if isinstance(activities, AardbeiSyncError):
92
+        return {"error": activities.value}, 503
93
+
94
+    return {"activities": [x.as_json_dict for x in activities]}, 200
95
+
96
+
97
+@app.route("/aardbei/apply_activity", methods=["POST"])
98
+def aardbei_apply_activity() -> Tuple[Dict[str, Any], int]:
99
+    data: Dict[str, Union[str, int]] = request.json
100
+    aid = data["activity_id"]
101
+    token = data["token"]
102
+    endpoint = data["endpoint"]
103
+
104
+    if not isinstance(aid, int):
105
+        return {"error": "nonnumeric_activity_id"}, 400
106
+
107
+    if not isinstance(token, str):
108
+        return {"error": "illtyped_token"}, 400
109
+
110
+    if not isinstance(endpoint, str):
111
+        return {"error": "illtyped_endpoint"}, 400
112
+
113
+    activity = get_activity(activity_id=ActivityId(aid), token=token, endpoint=endpoint)
114
+
115
+    if isinstance(activity, AardbeiSyncError):
116
+        return {"error": activity.value}, 503
117
+
118
+    match_activity(activity)
119
+    db.session.commit()
120
+
121
+    return (activity.as_json_dict, 200)

+ 10 - 0
piket_server/util.py

@@ -0,0 +1,10 @@
1
+import datetime
2
+from typing import Optional
3
+
4
+
5
+def fmt_datetime(x: Optional[datetime.datetime]) -> Optional[str]:
6
+    """Format a datetime as ISO 8601, if it's not None."""
7
+    if x is not None:
8
+        return x.isoformat()
9
+
10
+    return None