Digitale bierlijst

__init__.py 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. """
  2. Piket server, handles events generated by the client.
  3. """
  4. import datetime
  5. import os
  6. from collections import defaultdict
  7. from sqlalchemy.exc import SQLAlchemyError
  8. from sqlalchemy import func
  9. from flask import Flask, jsonify, abort, request
  10. from flask_sqlalchemy import SQLAlchemy
  11. DATA_HOME = os.environ.get("XDG_DATA_HOME", "~/.local/share")
  12. CONFIG_DIR = os.path.join(DATA_HOME, "piket_server")
  13. DB_PATH = os.path.expanduser(os.path.join(CONFIG_DIR, "database.sqlite3"))
  14. DB_URL = f"sqlite:///{DB_PATH}"
  15. app = Flask("piket_server")
  16. app.config["SQLALCHEMY_DATABASE_URI"] = DB_URL
  17. app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
  18. db = SQLAlchemy(app)
  19. # ---------- Models ----------
  20. class Person(db.Model):
  21. """Represents a person to be shown on the lists."""
  22. __tablename__ = "people"
  23. person_id = db.Column(db.Integer, primary_key=True)
  24. name = db.Column(db.String, nullable=False)
  25. active = db.Column(db.Boolean, nullable=False, default=False)
  26. consumptions = db.relationship("Consumption", backref="person", lazy=True)
  27. def __repr__(self) -> str:
  28. return f"<Person {self.person_id}: {self.name}>"
  29. @property
  30. def as_dict(self) -> dict:
  31. return {
  32. "person_id": self.person_id,
  33. "active": self.active,
  34. "name": self.name,
  35. "consumptions": {
  36. ct.consumption_type_id: Consumption.query.filter_by(person=self)
  37. .filter_by(settlement=None)
  38. .filter_by(consumption_type=ct)
  39. .filter_by(reversed=False)
  40. .count()
  41. for ct in ConsumptionType.query.filter_by(active=True).all()
  42. },
  43. }
  44. class Export(db.Model):
  45. """Represents a set of exported Settlements."""
  46. __tablename__ = "exports"
  47. export_id = db.Column(db.Integer, primary_key=True)
  48. created_at = db.Column(
  49. db.DateTime, default=datetime.datetime.utcnow, nullable=False
  50. )
  51. settlements = db.relationship("Settlement", backref="export", lazy=True)
  52. @property
  53. def as_dict(self) -> dict:
  54. return {
  55. "export_id": self.export_id,
  56. "created_at": self.created_at.isoformat(),
  57. "settlement_ids": [s.settlement_id for s in self.settlements],
  58. }
  59. class Settlement(db.Model):
  60. """Represents a settlement of the list."""
  61. __tablename__ = "settlements"
  62. settlement_id = db.Column(db.Integer, primary_key=True)
  63. name = db.Column(db.String, nullable=False)
  64. export_id = db.Column(db.Integer, db.ForeignKey("exports.export_id"), nullable=True)
  65. consumptions = db.relationship("Consumption", backref="settlement", lazy=True)
  66. def __repr__(self) -> str:
  67. return f"<Settlement {self.settlement_id}: {self.name}>"
  68. @property
  69. def as_dict(self) -> dict:
  70. return {
  71. "settlement_id": self.settlement_id,
  72. "name": self.name,
  73. "consumption_summary": self.consumption_summary,
  74. "unique_people": self.unique_people,
  75. }
  76. @property
  77. def unique_people(self) -> int:
  78. q = (
  79. Consumption.query.filter_by(settlement=self)
  80. .filter_by(reversed=False)
  81. .group_by(Consumption.person_id)
  82. .count()
  83. )
  84. return q
  85. @property
  86. def consumption_summary(self) -> dict:
  87. q = (
  88. Consumption.query.filter_by(settlement=self)
  89. .filter_by(reversed=False)
  90. .group_by(Consumption.consumption_type_id)
  91. .order_by(ConsumptionType.name)
  92. .outerjoin(ConsumptionType)
  93. .with_entities(
  94. Consumption.consumption_type_id,
  95. ConsumptionType.name,
  96. func.count(Consumption.consumption_id),
  97. )
  98. .all()
  99. )
  100. return {r[0]: {"name": r[1], "count": r[2]} for r in q}
  101. @property
  102. def per_person(self) -> dict:
  103. # Get keys of seen consumption_types
  104. c_types = self.consumption_summary.keys()
  105. result = {}
  106. for type in c_types:
  107. c_type = ConsumptionType.query.get(type)
  108. result[type] = {"consumption_type": c_type.as_dict, "counts": {}}
  109. q = (
  110. Consumption.query.filter_by(settlement=self)
  111. .filter_by(reversed=False)
  112. .filter_by(consumption_type=c_type)
  113. .group_by(Consumption.person_id)
  114. .order_by(Person.name)
  115. .outerjoin(Person)
  116. .with_entities(
  117. Person.person_id,
  118. Person.name,
  119. func.count(Consumption.consumption_id),
  120. )
  121. .all()
  122. )
  123. for row in q:
  124. result[type]["counts"][row[0]] = {"name": row[1], "count": row[2]}
  125. return result
  126. class ConsumptionType(db.Model):
  127. """Represents a type of consumption to be counted."""
  128. __tablename__ = "consumption_types"
  129. consumption_type_id = db.Column(db.Integer, primary_key=True)
  130. name = db.Column(db.String, nullable=False)
  131. icon = db.Column(db.String)
  132. active = db.Column(db.Boolean, default=True)
  133. consumptions = db.relationship("Consumption", backref="consumption_type", lazy=True)
  134. def __repr__(self) -> str:
  135. return f"<ConsumptionType: {self.name}>"
  136. @property
  137. def as_dict(self) -> dict:
  138. return {
  139. "consumption_type_id": self.consumption_type_id,
  140. "name": self.name,
  141. "icon": self.icon,
  142. }
  143. class Consumption(db.Model):
  144. """Represent one consumption to be counted."""
  145. __tablename__ = "consumptions"
  146. consumption_id = db.Column(db.Integer, primary_key=True)
  147. person_id = db.Column(db.Integer, db.ForeignKey("people.person_id"), nullable=True)
  148. consumption_type_id = db.Column(
  149. db.Integer,
  150. db.ForeignKey("consumption_types.consumption_type_id"),
  151. nullable=False,
  152. )
  153. settlement_id = db.Column(
  154. db.Integer, db.ForeignKey("settlements.settlement_id"), nullable=True
  155. )
  156. created_at = db.Column(
  157. db.DateTime, default=datetime.datetime.utcnow, nullable=False
  158. )
  159. reversed = db.Column(db.Boolean, default=False, nullable=False)
  160. def __repr__(self) -> str:
  161. return f"<Consumption: {self.consumption_type.name} for {self.person.name}>"
  162. @property
  163. def as_dict(self) -> dict:
  164. return {
  165. "consumption_id": self.consumption_id,
  166. "person_id": self.person_id,
  167. "consumption_type_id": self.consumption_type_id,
  168. "settlement_id": self.settlement_id,
  169. "created_at": self.created_at.isoformat(),
  170. "reversed": self.reversed,
  171. }
  172. # ---------- Models ----------
  173. @app.route("/ping")
  174. def ping() -> None:
  175. """Return a status ping."""
  176. return "Pong"
  177. @app.route("/status")
  178. def status() -> None:
  179. """Return a status dict with info about the database."""
  180. unsettled_q = Consumption.query.filter_by(settlement=None).filter_by(reversed=False)
  181. unsettled = unsettled_q.count()
  182. first = None
  183. last = None
  184. if unsettled:
  185. last = (
  186. unsettled_q.order_by(Consumption.created_at.desc())
  187. .first()
  188. .created_at.isoformat()
  189. )
  190. first = (
  191. unsettled_q.order_by(Consumption.created_at.asc())
  192. .first()
  193. .created_at.isoformat()
  194. )
  195. return jsonify({"unsettled": {"amount": unsettled, "first": first, "last": last}})
  196. # Person
  197. @app.route("/people", methods=["GET"])
  198. def get_people():
  199. """Return a list of currently known people."""
  200. q = Person.query.order_by(Person.name)
  201. if request.args.get("active"):
  202. active_status = request.args.get("active", type=int)
  203. q = q.filter_by(active=active_status)
  204. people = q.all()
  205. engine = db.get_engine()
  206. query = """
  207. SELECT
  208. consumptions.person_id,
  209. consumptions.consumption_type_id,
  210. COUNT(*)
  211. FROM consumptions
  212. JOIN consumption_types
  213. ON consumptions.consumption_type_id = consumption_types.consumption_type_id
  214. WHERE
  215. consumptions.settlement_id IS NULL
  216. AND consumptions.reversed = 0
  217. GROUP BY consumptions.person_id, consumptions.consumption_type_id;
  218. """
  219. raw_counts = engine.execute(query)
  220. counts: "Dict[int, Dict[str, int]]" = defaultdict(dict)
  221. for person_id, consumption_type_id, count in raw_counts:
  222. counts[person_id][str(consumption_type_id)] = count
  223. result = [
  224. {
  225. "name": person.name,
  226. "active": person.active,
  227. "person_id": person.person_id,
  228. "consumptions": counts[person.person_id],
  229. }
  230. for person in people
  231. ]
  232. # result = [person.as_dict for person in people]
  233. return jsonify(people=result)
  234. @app.route("/people/<int:person_id>", methods=["GET"])
  235. def get_person(person_id: int):
  236. person = Person.query.get_or_404(person_id)
  237. return jsonify(person=person.as_dict)
  238. @app.route("/people", methods=["POST"])
  239. def add_person():
  240. """
  241. Add a new person.
  242. Required parameters:
  243. - name (str)
  244. """
  245. json = request.get_json()
  246. if not json:
  247. return jsonify({"error": "Could not parse JSON."}), 400
  248. data = json.get("person") or {}
  249. person = Person(name=data.get("name"), active=data.get("active", False))
  250. try:
  251. db.session.add(person)
  252. db.session.commit()
  253. except SQLAlchemyError:
  254. return jsonify({"error": "Invalid arguments for Person."}), 400
  255. return jsonify(person=person.as_dict), 201
  256. @app.route("/people/<int:person_id>/add_consumption", methods=["POST"])
  257. def add_consumption(person_id: int):
  258. person = Person.query.get_or_404(person_id)
  259. consumption = Consumption(person=person, consumption_type_id=1)
  260. try:
  261. db.session.add(consumption)
  262. db.session.commit()
  263. except SQLAlchemyError:
  264. return (
  265. jsonify(
  266. {"error": "Invalid Consumption parameters.", "person": person.as_dict}
  267. ),
  268. 400,
  269. )
  270. return jsonify(person=person.as_dict, consumption=consumption.as_dict), 201
  271. @app.route("/people/<int:person_id>", methods=["PATCH"])
  272. def update_person(person_id: int):
  273. person = Person.query.get_or_404(person_id)
  274. data = request.json["person"]
  275. if "active" in data:
  276. person.active = data["active"]
  277. db.session.add(person)
  278. db.session.commit()
  279. return jsonify(person=person.as_dict)
  280. @app.route("/people/<int:person_id>/add_consumption/<int:ct_id>", methods=["POST"])
  281. def add_consumption2(person_id: int, ct_id: int):
  282. person = Person.query.get_or_404(person_id)
  283. consumption = Consumption(person=person, consumption_type_id=ct_id)
  284. try:
  285. db.session.add(consumption)
  286. db.session.commit()
  287. except SQLAlchemyError:
  288. return (
  289. jsonify(
  290. {"error": "Invalid Consumption parameters.", "person": person.as_dict}
  291. ),
  292. 400,
  293. )
  294. return jsonify(person=person.as_dict, consumption=consumption.as_dict), 201
  295. @app.route("/consumptions/<int:consumption_id>", methods=["DELETE"])
  296. def reverse_consumption(consumption_id: int):
  297. """Reverse a consumption."""
  298. consumption = Consumption.query.get_or_404(consumption_id)
  299. if consumption.reversed:
  300. return (
  301. jsonify(
  302. {
  303. "error": "Consumption already reversed",
  304. "consumption": consumption.as_dict,
  305. }
  306. ),
  307. 409,
  308. )
  309. try:
  310. consumption.reversed = True
  311. db.session.add(consumption)
  312. db.session.commit()
  313. except SQLAlchemyError:
  314. return jsonify({"error": "Database error."}), 500
  315. return jsonify(consumption=consumption.as_dict), 200
  316. # ConsumptionType
  317. @app.route("/consumption_types", methods=["GET"])
  318. def get_consumption_types():
  319. """Return a list of currently active consumption types."""
  320. ctypes = ConsumptionType.query.filter_by(active=True).all()
  321. result = [ct.as_dict for ct in ctypes]
  322. return jsonify(consumption_types=result)
  323. @app.route("/consumption_types/<int:consumption_type_id>", methods=["GET"])
  324. def get_consumption_type(consumption_type_id: int):
  325. ct = ConsumptionType.query.get_or_404(consumption_type_id)
  326. return jsonify(consumption_type=ct.as_dict)
  327. @app.route("/consumption_types", methods=["POST"])
  328. def add_consumption_type():
  329. """Add a new ConsumptionType."""
  330. json = request.get_json()
  331. if not json:
  332. return jsonify({"error": "Could not parse JSON."}), 400
  333. data = json.get("consumption_type") or {}
  334. ct = ConsumptionType(name=data.get("name"), icon=data.get("icon"))
  335. try:
  336. db.session.add(ct)
  337. db.session.commit()
  338. except SQLAlchemyError:
  339. return jsonify({"error": "Invalid arguments for ConsumptionType."}), 400
  340. return jsonify(consumption_type=ct.as_dict), 201
  341. # Settlement
  342. @app.route("/settlements", methods=["GET"])
  343. def get_settlements():
  344. """Return a list of the active Settlements."""
  345. result = Settlement.query.all()
  346. return jsonify(settlements=[s.as_dict for s in result])
  347. @app.route("/settlements/<int:settlement_id>", methods=["GET"])
  348. def get_settlement(settlement_id: int):
  349. """Show full details for a single Settlement."""
  350. s = Settlement.query.get_or_404(settlement_id)
  351. per_person = s.per_person
  352. return jsonify(settlement=s.as_dict, count_info=per_person)
  353. @app.route("/settlements", methods=["POST"])
  354. def add_settlement():
  355. """Create a Settlement, and link all un-settled Consumptions to it."""
  356. json = request.get_json()
  357. if not json:
  358. return jsonify({"error": "Could not parse JSON."}), 400
  359. data = json.get("settlement") or {}
  360. s = Settlement(name=data["name"])
  361. db.session.add(s)
  362. db.session.commit()
  363. Consumption.query.filter_by(settlement=None).update(
  364. {"settlement_id": s.settlement_id}
  365. )
  366. db.session.commit()
  367. return jsonify(settlement=s.as_dict)
  368. # Export
  369. @app.route("/exports", methods=["GET"])
  370. def get_exports():
  371. """Return a list of the created Exports."""
  372. result = Export.query.all()
  373. return jsonify(exports=[e.as_dict for e in result])
  374. @app.route("/exports/<int:export_id>", methods=["GET"])
  375. def get_export(export_id: int):
  376. """Return an overview for the given Export."""
  377. e = Export.query.get_or_404(export_id)
  378. ss = [s.as_dict for s in e.settlements]
  379. return jsonify(export=e.as_dict, settlements=ss)
  380. @app.route("/exports", methods=["POST"])
  381. def add_export():
  382. """Create an Export, and link all un-exported Settlements to it."""
  383. # Assert that there are Settlements to be exported.
  384. s_count = Settlement.query.filter_by(export=None).count()
  385. if s_count == 0:
  386. return jsonify(error="No un-exported Settlements."), 403
  387. e = Export()
  388. db.session.add(e)
  389. db.session.commit()
  390. Settlement.query.filter_by(export=None).update({"export_id": e.export_id})
  391. db.session.commit()
  392. ss = [s.as_dict for s in e.settlements]
  393. return jsonify(export=e.as_dict, settlements=ss), 201