Browse Source

Split up server code into modules

Maarten van den Berg 5 years ago
parent
commit
e80d70e999

+ 8 - 490
piket_server/__init__.py

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

+ 19 - 0
piket_server/flask.py

@@ -0,0 +1,19 @@
1
+"""
2
+Defines the Flask object used to run the server.
3
+"""
4
+
5
+import os
6
+from typing import Any
7
+
8
+from flask import Flask
9
+from flask_sqlalchemy import SQLAlchemy  # type: ignore
10
+
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
+
16
+app = Flask("piket_server")
17
+app.config["SQLALCHEMY_DATABASE_URI"] = DB_URL
18
+app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
19
+db: Any = SQLAlchemy(app)

+ 208 - 0
piket_server/models.py

@@ -0,0 +1,208 @@
1
+"""
2
+Defines database models used by the server.
3
+"""
4
+
5
+import datetime
6
+
7
+from sqlalchemy import func
8
+from sqlalchemy.exc import SQLAlchemyError
9
+
10
+from piket_server.flask import db
11
+
12
+
13
+class Person(db.Model):
14
+    """ Represents a person to be shown on the lists. """
15
+
16
+    __tablename__ = "people"
17
+
18
+    person_id = db.Column(db.Integer, primary_key=True)
19
+    full_name = db.Column(db.String, nullable=False)
20
+    display_name = db.Column(db.String, nullable=True)
21
+    aardbei_id = db.Column(db.Integer, nullable=True)
22
+    active = db.Column(db.Boolean, nullable=False, default=False)
23
+
24
+    consumptions = db.relationship("Consumption", backref="person", lazy=True)
25
+
26
+    def __repr__(self) -> str:
27
+        return f"<Person {self.person_id}: {self.full_name}>"
28
+
29
+    @property
30
+    def as_dict(self) -> dict:
31
+        return {
32
+            "person_id": self.person_id,
33
+            "active": self.active,
34
+            "full_name": self.full_name,
35
+            "display_name": self.display_name,
36
+            "consumptions": {
37
+                ct.consumption_type_id: Consumption.query.filter_by(person=self)
38
+                .filter_by(settlement=None)
39
+                .filter_by(consumption_type=ct)
40
+                .filter_by(reversed=False)
41
+                .count()
42
+                for ct in ConsumptionType.query.all()
43
+            },
44
+        }
45
+
46
+
47
+class Export(db.Model):
48
+    """ Represents a set of exported Settlements. """
49
+
50
+    __tablename__ = "exports"
51
+
52
+    export_id = db.Column(db.Integer, primary_key=True)
53
+    created_at = db.Column(
54
+        db.DateTime, default=datetime.datetime.utcnow, nullable=False
55
+    )
56
+
57
+    settlements = db.relationship("Settlement", backref="export", lazy=True)
58
+
59
+    @property
60
+    def as_dict(self) -> dict:
61
+        return {
62
+            "export_id": self.export_id,
63
+            "created_at": self.created_at.isoformat(),
64
+            "settlement_ids": [s.settlement_id for s in self.settlements],
65
+        }
66
+
67
+
68
+class Settlement(db.Model):
69
+    """ Represents a settlement of the list. """
70
+
71
+    __tablename__ = "settlements"
72
+
73
+    settlement_id = db.Column(db.Integer, primary_key=True)
74
+    name = db.Column(db.String, nullable=False)
75
+    export_id = db.Column(db.Integer, db.ForeignKey("exports.export_id"), nullable=True)
76
+
77
+    consumptions = db.relationship("Consumption", backref="settlement", lazy=True)
78
+
79
+    def __repr__(self) -> str:
80
+        return f"<Settlement {self.settlement_id}: {self.name}>"
81
+
82
+    @property
83
+    def as_dict(self) -> dict:
84
+        return {
85
+            "settlement_id": self.settlement_id,
86
+            "name": self.name,
87
+            "consumption_summary": self.consumption_summary,
88
+            "unique_people": self.unique_people,
89
+        }
90
+
91
+    @property
92
+    def unique_people(self) -> int:
93
+        q = (
94
+            Consumption.query.filter_by(settlement=self)
95
+            .filter_by(reversed=False)
96
+            .group_by(Consumption.person_id)
97
+            .count()
98
+        )
99
+        return q
100
+
101
+    @property
102
+    def consumption_summary(self) -> dict:
103
+        q = (
104
+            Consumption.query.filter_by(settlement=self)
105
+            .filter_by(reversed=False)
106
+            .group_by(Consumption.consumption_type_id)
107
+            .order_by(ConsumptionType.name)
108
+            .outerjoin(ConsumptionType)
109
+            .with_entities(
110
+                Consumption.consumption_type_id,
111
+                ConsumptionType.name,
112
+                func.count(Consumption.consumption_id),
113
+            )
114
+            .all()
115
+        )
116
+
117
+        return {r[0]: {"name": r[1], "count": r[2]} for r in q}
118
+
119
+    @property
120
+    def per_person(self) -> dict:
121
+        # Get keys of seen consumption_types
122
+        c_types = self.consumption_summary.keys()
123
+
124
+        result = {}
125
+        for type in c_types:
126
+            c_type = ConsumptionType.query.get(type)
127
+            result[type] = {"consumption_type": c_type.as_dict, "counts": {}}
128
+
129
+            q = (
130
+                Consumption.query.filter_by(settlement=self)
131
+                .filter_by(reversed=False)
132
+                .filter_by(consumption_type=c_type)
133
+                .group_by(Consumption.person_id)
134
+                .order_by(Person.full_name)
135
+                .outerjoin(Person)
136
+                .with_entities(
137
+                    Person.person_id,
138
+                    Person.full_name,
139
+                    func.count(Consumption.consumption_id),
140
+                )
141
+                .all()
142
+            )
143
+
144
+            for row in q:
145
+                result[type]["counts"][row[0]] = {"name": row[1], "count": row[2]}
146
+
147
+        return result
148
+
149
+
150
+class ConsumptionType(db.Model):
151
+    """ Represents a type of consumption to be counted. """
152
+
153
+    __tablename__ = "consumption_types"
154
+
155
+    consumption_type_id = db.Column(db.Integer, primary_key=True)
156
+    name = db.Column(db.String, nullable=False)
157
+    icon = db.Column(db.String)
158
+    active = db.Column(db.Boolean, default=True)
159
+
160
+    consumptions = db.relationship("Consumption", backref="consumption_type", lazy=True)
161
+
162
+    def __repr__(self) -> str:
163
+        return f"<ConsumptionType: {self.name}>"
164
+
165
+    @property
166
+    def as_dict(self) -> dict:
167
+        return {
168
+            "consumption_type_id": self.consumption_type_id,
169
+            "name": self.name,
170
+            "icon": self.icon,
171
+        }
172
+
173
+
174
+class Consumption(db.Model):
175
+    """ Represent one consumption to be counted. """
176
+
177
+    __tablename__ = "consumptions"
178
+
179
+    consumption_id = db.Column(db.Integer, primary_key=True)
180
+    person_id = db.Column(db.Integer, db.ForeignKey("people.person_id"), nullable=True)
181
+    consumption_type_id = db.Column(
182
+        db.Integer,
183
+        db.ForeignKey("consumption_types.consumption_type_id"),
184
+        nullable=False,
185
+    )
186
+    settlement_id = db.Column(
187
+        db.Integer, db.ForeignKey("settlements.settlement_id"), nullable=True
188
+    )
189
+    created_at = db.Column(
190
+        db.DateTime, default=datetime.datetime.utcnow, nullable=False
191
+    )
192
+    reversed = db.Column(db.Boolean, default=False, nullable=False)
193
+
194
+    def __repr__(self) -> str:
195
+        return (
196
+            f"<Consumption: {self.consumption_type.name} for {self.person.full_name}>"
197
+        )
198
+
199
+    @property
200
+    def as_dict(self) -> dict:
201
+        return {
202
+            "consumption_id": self.consumption_id,
203
+            "person_id": self.person_id,
204
+            "consumption_type_id": self.consumption_type_id,
205
+            "settlement_id": self.settlement_id,
206
+            "created_at": self.created_at.isoformat(),
207
+            "reversed": self.reversed,
208
+        }

