|  | @@ -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)
 |