Maarten van den Berg лет назад: 6
Родитель
Сommit
e1c6decce8

+ 3 - 0
piket_client/TODO

@@ -0,0 +1,3 @@
1
+- Undo werkend
2
+- Rebuild werkend, refreshknop
3
+- CT toevoegen werkend, koppelen aan refresh

+ 43 - 7
piket_client/gui.py

@@ -1,6 +1,7 @@
1 1
 """
2 2
 Provides the graphical front-end for Piket.
3 3
 """
4
+import collections
4 5
 import logging
5 6
 import os
6 7
 import sys
@@ -29,8 +30,8 @@ try:
29 30
 except ImportError:
30 31
     dbus = None
31 32
 
32
-from piket_client.sound import PLOP_WAVE
33
-from piket_client.model import Person, ConsumptionType
33
+from piket_client.sound import PLOP_WAVE, UNDO_WAVE
34
+from piket_client.model import Person, ConsumptionType, Consumption
34 35
 import piket_client.logger
35 36
 
36 37
 LOG = logging.getLogger(__name__)
@@ -44,12 +45,15 @@ def plop() -> None:
44 45
 class NameButton(QPushButton):
45 46
     """ Wraps a QPushButton to provide a counter. """
46 47
 
48
+    consumption_created = Signal(Consumption)
49
+
47 50
     def __init__(self, person: Person, active_id: str, *args, **kwargs) -> None:
48 51
         self.person = person
49 52
         self.active_id = active_id
50 53
 
51 54
         super().__init__(self.current_label, *args, **kwargs)
52 55
         self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
56
+        self.consumption_created.connect(self.window().consumption_added)
53 57
 
54 58
         self.clicked.connect(self.process_click)
55 59
 
@@ -78,9 +82,11 @@ class NameButton(QPushButton):
78 82
 
79 83
     def process_click(self) -> None:
80 84
         """ Process a click on this button. """
81
-        if self.person.add_consumption(self.active_id):
85
+        result = self.person.add_consumption(self.active_id)
86
+        if result:
82 87
             plop()
83 88
             self.setText(self.current_label)
89
+            self.consumption_created.emit(result)
84 90
         else:
85 91
             print("Jantoeternuitje, kapot")
86 92
 
@@ -91,8 +97,8 @@ class NameButtons(QWidget):
91 97
 
92 98
     new_id_set = Signal(str)
93 99
 
94
-    def __init__(self, consumption_type_id: str) -> None:
95
-        super().__init__()
100
+    def __init__(self, consumption_type_id: str, *args, **kwargs) -> None:
101
+        super().__init__(*args, **kwargs)
96 102
 
97 103
         self.layout = None
98 104
         self.layout = QGridLayout()
@@ -143,6 +149,8 @@ class PiketMainWindow(QMainWindow):
143 149
         self.main_widget = None
144 150
         self.toolbar = None
145 151
         self.osk = None
152
+        self.undo_action = None
153
+        self.undo_queue = collections.deque([], 20)
146 154
         self.init_ui()
147 155
 
148 156
     def init_ui(self) -> None:
@@ -174,7 +182,10 @@ class PiketMainWindow(QMainWindow):
174 182
         self.toolbar.addAction(
175 183
             self.load_icon("add_person.svg"), "Nieuw persoon", self.add_person
176 184
         )
177
-        self.toolbar.addAction(self.load_icon("undo.svg"), "Oeps")
185
+        self.undo_action = self.toolbar.addAction(
186
+            self.load_icon("undo.svg"), "Oeps", self.do_undo
187
+        )
188
+        self.undo_action.setDisabled(True)
178 189
 
