Digitale bierlijst

__init__.py 12KB

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