Digitale bierlijst

aardbei_sync.py 10KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. from __future__ import annotations
  2. import datetime
  3. import json
  4. import logging
  5. from dataclasses import asdict, dataclass
  6. from enum import Enum
  7. from typing import Any, Dict, List, NewType, Optional, Tuple
  8. import requests
  9. from piket_server.models import Person
  10. from piket_server.flask import db
  11. # AARDBEI_ENDPOINT = "https://aardbei.app"
  12. AARDBEI_ENDPOINT = "http://localhost:3000"
  13. ActivityId = NewType("ActivityId", int)
  14. PersonId = NewType("PersonId", int)
  15. MemberId = NewType("MemberId", int)
  16. ParticipantId = NewType("ParticipantId", int)
  17. @dataclass(frozen=True)
  18. class AardbeiPerson:
  19. aardbei_id: PersonId
  20. full_name: str
  21. @classmethod
  22. def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiPerson:
  23. d = data["person"]
  24. return cls(full_name=d["full_name"], aardbei_id=PersonId(d["id"]))
  25. @dataclass(frozen=True)
  26. class AardbeiMember:
  27. person: AardbeiPerson
  28. aardbei_id: MemberId
  29. is_leader: bool
  30. display_name: str
  31. @classmethod
  32. def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiMember:
  33. logging.debug("Init with data %s", json.dumps(data))
  34. d = data["member"]
  35. person = AardbeiPerson.from_aardbei_dict(d)
  36. return cls(
  37. person=person,
  38. aardbei_id=MemberId(d["id"]),
  39. is_leader=d["is_leader"],
  40. display_name=d["display_name"],
  41. )
  42. @dataclass(frozen=True)
  43. class AardbeiParticipant:
  44. person: AardbeiPerson
  45. member: Optional[AardbeiMember]
  46. aardbei_id: ParticipantId
  47. attending: bool
  48. is_organizer: bool
  49. notes: Optional[str]
  50. @property
  51. def name(self) -> str:
  52. if self.member is not None:
  53. return self.member.display_name
  54. return self.person.full_name
  55. @classmethod
  56. def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiParticipant:
  57. d = data["participant"]
  58. person = AardbeiPerson.from_aardbei_dict(d)
  59. member: Optional[AardbeiMember] = None
  60. if d["member"] is not None:
  61. member = AardbeiMember.from_aardbei_dict(d)
  62. aardbei_id = ParticipantId(d["id"])
  63. return cls(
  64. person=person,
  65. member=member,
  66. aardbei_id=aardbei_id,
  67. attending=d["attending"],
  68. is_organizer=d["is_organizer"],
  69. notes=d["notes"],
  70. )
  71. class NoResponseAction(Enum):
  72. Present = "present"
  73. Absent = "absent"
  74. @dataclass(frozen=True)
  75. class ResponseCounts:
  76. present: int
  77. absent: int
  78. unknown: int
  79. @classmethod
  80. def from_aardbei_dict(cls, data: Dict[str, int]) -> ResponseCounts:
  81. return cls(
  82. present=data["present"], absent=data["absent"], unknown=data["unknown"]
  83. )
  84. @dataclass(frozen=True)
  85. class SparseAardbeiActivity:
  86. aardbei_id: ActivityId
  87. name: str
  88. description: str
  89. location: str
  90. start: datetime.datetime
  91. end: Optional[datetime.datetime]
  92. deadline: Optional[datetime.datetime]
  93. reminder_at: Optional[datetime.datetime]
  94. no_response_action: NoResponseAction
  95. response_counts: ResponseCounts
  96. def distance(self, reference: datetime.datetime) -> datetime.timedelta:
  97. """Calculate how long ago this Activity ended / how much time until it starts."""
  98. if self.end is not None:
  99. if reference > self.start and reference < self.end:
  100. return datetime.timedelta(seconds=0)
  101. elif reference < self.start:
  102. return self.start - reference
  103. elif reference > self.end:
  104. return reference - self.end
  105. if reference > self.start:
  106. return reference - self.start
  107. return self.start - reference
  108. @classmethod
  109. def from_aardbei_dict(cls, data: Dict[str, Any]) -> SparseAardbeiActivity:
  110. start: datetime.datetime = datetime.datetime.fromisoformat(
  111. data["activity"]["start"]
  112. )
  113. end: Optional[datetime.datetime] = None
  114. if data["activity"]["end"] is not None:
  115. end = datetime.datetime.fromisoformat(data["activity"]["end"])
  116. deadline: Optional[datetime.datetime] = None
  117. if data["activity"]["deadline"] is not None:
  118. deadline = datetime.datetime.fromisoformat(data["activity"]["deadline"])
  119. reminder_at: Optional[datetime.datetime] = None
  120. if data["activity"]["reminder_at"] is not None:
  121. reminder_at = datetime.datetime.fromisoformat(
  122. data["activity"]["reminder_at"]
  123. )
  124. no_response_action = NoResponseAction(data["activity"]["no_response_action"])
  125. response_counts = ResponseCounts.from_aardbei_dict(
  126. data["activity"]["response_counts"]
  127. )
  128. return cls(
  129. aardbei_id=ActivityId(data["activity"]["id"]),
  130. name=data["activity"]["name"],
  131. description=data["activity"]["description"],
  132. location=data["activity"]["location"],
  133. start=start,
  134. end=end,
  135. deadline=deadline,
  136. reminder_at=reminder_at,
  137. no_response_action=no_response_action,
  138. response_counts=response_counts,
  139. )
  140. @dataclass(frozen=True)
  141. class AardbeiActivity(SparseAardbeiActivity):
  142. participants: List[AardbeiParticipant]
  143. @classmethod
  144. def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiActivity:
  145. participants: List[AardbeiParticipant] = [
  146. AardbeiParticipant.from_aardbei_dict(x)
  147. for x in data["activity"]["participants"]
  148. ]
  149. sparse = super().from_aardbei_dict(data)
  150. return cls(participants=participants, **asdict(sparse))
  151. @dataclass(frozen=True)
  152. class AardbeiMatch:
  153. local: Person
  154. remote: AardbeiMember
  155. @dataclass(frozen=True)
  156. class AardbeiLink:
  157. matches: List[AardbeiMatch]
  158. """People that exist on both sides, but aren't linked in the people table."""
  159. altered_name: List[AardbeiMatch]
  160. """People that are already linked but changed one of their names."""
  161. remote_only: List[AardbeiMember]
  162. """People that only exist on the remote."""
  163. def get_aardbei_people(token: str) -> List[AardbeiMember]:
  164. resp = requests.get(
  165. f"{AARDBEI_ENDPOINT}/api/groups/0/", headers={"Authorization": f"Group {token}"}
  166. )
  167. resp.raise_for_status()
  168. members = resp.json()["group"]["members"]
  169. return [AardbeiMember.from_aardbei_dict(x) for x in members]
  170. def match_local_aardbei(aardbei_members: List[AardbeiMember]) -> AardbeiLink:
  171. matches: List[AardbeiMatch] = []
  172. altered_name: List[AardbeiMatch] = []
  173. remote_only: List[AardbeiMember] = []
  174. for member in aardbei_members:
  175. p: Optional[Person] = Person.query.filter_by(
  176. aardbei_id=member.aardbei_id
  177. ).one_or_none()
  178. if p is not None:
  179. if (
  180. p.full_name != member.person.full_name
  181. or p.display_name != member.display_name
  182. ):
  183. altered_name.append(AardbeiMatch(p, member))
  184. else:
  185. logging.info(
  186. "OK: %s / %s (L%s/R%s)",
  187. p.full_name,
  188. p.display_name,
  189. p.person_id,
  190. p.aardbei_id,
  191. )
  192. continue
  193. p = Person.query.filter_by(full_name=member.person.full_name).one_or_none()
  194. if p is not None:
  195. matches.append(AardbeiMatch(p, member))
  196. else:
  197. remote_only.append(member)
  198. return AardbeiLink(matches, altered_name, remote_only)
  199. def link_matches(matches: List[AardbeiMatch]) -> None:
  200. for match in matches:
  201. match.local.aardbei_id = match.remote.aardbei_id
  202. match.local.display_name = match.remote.display_name
  203. logging.info(
  204. "Linking local %s (%s) to remote %s (%s)",
  205. match.local.full_name,
  206. match.local.person_id,
  207. match.remote.display_name,
  208. match.remote.aardbei_id,
  209. )
  210. db.session.add(match.local)
  211. def create_missing(missing: List[AardbeiMember]) -> None:
  212. for member in missing:
  213. pnew = Person(
  214. full_name=member.person.full_name,
  215. display_name=member.display_name,
  216. aardbei_id=member.aardbei_id,
  217. active=False,
  218. )
  219. logging.info(
  220. "Creating new person for %s / %s (%s)",
  221. member.person.full_name,
  222. member.display_name,
  223. member.aardbei_id,
  224. )
  225. db.session.add(pnew)
  226. def update_names(matches: List[AardbeiMatch]) -> None:
  227. for match in matches:
  228. p = match.local
  229. member = match.remote
  230. aardbei_person = member.person
  231. changed = False
  232. if p.full_name != aardbei_person.full_name:
  233. logging.info(
  234. "Updating %s (L%s/R%s) full name %s to %s",
  235. aardbei_person.full_name,
  236. p.person_id,
  237. aardbei_person.aardbei_id,
  238. p.full_name,
  239. aardbei_person.full_name,
  240. )
  241. p.full_name = aardbei_person.full_name
  242. changed = True
  243. if p.display_name != member.display_name:
  244. logging.info(
  245. "Updating %s (L%s/R%s) display name %s to %s",
  246. p.full_name,
  247. p.person_id,
  248. aardbei_person.aardbei_id,
  249. p.display_name,
  250. member.display_name,
  251. )
  252. p.display_name = member.display_name
  253. changed = True
  254. assert changed, "got match but didn't update anything"
  255. db.session.add(p)
  256. def get_activities(token: str) -> List[SparseAardbeiActivity]:
  257. result: List[SparseAardbeiActivity] = []
  258. for category in ("upcoming", "current", "previous"):
  259. resp = requests.get(
  260. f"{AARDBEI_ENDPOINT}/api/groups/0/{category}_activities",
  261. headers={"Authorization": f"Group {token}"},
  262. )
  263. resp.raise_for_status()
  264. for item in resp.json():
  265. result.append(SparseAardbeiActivity.from_aardbei_dict(item))
  266. return result
  267. if __name__ == "__main__":
  268. logging.basicConfig(level=logging.DEBUG)
  269. token = input("Token: ")
  270. aardbei_people = get_aardbei_people(token)
  271. activities = get_activities(token)
  272. link = match_local_aardbei(aardbei_people)
  273. link_matches(link.matches)
  274. create_missing(link.remote_only)
  275. update_names(link.altered_name)
  276. confirm = input("Commit? Y/N")
  277. if confirm.lower() == "y":
  278. print("Committing.")
  279. db.session.commit()
  280. else:
  281. print("Not committing.")