Browse Source

Add matching local state with an activity

Maarten van den Berg 5 years ago
parent
commit
d7635b8ba0

+ 1 - 0
piket_server/__init__.py

10
 import piket_server.routes.consumption_types
10
 import piket_server.routes.consumption_types
11
 import piket_server.routes.settlements
11
 import piket_server.routes.settlements
12
 import piket_server.routes.exports
12
 import piket_server.routes.exports
13
+import piket_server.routes.aardbei

+ 330 - 15
piket_server/aardbei_sync.py

3
 import datetime
3
 import datetime
4
 import json
4
 import json
5
 import logging
5
 import logging
6
+import sys
6
 from dataclasses import asdict, dataclass
7
 from dataclasses import asdict, dataclass
7
 from enum import Enum
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
 import requests
11
 import requests
11
 
12
 
12
-from piket_server.models import Person
13
 from piket_server.flask import db
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
 # AARDBEI_ENDPOINT = "https://aardbei.app"
17
 # AARDBEI_ENDPOINT = "https://aardbei.app"
16
 AARDBEI_ENDPOINT = "http://localhost:3000"
18
 AARDBEI_ENDPOINT = "http://localhost:3000"
19
+log = logging.getLogger(__name__)
17
 
20
 
18
 ActivityId = NewType("ActivityId", int)
21
 ActivityId = NewType("ActivityId", int)
19
 PersonId = NewType("PersonId", int)
22
 PersonId = NewType("PersonId", int)
23
 
26
 
24
 @dataclass(frozen=True)
27
 @dataclass(frozen=True)
25
 class AardbeiPerson:
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
     aardbei_id: PersonId
35
     aardbei_id: PersonId
27
     full_name: str
36
     full_name: str
28
 
37
 
29
     @classmethod
38
     @classmethod
30
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiPerson:
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
         d = data["person"]
49
         d = data["person"]
32
         return cls(full_name=d["full_name"], aardbei_id=PersonId(d["id"]))
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
 @dataclass(frozen=True)
64
 @dataclass(frozen=True)
36
 class AardbeiMember:
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
     person: AardbeiPerson
72
     person: AardbeiPerson
38
     aardbei_id: MemberId
73
     aardbei_id: MemberId
39
     is_leader: bool
74
     is_leader: bool
41
 
76
 
42
     @classmethod
77
     @classmethod
43
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiMember:
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
         d = data["member"]
101
         d = data["member"]
46
         person = AardbeiPerson.from_aardbei_dict(d)
102
         person = AardbeiPerson.from_aardbei_dict(d)