+ 0 - 0
piket_server/routes/__init__.py


+ 44 - 0
piket_server/routes/consumption_types.py

@@ -0,0 +1,44 @@
1
+"""
2
+Provides routes related to managing ConsumptionType objects.
3
+"""
4
+
5
+from sqlalchemy.exc import SQLAlchemyError
6
+from flask import jsonify, request
7
+
8
+from piket_server.models import ConsumptionType
9
+from piket_server.flask import app, db
10
+
11
+
12
+@app.route("/consumption_types", methods=["GET"])
13
+def get_consumption_types():
14
+    """ Return a list of currently active consumption types. """
15
+    ctypes = ConsumptionType.query.filter_by(active=True).all()
16
+    result = [ct.as_dict for ct in ctypes]
17
+    return jsonify(consumption_types=result)
18
+
19
+
20
+@app.route("/consumption_types/<int:consumption_type_id>", methods=["GET"])
21
+def get_consumption_type(consumption_type_id: int):
22
+    ct = ConsumptionType.query.get_or_404(consumption_type_id)
23
+
24
+    return jsonify(consumption_type=ct.as_dict)
25
+
26
+
27
+@app.route("/consumption_types", methods=["POST"])
28
+def add_consumption_type():
29
+    """ Add a new ConsumptionType.  """
30
+    json = request.get_json()
31
+
32
+    if not json:
33
+        return jsonify({"error": "Could not parse JSON."}), 400
34
+
35
+    data = json.get("consumption_type") or {}
36
+    ct = ConsumptionType(name=data.get("name"), icon=data.get("icon"))
37
+
38
+    try:
39
+        db.session.add(ct)
40
+        db.session.commit()
41
+    except SQLAlchemyError:
42
+        return jsonify({"error": "Invalid arguments for ConsumptionType."}), 400
43
+
44
+    return jsonify(consumption_type=ct.as_dict), 201