179 190
         self.toolbar.addAction(
180 191
             self.load_icon("quit.svg"), "Afsluiten", self.confirm_quit
@@ -231,7 +242,7 @@ class PiketMainWindow(QMainWindow):
231 242
         self.addToolBar(self.toolbar)
232 243
 
233 244
         # Initialize main widget
234
-        self.main_widget = NameButtons(ag.actions()[0].data())
245
+        self.main_widget = NameButtons(ag.actions()[0].data(), self)
235 246
         self.consumption_type_changed.connect(self.main_widget.consumption_type_changed)
236 247
         self.setCentralWidget(self.main_widget)
237 248
 
@@ -282,6 +293,31 @@ class PiketMainWindow(QMainWindow):
282 293
             LOG.warning("Shutdown by user.")
283 294
             QApplication.instance().quit()
284 295
 
296
+    def do_undo(self) -> None:
297
+        """ Undo the last marked consumption. """
298
+        UNDO_WAVE.play()
299
+
300
+        to_undo = self.undo_queue.pop()
301
+        LOG.warning("Undoing consumption %s", to_undo)
302
+
303
+        result = to_undo.reverse()
304
+
305
+        if not result or not result.reversed:
306
+            LOG.error("Reversed consumption %s but was not reversed!", to_undo)
307
+            self.undo_queue.append(to_undo)
308
+
309
+        elif not self.undo_queue:
310
+            self.undo_action.setDisabled(True)
311
+
312
+        self.main_widget.init_ui()
313
+
314
+    @Slot(Consumption)
315
+    def consumption_added(self, consumption):
316
+        """ Mark an added consumption in the queue. """
317
+
318
+        self.undo_queue.append(consumption)
319
+        self.undo_action.setDisabled(False)
320
+
285 321
     @staticmethod
286 322
     def create_spacer() -> QWidget:
287 323
         """ Return an empty QWidget that automatically expands. """

+ 53 - 1
piket_client/model.py

@@ -1,6 +1,7 @@
1 1
 """
2 2
 Provides access to the models stored in the database, via the server.
3 3
 """
4
+import datetime
4 5
 from typing import NamedTuple
5 6
 from urllib.parse import urljoin
6 7
 
@@ -39,7 +40,7 @@ class Person(NamedTuple):
39 40
 
40 41
             self.consumptions.update(data["person"]["consumptions"])
41 42
 
42
-            return True
43
+            return Consumption.from_dict(data["consumption"])
43 44
         except ValueError:
44 45
             LOG.error(
45 46
                 "Did not get JSON on adding Consumption (%s): %s",
@@ -220,3 +221,54 @@ class ConsumptionType(NamedTuple):
220 221
             consumption_type_id=data["consumption_type_id"],
221 222
             icon=data.get("icon"),
222 223
         )
224
+
225
+
226
+class Consumption(NamedTuple):
227
+    """ Represents a stored Consumption. """
228
+
229
+    consumption_id: int
230
+    person_id: int
231
+    consumption_type_id: int
232
+    created_at: datetime.datetime
233
+    reversed: bool = False
234
+    settlement_id: int = None
235
+
236
+    @classmethod
237
+    def from_dict(cls, data: dict) -> "Consumption":
238
+        """ Reconstruct a Consumption from a dict. """
239
+        return cls(
240
+            consumption_id=data["consumption_id"],
241
+            person_id=data["person_id"],
242
+            consumption_type_id=data["consumption_type_id"],
243
+            settlement_id=data["settlement_id"],
244
+            created_at=datetime.datetime.fromisoformat(data["created_at"]),
245
+            reversed=data["reversed"],
246
+        )
247
+
248
+    def reverse(self) -> "Consumption":
249
+        """ Reverse this consumption. """
250
+        req = requests.delete(
251
+            urljoin(SERVER_URL, f"/consumptions/{self.consumption_id}")
252
+        )
253
+
254
+        try:
255
+            data = req.json()
256
+
257
+            if "error" in data:
258
+                LOG.error(
259
+                    "Could not reverse consumption %s (%s): %s",
260
+                    self.consumption_id,
261
+                    req.status_code,
262
+                    data,
263
+                )
264
+                return False
265
+
266
+            return Consumption.from_dict(data["consumption"])
267
+
268
+        except ValueError:
269
+            LOG.error(
270
+                "Did not get JSON on reversing Consumption (%s): %s",
271
+                req.status_code,
272
+                req.content,
273
+            )
274
+            return False

+ 3 - 0
piket_client/sound.py

@@ -12,3 +12,6 @@ SOUNDS_DIR = os.path.join(os.path.dirname(__file__), "sounds")
12 12
 
13 13
 PLOP_WAVE = sa.WaveObject.from_wave_file(os.path.join(SOUNDS_DIR, "plop.wav"))
14 14
 """ SimpleAudio WaveObject containing the plop sound. """
15
+
16
+UNDO_WAVE = sa.WaveObject.from_wave_file(os.path.join(SOUNDS_DIR, "undo.wav"))
17
+""" SimpleAudio WaveObject containing the undo sound. """

BIN
piket_client/sounds/undo.wav


+ 31 - 0
piket_server/__init__.py

@@ -43,6 +43,7 @@ class Person(db.Model):
43 43
             "consumptions": {
44 44
                 ct.consumption_type_id: Consumption.query.filter_by(person=self)
45 45
                 .filter_by(consumption_type=ct)
46
+                .filter_by(reversed=False)
46 47
                 .count()
47 48
                 for ct in ConsumptionType.query.all()
48 49
             },
@@ -104,6 +105,7 @@ class Consumption(db.Model):
104 105
     created_at = db.Column(
105 106
         db.DateTime, default=datetime.datetime.utcnow, nullable=False
106 107
     )
108
+    reversed = db.Column(db.Boolean, default=False, nullable=False)
107 109
 
108 110
     def __repr__(self) -> str:
109 111
         return f"<Consumption: {self.consumption_type.name} for {self.person.name}>"
@@ -111,10 +113,12 @@ class Consumption(db.Model):
111 113
     @property
112 114
     def as_dict(self) -> dict:
113 115
         return {
116
+            "consumption_id": self.consumption_id,
114 117
             "person_id": self.person_id,
115 118
             "consumption_type_id": self.consumption_type_id,
116 119
             "settlement_id": self.settlement_id,
117 120
             "created_at": self.created_at.isoformat(),
121
+            "reversed": self.reversed,
118 122
         }
119 123
 
120 124
 
@@ -205,6 +209,33 @@ def add_consumption2(person_id: int, ct_id: int):
205 209
     return jsonify(person=person.as_dict, consumption=consumption.as_dict), 201
206 210
 
207 211
 
212
+@app.route("/consumptions/<int:consumption_id>", methods=["DELETE"])
213
+def reverse_consumption(consumption_id: int):
214
+    """ Reverse a consumption. """
215
+    consumption = Consumption.query.get_or_404(consumption_id)
216
+
217
+    if consumption.reversed:
218
+        return (
219
+            jsonify(
220
+                {
221
+                    "error": "Consumption already reversed",
222
+                    "consumption": consumption.as_dict,
223
+                }
224
+            ),
225
+            409,
226
+        )
227
+
228
+    try:
229
+        consumption.reversed = True
230
+        db.session.add(consumption)
231
+        db.session.commit()
232
+
233
+    except SQLAlchemyError:
234
+        return jsonify({"error": "Database error."}), 500
235
+
236
+    return jsonify(consumption=consumption.as_dict), 200
237
+
238
+
208 239
 # ConsumptionType
209 240
 @app.route("/consumption_types", methods=["GET"])
210 241
 def get_consumption_types():

+ 0 - 40
piket_server/alembic/versions/491bb980d1d7_add_settlement_allow_null_person_on_.py

@@ -1,40 +0,0 @@
1
-"""Add Settlements
2
-
3
-Revision ID: 491bb980d1d7
4
-Revises: de101d627237
5
-Create Date: 2018-08-22 22:37:49.467438
6
-
7
-"""
8
-from alembic import op
9
-import sqlalchemy as sa
10
-
11
-
12
-# revision identifiers, used by Alembic.
13
-revision = "491bb980d1d7"
14
-down_revision = "de101d627237"
15
-branch_labels = None
16
-depends_on = None
17
-
18
-
19
-def upgrade():
20
-    op.create_table(
21
-        "settlements",
22
-        sa.Column("settlement_id", sa.Integer, primary_key=True),
23
-        sa.Column("name", sa.String, nullable=False),
24
-    )
25
-
26
-    op.add_column(
27
-        "consumptions",
28
-        sa.Column(
29
-            "settlement_id",
30
-            sa.Integer,
31
-            sa.ForeignKey("settlements.settlement_id"),
32
-            nullable=True,
33
-        ),
34
-    )
35
-
36
-
37
-def downgrade():
38
-    op.drop_column("consumptions", "settlement_id")
39
-
40
-    op.drop_table("settlements")

+ 11 - 1
piket_server/alembic/versions/de101d627237_create_consumptions_consumption_types.py

@@ -1,4 +1,4 @@
1
-"""Create consumptions, consumption_types
1
+"""Create consumptions, consumption_types, settlements
2 2
 
3 3
 Revision ID: de101d627237
4 4
 Revises: a09086bfe84c
@@ -25,19 +25,29 @@ def upgrade():
25 25
     )