47
         return cls(
103
         return cls(
51
             display_name=d["display_name"],
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
 @dataclass(frozen=True)
142
 @dataclass(frozen=True)
56
 class AardbeiParticipant:
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
     person: AardbeiPerson
150
     person: AardbeiPerson
58
     member: Optional[AardbeiMember]
151
     member: Optional[AardbeiMember]
59
     aardbei_id: ParticipantId
152
     aardbei_id: ParticipantId
63
 
156
 
64
     @property
157
     @property
65
     def name(self) -> str:
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
         if self.member is not None:
163
         if self.member is not None:
67
             return self.member.display_name
164
             return self.member.display_name
68
 
165
 
70
 
167
 
71
     @classmethod
168
     @classmethod
72
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiParticipant:
169
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiParticipant:
170
+        """
171
+        Load from a dictionary as provided by Aardbei.
172
+        """
73
         d = data["participant"]
173
         d = data["participant"]
74
         person = AardbeiPerson.from_aardbei_dict(d)
174
         person = AardbeiPerson.from_aardbei_dict(d)
75
 
175
 
88
             notes=d["notes"],
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
 class NoResponseAction(Enum):
211
 class NoResponseAction(Enum):
212
+    """Represents the "no response action" attribute of Activities in Aardbei."""
213
+
93
     Present = "present"
214
     Present = "present"
94
     Absent = "absent"
215
     Absent = "absent"
95
 
216
 
96
 
217
 
97
 @dataclass(frozen=True)
218
 @dataclass(frozen=True)
98
 class ResponseCounts:
219
 class ResponseCounts:
220
+    """Represents the "response counts" attribute of Activities in Aardbei."""
221
+
99
     present: int
222
     present: int
100
     absent: int
223
     absent: int
101
     unknown: int
224
     unknown: int
102
 
225
 
103
     @classmethod
226
     @classmethod
104
     def from_aardbei_dict(cls, data: Dict[str, int]) -> ResponseCounts:
227
     def from_aardbei_dict(cls, data: Dict[str, int]) -> ResponseCounts:
228
+        """Load from a dict as provided by Aardbei."""
105
         return cls(
229
         return cls(
106
             present=data["present"], absent=data["absent"], unknown=data["unknown"]
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
 @dataclass(frozen=True)
239
 @dataclass(frozen=True)
111
 class SparseAardbeiActivity:
240
 class SparseAardbeiActivity:
139
 
268
 
140
     @classmethod
269
     @classmethod
141
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> SparseAardbeiActivity:
270
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> SparseAardbeiActivity:
271
+        """Load from a dict as provided by Aardbei."""
142
         start: datetime.datetime = datetime.datetime.fromisoformat(
272
         start: datetime.datetime = datetime.datetime.fromisoformat(
143
             data["activity"]["start"]
273
             data["activity"]["start"]
144
         )
274
         )
176
             response_counts=response_counts,
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
 @dataclass(frozen=True)
328
 @dataclass(frozen=True)
181
 class AardbeiActivity(SparseAardbeiActivity):
329
 class AardbeiActivity(SparseAardbeiActivity):
330
+    """Contains the data of an Activity as exposed by Aardbei."""
331
+
182
     participants: List[AardbeiParticipant]
332
     participants: List[AardbeiParticipant]
183
 
333
 
184
     @classmethod
334
     @classmethod
185
     def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiActivity:
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
         participants: List[AardbeiParticipant] = [
339
         participants: List[AardbeiParticipant] = [
187
             AardbeiParticipant.from_aardbei_dict(x)
340
             AardbeiParticipant.from_aardbei_dict(x)
188
             for x in data["activity"]["participants"]
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
 @dataclass(frozen=True)
390
 @dataclass(frozen=True)
197
 class AardbeiMatch:
391
 class AardbeiMatch:
392
+    """Represents a match between a local Person and a Person present in Aardbei's data."""
393
+
198
     local: Person
394
     local: Person
199
     remote: AardbeiMember
395
     remote: AardbeiMember
200
 
396
 
201
 
397
 
202
 @dataclass(frozen=True)
398
 @dataclass(frozen=True)
203
 class AardbeiLink:
399
 class AardbeiLink:
400
+    """Represents a set of differences between the local state and Aardbei's set of people."""
401
+
204
     matches: List[AardbeiMatch]
402
     matches: List[AardbeiMatch]
205
     """People that exist on both sides, but aren't linked in the people table."""
403
     """People that exist on both sides, but aren't linked in the people table."""
206
     altered_name: List[AardbeiMatch]
404
     altered_name: List[AardbeiMatch]
208
     remote_only: List[AardbeiMember]
406
     remote_only: List[AardbeiMember]
209
     """People that only exist on the remote."""
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
     members = resp.json()["group"]["members"]
440
     members = resp.json()["group"]["members"]
219
 
441
 
221
 
443
 
222
 
444
 
223
 def match_local_aardbei(aardbei_members: List[AardbeiMember]) -> AardbeiLink:
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
     matches: List[AardbeiMatch] = []
450
     matches: List[AardbeiMatch] = []
225
     altered_name: List[AardbeiMatch] = []
451
     altered_name: List[AardbeiMatch] = []
226
     remote_only: List[AardbeiMember] = []
452
     remote_only: List[AardbeiMember] = []
259
 
485
 
260
 
486
 
261
 def link_matches(matches: List[AardbeiMatch]) -> None:
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
     for match in matches:
494
     for match in matches:
263
         match.local.aardbei_id = match.remote.aardbei_id
495
         match.local.aardbei_id = match.remote.aardbei_id
264
         match.local.display_name = match.remote.display_name
496
         match.local.display_name = match.remote.display_name
274
 
506
 
275
 
507
 
276
 def create_missing(missing: List[AardbeiMember]) -> None:
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
     for member in missing:
515
     for member in missing:
278
         pnew = Person(
516
         pnew = Person(
279
             full_name=member.person.full_name,
517
             full_name=member.person.full_name,
291
 
529
 
292
 
530
 
293
 def update_names(matches: List[AardbeiMatch]) -> None:
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
     for match in matches:
540
     for match in matches:
295
         p = match.local
541
         p = match.local
296
         member = match.remote
542
         member = match.remote
327
         db.session.add(p)
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
     result: List[SparseAardbeiActivity] = []
584
     result: List[SparseAardbeiActivity] = []
332
 
585
 
333
     for category in ("upcoming", "current", "previous"):
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
         resp = requests.get(
620
         resp = requests.get(
335
-            f"{AARDBEI_ENDPOINT}/api/groups/0/{category}_activities",
621
+            f"{endpoint}/api/activities/{activity_id}",
336
             headers={"Authorization": f"Group {token}"},
622
             headers={"Authorization": f"Group {token}"},
337
         )
623
         )
338
 
624
 
339
         resp.raise_for_status()
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
 if __name__ == "__main__":
651
 if __name__ == "__main__":
349
 
653
 
350
     token = input("Token: ")
654
     token = input("Token: ")
351
     aardbei_people = get_aardbei_people(token)
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
     activities = get_activities(token)
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
     link = match_local_aardbei(aardbei_people)
667
     link = match_local_aardbei(aardbei_people)
668
+
354
     link_matches(link.matches)
669
     link_matches(link.matches)
355
     create_missing(link.remote_only)
670
     create_missing(link.remote_only)
356
     update_names(link.altered_name)
671
     update_names(link.altered_name)

+ 9 - 11
piket_server/alembic/env.py

12
 # This line sets up loggers basically.
12
 # This line sets up loggers basically.
13
 fileConfig(config.config_file_name)
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
 # other values from the config, defined by the needs of env.py,
15
 # other values from the config, defined by the needs of env.py,
24
 # can be acquired:
16
 # can be acquired:
25
 # my_important_option = config.get_main_option("my_important_option")
17
 # my_important_option = config.get_main_option("my_important_option")
26
 # ... etc.
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
 os.makedirs(os.path.expanduser(CONFIG_DIR), mode=0o744, exist_ok=True)
27
 os.makedirs(os.path.expanduser(CONFIG_DIR), mode=0o744, exist_ok=True)
30
 
28
 
31
 config.file_config["alembic"]["sqlalchemy.url"] = DB_URL
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
     """Run migrations in 'offline' mode.
33
     """Run migrations in 'offline' mode.
36
 
34
 
37
     This configures the context with just a URL
35
     This configures the context with just a URL
50
         context.run_migrations()
48
         context.run_migrations()
51
 
49
 
52
 
50
 
53
-def run_migrations_online():
51
+def run_migrations_online() -> None:
54
     """Run migrations in 'online' mode.
52
     """Run migrations in 'online' mode.
55
 
53
 
56
     In this scenario we need to create an Engine
54
     In this scenario we need to create an Engine

+ 121 - 0
piket_server/routes/aardbei.py

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

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