+ 36 - 0
piket_server/routes/consumptions.py

@@ -0,0 +1,36 @@
1
+"""
2
+Provides routes related to Consumption objects.
3
+"""
4
+
5
+from flask import jsonify
6
+from sqlalchemy.exc import SQLAlchemyError
7
+
8
+from piket_server.flask import app, db
9
+from piket_server.models import Consumption
10
+
11
+
12
+@app.route("/consumptions/<int:consumption_id>", methods=["DELETE"])
13
+def reverse_consumption(consumption_id: int):
14
+    """ Reverse a consumption. """
15
+    consumption = Consumption.query.get_or_404(consumption_id)
16
+
17
+    if consumption.reversed:
18
+        return (
19
+            jsonify(
20
+                {
21
+                    "error": "Consumption already reversed",
22
+                    "consumption": consumption.as_dict,
23
+                }
24
+            ),
25
+            409,
26
+        )
27
+
28
+    try:
29
+        consumption.reversed = True
30
+        db.session.add(consumption)
31
+        db.session.commit()
32
+
33
+    except SQLAlchemyError:
34
+        return jsonify({"error": "Database error."}), 500
35
+
36
+    return jsonify(consumption=consumption.as_dict), 200

+ 46 - 0
piket_server/routes/exports.py

@@ -0,0 +1,46 @@
1
+"""
2
+Provides routes for managing Export objects.
3
+"""
4
+
5
+from flask import jsonify
6
+from sqlalchemy.exc import SQLAlchemyError
7
+
8
+from piket_server.flask import app, db
9
+from piket_server.models import Export, Settlement
10
+
11
+@app.route("/exports", methods=["GET"])
12
+def get_exports():
13
+    """ Return a list of the created Exports. """
14
+    result = Export.query.all()
15
+    return jsonify(exports=[e.as_dict for e in result])
16
+
17
+
18
+@app.route("/exports/<int:export_id>", methods=["GET"])
19
+def get_export(export_id: int):
20
+    """ Return an overview for the given Export. """
21
+    e = Export.query.get_or_404(export_id)
22
+
23
+    ss = [s.as_dict for s in e.settlements]
24
+
25
+    return jsonify(export=e.as_dict, settlements=ss)
26
+
27
+
28
+@app.route("/exports", methods=["POST"])
29
+def add_export():
30
+    """ Create an Export, and link all un-exported Settlements to it. """
31
+    # Assert that there are Settlements to be exported.
32
+    s_count = Settlement.query.filter_by(export=None).count()
33
+    if s_count == 0:
34
+        return jsonify(error="No un-exported Settlements."), 403
35
+
36
+    e = Export()
37
+
38
+    db.session.add(e)
39
+    db.session.commit()
40
+
41
+    Settlement.query.filter_by(export=None).update({"export_id": e.export_id})
42
+    db.session.commit()
43
+
44
+    ss = [s.as_dict for s in e.settlements]
45
+
46
+    return jsonify(export=e.as_dict, settlements=ss), 201

