Digitale bierlijst

aardbei_sync.py 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  1. from __future__ import annotations
  2. import datetime
  3. import json
  4. import logging
  5. import sys
  6. from dataclasses import asdict, dataclass
  7. from enum import Enum
  8. from typing import Any, Dict, List, NewType, Optional, Tuple, Union
  9. import requests
  10. from piket_server.flask import db
  11. from piket_server.models import Person
  12. from piket_server.util import fmt_datetime
  13. # AARDBEI_ENDPOINT = "https://aardbei.app"
  14. AARDBEI_ENDPOINT = "http://localhost:3000"
  15. log = logging.getLogger(__name__)
  16. ActivityId = NewType("ActivityId", int)
  17. PersonId = NewType("PersonId", int)
  18. MemberId = NewType("MemberId", int)
  19. ParticipantId = NewType("ParticipantId", int)
  20. @dataclass(frozen=True)
  21. class AardbeiPerson:
  22. """
  23. Contains the data on a Person as exposed by Aardbei.
  24. A Person represents a person in the real world, and maps to a Person in the local database.
  25. """
  26. aardbei_id: PersonId
  27. full_name: str
  28. @classmethod
  29. def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiPerson:
  30. """
  31. Load from a dictionary provided by Aardbei.
  32. >>> AardbeiPerson.from_aardbei_dict(
  33. {"person": {"aardbei_id": 1, "full_name": "Henkie Kraggelwenk"}}
  34. )
  35. AardbeiPerson(aardbei_id=AardbeiId(1), full_name="Henkie Kraggelwenk")
  36. """
  37. d = data["person"]
  38. return cls(full_name=d["full_name"], aardbei_id=PersonId(d["id"]))
  39. @property
  40. def as_json_dict(self) -> Dict[str, Any]:
  41. """
  42. Serialize to a dictionary as provided by Aardbei.
  43. >>> AardbeiPerson(aardbei_id=AardbeiId(1), full_name="Henkie Kraggelwenk").as_json_dict
  44. {"person": {"id": 1, "full_name": "Henkie Kraggelwenk"}}
  45. """
  46. return {"person": {"id": self.aardbei_id, "full_name": self.full_name}}
  47. @dataclass(frozen=True)
  48. class AardbeiMember:
  49. """
  50. Contains the data on a Member exposed by Aardbei.
  51. A Member represents the membership of a Person in a Group in Aardbei.
  52. """
  53. person: AardbeiPerson
  54. aardbei_id: MemberId
  55. is_leader: bool
  56. display_name: str
  57. @classmethod
  58. def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiMember:
  59. """
  60. Load from a dictionary provided by Aardbei.
  61. >>> from_aardbei_dict({
  62. "member": {
  63. "person": {
  64. "full_name": "Roer Kuggelvork",
  65. "id": 2,
  66. },
  67. "id": 23,
  68. "is_leader": False,
  69. "display_name": "Roer",
  70. },
  71. })
  72. AardbeiMember(
  73. person=AardbeiPerson(aardbei_id=PersonId(2), full_name="Roer Kuggelvork"),
  74. aardbei_id=MemberId(23),
  75. is_leader=False,
  76. display_name="Roer",
  77. )
  78. """
  79. d = data["member"]
  80. person = AardbeiPerson.from_aardbei_dict(d)
  81. return cls(
  82. person=person,
  83. aardbei_id=MemberId(d["id"]),
  84. is_leader=d["is_leader"],
  85. display_name=d["display_name"],
  86. )
  87. @property
  88. def as_json_dict(self) -> Dict[str, Any]:
  89. """
  90. Serialize to a dict as provided by Aardbei.
  91. >>> AardbeiMember(
  92. person=AardbeiPerson(aardbei_id=PersonId(2), full_name="Roer Kuggelvork"),
  93. aardbei_id=MemberId(23),
  94. is_leader=False,
  95. display_name="Roer",
  96. )
  97. {
  98. "member": {
  99. "person": {
  100. "full_name": "Roer Kuggelvork",
  101. "id": 2,
  102. },
  103. "id": 23,
  104. "is_leader": False,
  105. "display_name": "Roer",
  106. }
  107. }
  108. """
  109. res = {
  110. "id": self.aardbei_id,
  111. "is_leader": self.is_leader,
  112. "display_name": self.display_name,
  113. }
  114. res.update(self.person.as_json_dict)
  115. return res
  116. @dataclass(frozen=True)
  117. class AardbeiParticipant:
  118. """
  119. Represents a Participant as exposed by Aardbei.
  120. A Participant represents the participation of a Person (optionally as a Member in a Group) in an Activity.
  121. """
  122. person: AardbeiPerson
  123. member: Optional[AardbeiMember]
  124. aardbei_id: ParticipantId
  125. attending: bool
  126. is_organizer: bool
  127. notes: Optional[str]
  128. @property
  129. def name(self) -> str:
  130. """
  131. Return the name to show for this Participant.
  132. This is the display_name if a Member is present, else the Participant's Person's full name.
  133. """
  134. if self.member is not None:
  135. return self.member.display_name
  136. return self.person.full_name
  137. @classmethod
  138. def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiParticipant:
  139. """
  140. Load from a dictionary as provided by Aardbei.
  141. """
  142. d = data["participant"]
  143. person = AardbeiPerson.from_aardbei_dict(d)
  144. member: Optional[AardbeiMember] = None
  145. if d["member"] is not None:
  146. member = AardbeiMember.from_aardbei_dict(d)
  147. aardbei_id = ParticipantId(d["id"])
  148. return cls(
  149. person=person,
  150. member=member,
  151. aardbei_id=aardbei_id,
  152. attending=d["attending"],
  153. is_organizer=d["is_organizer"],
  154. notes=d["notes"],
  155. )
  156. @property
  157. def as_json_dict(self) -> Dict[str, Any]:
  158. """
  159. Serialize to a dict as provided by Aardbei.
  160. """
  161. res = {
  162. "participant": {
  163. "id": self.aardbei_id,
  164. "attending": self.attending,
  165. "is_organizer": self.is_organizer,
  166. "notes": self.notes,
  167. }
  168. }
  169. res.update(self.person.as_json_dict)
  170. if self.member is not None:
  171. res.update(self.member.as_json_dict)
  172. return res
  173. class NoResponseAction(Enum):
  174. """Represents the "no response action" attribute of Activities in Aardbei."""
  175. Present = "present"
  176. Absent = "absent"
  177. @dataclass(frozen=True)
  178. class ResponseCounts:
  179. """Represents the "response counts" attribute of Activities in Aardbei."""
  180. present: int
  181. absent: int
  182. unknown: int
  183. @classmethod
  184. def from_aardbei_dict(cls, data: Dict[str, int]) -> ResponseCounts:
  185. """Load from a dict as provided by Aardbei."""
  186. return cls(
  187. present=data["present"], absent=data["absent"], unknown=data["unknown"]
  188. )
  189. @property
  190. def as_json_dict(self) -> Dict[str, int]:
  191. """Serialize to a dict as provided by Aardbei."""
  192. return {"present": self.present, "absent": self.absent, "unknown": self.unknown}
  193. @dataclass(frozen=True)
  194. class SparseAardbeiActivity:
  195. aardbei_id: ActivityId
  196. name: str
  197. description: str
  198. location: str
  199. start: datetime.datetime
  200. end: Optional[datetime.datetime]
  201. deadline: Optional[datetime.datetime]
  202. reminder_at: Optional[datetime.datetime]
  203. no_response_action: NoResponseAction
  204. response_counts: ResponseCounts
  205. def distance(self, reference: datetime.datetime) -> datetime.timedelta:
  206. """Calculate how long ago this Activity ended / how much time until it starts."""
  207. if self.end is not None:
  208. if reference > self.start and reference < self.end:
  209. return datetime.timedelta(seconds=0)
  210. elif reference < self.start:
  211. return self.start - reference
  212. elif reference > self.end:
  213. return reference - self.end
  214. if reference > self.start:
  215. return reference - self.start
  216. return self.start - reference
  217. @classmethod
  218. def from_aardbei_dict(cls, data: Dict[str, Any]) -> SparseAardbeiActivity:
  219. """Load from a dict as provided by Aardbei."""
  220. start: datetime.datetime = datetime.datetime.fromisoformat(
  221. data["activity"]["start"]
  222. )
  223. end: Optional[datetime.datetime] = None
  224. if data["activity"]["end"] is not None:
  225. end = datetime.datetime.fromisoformat(data["activity"]["end"])
  226. deadline: Optional[datetime.datetime] = None
  227. if data["activity"]["deadline"] is not None:
  228. deadline = datetime.datetime.fromisoformat(data["activity"]["deadline"])
  229. reminder_at: Optional[datetime.datetime] = None
  230. if data["activity"]["reminder_at"] is not None:
  231. reminder_at = datetime.datetime.fromisoformat(
  232. data["activity"]["reminder_at"]
  233. )
  234. no_response_action = NoResponseAction(data["activity"]["no_response_action"])
  235. response_counts = ResponseCounts.from_aardbei_dict(
  236. data["activity"]["response_counts"]
  237. )
  238. return cls(
  239. aardbei_id=ActivityId(data["activity"]["id"]),
  240. name=data["activity"]["name"],
  241. description=data["activity"]["description"],
  242. location=data["activity"]["location"],
  243. start=start,
  244. end=end,
  245. deadline=deadline,
  246. reminder_at=reminder_at,
  247. no_response_action=no_response_action,
  248. response_counts=response_counts,
  249. )
  250. @property
  251. def as_json_dict(self) -> Dict[str, Any]:
  252. """Serialize to a dict as provided by Aardbei."""
  253. return {
  254. "activity": {
  255. "id": self.aardbei_id,
  256. "name": self.name,
  257. "description": self.description,
  258. "location": self.location,
  259. "start": fmt_datetime(self.start),
  260. "end": fmt_datetime(self.end),
  261. "deadline": fmt_datetime(self.deadline),
  262. "reminder_at": fmt_datetime(self.reminder_at),
  263. "no_response_action": self.no_response_action.value,
  264. "response_counts": self.response_counts.as_json_dict,
  265. }
  266. }
  267. @dataclass(frozen=True)
  268. class AardbeiActivity(SparseAardbeiActivity):
  269. """Contains the data of an Activity as exposed by Aardbei."""
  270. participants: List[AardbeiParticipant]
  271. @classmethod
  272. def from_aardbei_dict(cls, data: Dict[str, Any]) -> AardbeiActivity:
  273. """Load from a dict as provided by Aardbei."""
  274. # Ugly: This is a copy of the Sparse variant with added participants.
  275. # This is not ideal, but I don't care enough to fix this right now.
  276. participants: List[AardbeiParticipant] = [
  277. AardbeiParticipant.from_aardbei_dict(x)
  278. for x in data["activity"]["participants"]
  279. ]
  280. start: datetime.datetime = datetime.datetime.fromisoformat(
  281. data["activity"]["start"]
  282. )
  283. end: Optional[datetime.datetime] = None
  284. if data["activity"]["end"] is not None:
  285. end = datetime.datetime.fromisoformat(data["activity"]["end"])
  286. deadline: Optional[datetime.datetime] = None
  287. if data["activity"]["deadline"] is not None:
  288. deadline = datetime.datetime.fromisoformat(data["activity"]["deadline"])
  289. reminder_at: Optional[datetime.datetime] = None
  290. if data["activity"]["reminder_at"] is not None:
  291. reminder_at = datetime.datetime.fromisoformat(
  292. data["activity"]["reminder_at"]
  293. )
  294. no_response_action = NoResponseAction(data["activity"]["no_response_action"])
  295. response_counts = ResponseCounts.from_aardbei_dict(
  296. data["activity"]["response_counts"]
  297. )
  298. return cls(
  299. aardbei_id=ActivityId(data["activity"]["id"]),
  300. name=data["activity"]["name"],
  301. description=data["activity"]["description"],
  302. location=data["activity"]["location"],
  303. start=start,
  304. end=end,
  305. deadline=deadline,
  306. reminder_at=reminder_at,
  307. no_response_action=no_response_action,
  308. response_counts=response_counts,
  309. participants=participants,
  310. )
  311. @property
  312. def as_json_dict(self) -> Dict[str, Any]:
  313. """Serialize to a dict as provided by Aardbei."""
  314. res = super().as_json_dict
  315. res["participants"] = [p.as_json_dict for p in self.participants]
  316. return res
  317. @dataclass(frozen=True)
  318. class AardbeiMatch:
  319. """Represents a match between a local Person and a Person present in Aardbei's data."""
  320. local: Person
  321. remote: AardbeiMember
  322. @dataclass(frozen=True)
  323. class AardbeiLink:
  324. """Represents a set of differences between the local state and Aardbei's set of people."""
  325. matches: List[AardbeiMatch]
  326. """People that exist on both sides, but aren't linked in the people table."""
  327. altered_name: List[AardbeiMatch]
  328. """People that are already linked but changed one of their names."""
  329. remote_only: List[AardbeiMember]
  330. """People that only exist on the remote."""
  331. @property
  332. def num_changes(self) -> int:
  333. """Return the amount of mismatching people between Aardbei and the local state."""
  334. return len(self.matches) + len(self.altered_name) + len(self.remote_only)
  335. class AardbeiSyncError(Enum):
  336. """Represents errors that might occur when retrieving data from Aardbei."""
  337. CantConnect = "connect_fail"
  338. HTTPError = "http_fail"
  339. def get_aardbei_people(
  340. token: str, endpoint: str = AARDBEI_ENDPOINT
  341. ) -> Union[List[AardbeiMember], AardbeiSyncError]:
  342. """Retrieve the set of People in a Group from Aardbei, and parse this to
  343. AardbeiPerson objects. Return a AardbeiSyncError if something fails."""
  344. try:
  345. resp: requests.Response = requests.get(
  346. f"{AARDBEI_ENDPOINT}/api/groups/0/",
  347. headers={"Authorization": f"Group {token}"},
  348. )
  349. resp.raise_for_status()
  350. except requests.ConnectionError:
  351. return AardbeiSyncError.CantConnect
  352. except requests.HTTPError:
  353. return AardbeiSyncError.HTTPError
  354. members = resp.json()["group"]["members"]
  355. return [AardbeiMember.from_aardbei_dict(x) for x in members]
  356. def match_local_aardbei(aardbei_members: List[AardbeiMember]) -> AardbeiLink:
  357. """Inspect the local state and compare it with the set of given
  358. AardbeiMembers (containing AardbeiPersons). Return a AardbeiLink that
  359. indicates which local people don't match the remote state."""
  360. matches: List[AardbeiMatch] = []
  361. altered_name: List[AardbeiMatch] = []
  362. remote_only: List[AardbeiMember] = []
  363. for member in aardbei_members:
  364. p: Optional[Person] = Person.query.filter_by(
  365. aardbei_id=member.person.aardbei_id
  366. ).one_or_none()
  367. if p is not None:
  368. if (
  369. p.full_name != member.person.full_name
  370. or p.display_name != member.display_name
  371. ):
  372. altered_name.append(AardbeiMatch(p, member))
  373. else:
  374. logging.info(
  375. "OK: %s / %s (L%s/R%s)",
  376. p.full_name,
  377. p.display_name,
  378. p.person_id,
  379. p.aardbei_id,
  380. )
  381. continue
  382. p = Person.query.filter_by(full_name=member.person.full_name).one_or_none()
  383. if p is not None:
  384. matches.append(AardbeiMatch(p, member))
  385. else:
  386. remote_only.append(member)
  387. return AardbeiLink(matches, altered_name, remote_only)
  388. def link_matches(matches: List[AardbeiMatch]) -> None:
  389. """
  390. Update local people to add the remote ID to the local state.
  391. This only enqueues the changes in the local SQLAlchemy session, committing
  392. needs to be done separately.
  393. """
  394. for match in matches:
  395. match.local.aardbei_id = match.remote.person.aardbei_id
  396. match.local.display_name = match.remote.display_name
  397. logging.info(
  398. "Linking local %s (%s) to remote %s (%s)",
  399. match.local.full_name,
  400. match.local.person_id,
  401. match.remote.display_name,
  402. match.remote.person.aardbei_id,
  403. )
  404. db.session.add(match.local)
  405. def create_missing(missing: List[AardbeiMember]) -> None:
  406. """
  407. Create local people for all remote people that don't exist locally.
  408. This only enqueues the changes in the local SQLAlchemy session, committing
  409. needs to be done separately.
  410. """
  411. for member in missing:
  412. pnew = Person(
  413. full_name=member.person.full_name,
  414. display_name=member.display_name,
  415. aardbei_id=member.person.aardbei_id,
  416. active=False,
  417. )
  418. logging.info(
  419. "Creating new person for %s / %s (%s)",
  420. member.person.full_name,
  421. member.display_name,
  422. member.person.aardbei_id,
  423. )
  424. db.session.add(pnew)
  425. def update_names(matches: List[AardbeiMatch]) -> None:
  426. """
  427. Update the local full and display names of people that were already linked
  428. to a remote person, and who changed names on the remote.
  429. This only enqueues the changes in the local SQLAlchemy session, committing
  430. needs to be done separately.
  431. """
  432. for match in matches:
  433. p = match.local
  434. member = match.remote
  435. aardbei_person = member.person
  436. changed = False
  437. if p.full_name != aardbei_person.full_name:
  438. logging.info(
  439. "Updating %s (L%s/R%s) full name %s to %s",
  440. aardbei_person.full_name,
  441. p.person_id,
  442. aardbei_person.aardbei_id,
  443. p.full_name,
  444. aardbei_person.full_name,
  445. )
  446. p.full_name = aardbei_person.full_name
  447. changed = True
  448. if p.display_name != member.display_name:
  449. logging.info(
  450. "Updating %s (L%s/R%s) display name %s to %s",
  451. p.full_name,
  452. p.person_id,
  453. aardbei_person.aardbei_id,
  454. p.display_name,
  455. member.display_name,
  456. )
  457. p.display_name = member.display_name
  458. changed = True
  459. assert changed, "got match but didn't update anything"
  460. db.session.add(p)
  461. def get_activities(
  462. token: str, endpoint: str = AARDBEI_ENDPOINT
  463. ) -> Union[List[SparseAardbeiActivity], AardbeiSyncError]:
  464. """
  465. Get the list of activities present on the remote and return these
  466. activities, ordered by the temporal distance to the current time.
  467. """
  468. result: List[SparseAardbeiActivity] = []
  469. for category in ("upcoming", "current", "previous"):
  470. try:
  471. resp = requests.get(
  472. f"{endpoint}/api/groups/0/{category}_activities",
  473. headers={"Authorization": f"Group {token}"},
  474. )
  475. resp.raise_for_status()
  476. except requests.HTTPError as e:
  477. log.exception(e)
  478. return AardbeiSyncError.HTTPError
  479. except requests.ConnectionError as e:
  480. log.exception(e)
  481. return AardbeiSyncError.CantConnect
  482. for item in resp.json():
  483. result.append(SparseAardbeiActivity.from_aardbei_dict(item))
  484. now = datetime.datetime.now(datetime.timezone.utc)
  485. result.sort(key=lambda x: SparseAardbeiActivity.distance(x, now))
  486. return result
  487. def get_activity(
  488. activity_id: ActivityId, token: str, endpoint: str
  489. ) -> Union[AardbeiActivity, AardbeiSyncError]:
  490. """
  491. Get all data (including participants) from the remote about one activity
  492. with a given ID.
  493. """
  494. try:
  495. resp = requests.get(
  496. f"{endpoint}/api/activities/{activity_id}",
  497. headers={"Authorization": f"Group {token}"},
  498. )
  499. resp.raise_for_status()
  500. except requests.HTTPError as e:
  501. log.exception(e)
  502. return AardbeiSyncError.HTTPError
  503. except requests.ConnectionError as e:
  504. return AardbeiSyncError.CantConnect
  505. return AardbeiActivity.from_aardbei_dict(resp.json())
  506. def match_activity(activity: AardbeiActivity) -> None:
  507. """
  508. Update the local state to have mark all people present at the given
  509. activity as active, and all other people as inactive.
  510. """
  511. ps = activity.participants
  512. pids: List[PersonId] = [p.person.aardbei_id for p in ps if p.attending]
  513. Person.query.update(values={"active": False})
  514. Person.query.filter(Person.aardbei_id.in_(pids)).update(
  515. values={"active": True}, synchronize_session="fetch"
  516. )
  517. if __name__ == "__main__":
  518. logging.basicConfig(level=logging.DEBUG)
  519. token = input("Token: ")
  520. aardbei_people = get_aardbei_people(token)
  521. if isinstance(aardbei_people, AardbeiSyncError):
  522. logging.error("Could not get people: %s", aardbei_people.value)
  523. sys.exit(1)
  524. activities = get_activities(token)
  525. if isinstance(activities, AardbeiSyncError):
  526. logging.error("Could not get activities: %s", activities.value)
  527. sys.exit(1)
  528. link = match_local_aardbei(aardbei_people)
  529. link_matches(link.matches)
  530. create_missing(link.remote_only)
  531. update_names(link.altered_name)
  532. confirm = input("Commit? Y/N")
  533. if confirm.lower() == "y":
  534. print("Committing.")
  535. db.session.commit()
  536. else:
  537. print("Not committing.")