26 26
 
27 27
     op.create_table(
28
+        "settlements",
29
+        sa.Column("settlement_id", sa.Integer, primary_key=True),
30
+        sa.Column("name", sa.String, nullable=False),
31
+    )
32
+
33
+    op.create_table(
28 34
         "consumptions",
29 35
         sa.Column("consumption_id", sa.Integer, primary_key=True),
30 36
         sa.Column("person_id", sa.Integer, nullable=True),
31 37
         sa.Column("consumption_type_id", sa.Integer, nullable=False),
32 38
         sa.Column("created_at", sa.DateTime, nullable=False),
39
+        sa.Column("settlement_id", sa.Integer, nullable=True),
40
+        sa.Column("reversed", sa.Boolean, nullable=False, server_default="FALSE"),
33 41
         sa.ForeignKeyConstraint(
34 42
             ["consumption_type_id"], ["consumption_types.consumption_type_id"]
35 43
         ),
36 44
         sa.ForeignKeyConstraint(["person_id"], ["people.person_id"]),
45
+        sa.ForeignKeyConstraint(["settlement_id"], ["settlements.settlement_id"]),
37 46
         sa.PrimaryKeyConstraint("consumption_id"),
38 47
     )
39 48
 
40 49
 
41 50
 def downgrade():
42 51
     op.drop_table("consumptions")
52
+    op.drop_table("settlements")
43 53
     op.drop_table("consumption_types")

+ 8 - 2
piket_server/seed.py

@@ -4,6 +4,7 @@ Provides functions to manage the database while the server is offline.
4 4
 
5 5
 import argparse
6 6
 import csv
7
+import os
7 8
 
8 9
 from piket_server import db, Person, Settlement, ConsumptionType, Consumption
9 10
 
@@ -47,11 +48,16 @@ def cmd_clear(args) -> None:
47 48
         print("All data removed. Recreating database...")
48 49
         db.create_all()
49 50
 
51
+        from alembic.config import Config
52
+        from alembic import command
53
+
54
+        alembic_cfg = Config(os.path.join(os.path.dirname(__file__), "alembic.ini"))
55
+        command.stamp(alembic_cfg, "head")
56
+
50 57
         print("Done.")
51 58
         return
52 59
 
53
-    else:
54
-        print("Aborting.")
60
+    print("Aborting.")
55 61
 
56 62
 
57 63
 if __name__ == "__main__":