+ 38 - 0
piket_server/routes/general.py

@@ -0,0 +1,38 @@
1
+"""
2
+Provides general routes.
3
+"""
4
+
5
+from flask import jsonify
6
+
7
+from piket_server.flask import app
8
+from piket_server.models import Consumption
9
+
10
+
11
+@app.route("/ping")
12
+def ping() -> str:
13
+    """ Return a status ping. """
14
+    return "Pong"
15
+
16
+
17
+@app.route("/status")
18
+def status():
19
+    """ Return a status dict with info about the database. """
20
+    unsettled_q = Consumption.query.filter_by(settlement=None).filter_by(reversed=False)
21
+
22
+    unsettled = unsettled_q.count()
23
+
24
+    first = None
25
+    last = None
26
+    if unsettled:
27
+        last = (
28
+            unsettled_q.order_by(Consumption.created_at.desc())
29
+            .first()
30
+            .created_at.isoformat()
31
+        )
32
+        first = (
33
+            unsettled_q.order_by(Consumption.created_at.asc())
34
+            .first()
35
+            .created_at.isoformat()
36
+        )
37
+
38
+    return jsonify({"unsettled": {"amount": unsettled, "first": first, "last": last}})

+ 107 - 0
piket_server/routes/people.py

@@ -0,0 +1,107 @@
1
+"""
2
+Provides routes related to managing Person objects.
3
+"""
4
+
5
+from flask import jsonify, request
6
+from sqlalchemy.exc import SQLAlchemyError
7
+
8
+from piket_server.models import Consumption, Person
9
+from piket_server.flask import app, db
10
+
11
+
12
+@app.route("/people", methods=["GET"])
13
+def get_people():
14
+    """ Return a list of currently known people. """
15
+    people = Person.query.order_by(Person.full_name).all()
16
+    q = Person.query.order_by(Person.full_name)
17
+    if request.args.get("active"):
18
+        active_status = request.args.get("active", type=int)
19
+        q = q.filter_by(active=active_status)
20
+    people = q.all()
21
+    result = [person.as_dict for person in people]
22
+    return jsonify(people=result)
23
+
24
+
25
+@app.route("/people/<int:person_id>", methods=["GET"])
26
+def get_person(person_id: int):
27
+    person = Person.query.get_or_404(person_id)
28
+
29
+    return jsonify(person=person.as_dict)
30
+
31
+
32
+@app.route("/people", methods=["POST"])
33
+def add_person():
34
+    """
35
+    Add a new person.
36
+
37
+    Required parameters:
38
+    - name (str)
39
+    """
40
+    json = request.get_json()
41
+
42
+    if not json:
43
+        return jsonify({"error": "Could not parse JSON."}), 400
44
+
45
+    data = json.get("person") or {}
46
+    person = Person(name=data.get("name"), active=data.get("active", False))
47
+
48
+    try:
49
+        db.session.add(person)
50
+        db.session.commit()
51
+    except SQLAlchemyError:
52
+        return jsonify({"error": "Invalid arguments for Person."}), 400
53
+
54
+    return jsonify(person=person.as_dict), 201
55
+
56
+
57
+@app.route("/people/<int:person_id>", methods=["PATCH"])
58
+def update_person(person_id: int):
59
+    person = Person.query.get_or_404(person_id)
60
+
61
+    data = request.json["person"]
62
+
63
+    if "active" in data:
64
+        person.active = data["active"]
65
+
66
+        db.session.add(person)
67
+        db.session.commit()
68
+
69
+        return jsonify(person=person.as_dict)
70
+
71
+
72
+@app.route("/people/<int:person_id>/add_consumption", methods=["POST"])
73
+def add_consumption(person_id: int):
74
+    person = Person.query.get_or_404(person_id)
75
+
76
+    consumption = Consumption(person=person, consumption_type_id=1)
77
+    try:
78
+        db.session.add(consumption)
79
+        db.session.commit()
80
+    except SQLAlchemyError:
81
+        return (
82
+            jsonify(
83
+                {"error": "Invalid Consumption parameters.", "person": person.as_dict}
84
+            ),
85
+            400,
86
+        )
87
+
88
+    return jsonify(person=person.as_dict, consumption=consumption.as_dict), 201
89
+
90
+
91
+@app.route("/people/<int:person_id>/add_consumption/<int:ct_id>", methods=["POST"])
92
+def add_consumption2(person_id: int, ct_id: int):
93
+    person = Person.query.get_or_404(person_id)
94
+
95
+    consumption = Consumption(person=person, consumption_type_id=ct_id)
96
+    try:
97
+        db.session.add(consumption)
98
+        db.session.commit()
99
+    except SQLAlchemyError:
100
+        return (
101
+            jsonify(
102
+                {"error": "Invalid Consumption parameters.", "person": person.as_dict}
103
+            ),
104
+            400,
105
+        )
106
+
107
+    return jsonify(person=person.as_dict, consumption=consumption.as_dict), 201

+ 49 - 0
piket_server/routes/settlements.py

@@ -0,0 +1,49 @@
1
+"""
2
+Provides routes for managing Settlement objects.
3
+"""
4
+
5
+from sqlalchemy.exc import SQLAlchemyError
6
+from flask import jsonify, request
7
+
8
+from piket_server.flask import app, db
9
+from piket_server.models import Consumption, Settlement
10
+
11
+
12
+@app.route("/settlements", methods=["GET"])
13
+def get_settlements():
14
+    """ Return a list of the active Settlements. """
15
+    result = Settlement.query.all()
16
+    return jsonify(settlements=[s.as_dict for s in result])
17
+
18
+
19
+@app.route("/settlements/<int:settlement_id>", methods=["GET"])
20
+def get_settlement(settlement_id: int):
21
+    """ Show full details for a single Settlement. """
22
+    s = Settlement.query.get_or_404(settlement_id)
23
+
24
+    per_person = s.per_person
25
+
26
+    return jsonify(settlement=s.as_dict, count_info=per_person)
27
+
28
+
29
+@app.route("/settlements", methods=["POST"])
30
+def add_settlement():
31
+    """ Create a Settlement, and link all un-settled Consumptions to it. """
32
+    json = request.get_json()
33
+
34
+    if not json:
35
+        return jsonify({"error": "Could not parse JSON."}), 400
36
+
37
+    data = json.get("settlement") or {}
38
+    s = Settlement(name=data["name"])
39
+
40
+    db.session.add(s)
41
+    db.session.commit()
42
+
43
+    Consumption.query.filter_by(settlement=None).update(
44
+        {"settlement_id": s.settlement_id}
45
+    )
46
+
47
+    db.session.commit()
48
+
49
+    return jsonify(settlement=s.as_dict)

+ 4 - 3
piket_server/seed.py

@@ -6,7 +6,8 @@ import argparse
6 6
 import csv
7 7
 import os
8 8
 
9
-from piket_server import db, Person, Settlement, ConsumptionType, Consumption
9
+from piket_server.models import Person, Settlement, ConsumptionType, Consumption
10
+from piket_server.flask import db
10 11
 
11 12
 
12 13
 def main():
@@ -52,8 +53,8 @@ def cmd_clear(args) -> None:
52 53
         print("All data removed. Recreating database...")
53 54
         db.create_all()
54 55
 
55
-        from alembic.config import Config
56
-        from alembic import command
56
+        from alembic.config import Config  # type: ignore
57
+        from alembic import command  # type: ignore
57 58
 
58 59
         alembic_cfg = Config(os.path.join(os.path.dirname(__file__), "alembic.ini"))
59 60
         command.stamp(alembic_cfg, "head")