Maarten van den Berg 6 years ago
parent
commit
e1c6decce8

+ 3 - 0
piket_client/TODO

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

+ 43 - 7
piket_client/gui.py

1
 """
1
 """
2
 Provides the graphical front-end for Piket.
2
 Provides the graphical front-end for Piket.
3
 """
3
 """
4
+import collections
4
 import logging
5
 import logging
5
 import os
6
 import os
6
 import sys
7
 import sys
29
 except ImportError:
30
 except ImportError:
30
     dbus = None
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
 import piket_client.logger
35
 import piket_client.logger
35
 
36
 
36
 LOG = logging.getLogger(__name__)
37
 LOG = logging.getLogger(__name__)
44
 class NameButton(QPushButton):
45
 class NameButton(QPushButton):
45
     """ Wraps a QPushButton to provide a counter. """
46
     """ Wraps a QPushButton to provide a counter. """
46
 
47
 
48
+    consumption_created = Signal(Consumption)
49
+
47
     def __init__(self, person: Person, active_id: str, *args, **kwargs) -> None:
50
     def __init__(self, person: Person, active_id: str, *args, **kwargs) -> None:
48
         self.person = person
51
         self.person = person
49
         self.active_id = active_id
52
         self.active_id = active_id
50
 
53
 
51
         super().__init__(self.current_label, *args, **kwargs)
54
         super().__init__(self.current_label, *args, **kwargs)
52
         self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
55
         self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
56
+        self.consumption_created.connect(self.window().consumption_added)
53
 
57
 
54
         self.clicked.connect(self.process_click)
58
         self.clicked.connect(self.process_click)
55
 
59
 
78
 
82
 
79
     def process_click(self) -> None:
83
     def process_click(self) -> None:
80
         """ Process a click on this button. """
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
             plop()
87
             plop()
83
             self.setText(self.current_label)
88
             self.setText(self.current_label)
89
+            self.consumption_created.emit(result)
84
         else:
90
         else:
85
             print("Jantoeternuitje, kapot")
91
             print("Jantoeternuitje, kapot")
86
 
92
 
91
 
97
 
92
     new_id_set = Signal(str)
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
         self.layout = None
103
         self.layout = None
98
         self.layout = QGridLayout()
104
         self.layout = QGridLayout()
143
         self.main_widget = None
149
         self.main_widget = None
144
         self.toolbar = None
150
         self.toolbar = None
145
         self.osk = None
151
         self.osk = None
152
+        self.undo_action = None
153
+        self.undo_queue = collections.deque([], 20)
146
         self.init_ui()
154
         self.init_ui()
147
 
155
 
148
     def init_ui(self) -> None:
156
     def init_ui(self) -> None:
174
         self.toolbar.addAction(
182
         self.toolbar.addAction(
175
             self.load_icon("add_person.svg"), "Nieuw persoon", self.add_person
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
         self.toolbar.addAction(
190
         self.toolbar.addAction(
180
             self.load_icon("quit.svg"), "Afsluiten", self.confirm_quit
191
             self.load_icon("quit.svg"), "Afsluiten", self.confirm_quit
231
         self.addToolBar(self.toolbar)
242
         self.addToolBar(self.toolbar)
232
 
243
 
233
         # Initialize main widget
244
         # Initialize main widget
234
-        self.main_widget = NameButtons(ag.actions()[0].data())
245
+        self.main_widget = NameButtons(ag.actions()[0].data(), self)
235
         self.consumption_type_changed.connect(self.main_widget.consumption_type_changed)
246
         self.consumption_type_changed.connect(self.main_widget.consumption_type_changed)
236
         self.setCentralWidget(self.main_widget)
247
         self.setCentralWidget(self.main_widget)
237
 
248
 
282
             LOG.warning("Shutdown by user.")
293
             LOG.warning("Shutdown by user.")
283
             QApplication.instance().quit()
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
     @staticmethod
321
     @staticmethod
286
     def create_spacer() -> QWidget:
322
     def create_spacer() -> QWidget:
287
         """ Return an empty QWidget that automatically expands. """
323
         """ Return an empty QWidget that automatically expands. """

+ 53 - 1
piket_client/model.py

1
 """
1
 """
2
 Provides access to the models stored in the database, via the server.
2
 Provides access to the models stored in the database, via the server.
3
 """
3
 """
4
+import datetime
4
 from typing import NamedTuple
5
 from typing import NamedTuple
5
 from urllib.parse import urljoin
6
 from urllib.parse import urljoin
6
 
7
 
39
 
40
 
40
             self.consumptions.update(data["person"]["consumptions"])
41
             self.consumptions.update(data["person"]["consumptions"])
41
 
42
 
42
-            return True
43
+            return Consumption.from_dict(data["consumption"])
43
         except ValueError:
44
         except ValueError:
44
             LOG.error(
45
             LOG.error(
45
                 "Did not get JSON on adding Consumption (%s): %s",
46
                 "Did not get JSON on adding Consumption (%s): %s",
220
             consumption_type_id=data["consumption_type_id"],
221
             consumption_type_id=data["consumption_type_id"],
221
             icon=data.get("icon"),
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
 
12
 
13
 PLOP_WAVE = sa.WaveObject.from_wave_file(os.path.join(SOUNDS_DIR, "plop.wav"))
13
 PLOP_WAVE = sa.WaveObject.from_wave_file(os.path.join(SOUNDS_DIR, "plop.wav"))
14
 """ SimpleAudio WaveObject containing the plop sound. """
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
             "consumptions": {
43
             "consumptions": {
44
                 ct.consumption_type_id: Consumption.query.filter_by(person=self)
44
                 ct.consumption_type_id: Consumption.query.filter_by(person=self)
45
                 .filter_by(consumption_type=ct)
45
                 .filter_by(consumption_type=ct)
46
+                .filter_by(reversed=False)
46
                 .count()
47
                 .count()
47
                 for ct in ConsumptionType.query.all()
48
                 for ct in ConsumptionType.query.all()
48
             },
49
             },
104
     created_at = db.Column(
105
     created_at = db.Column(
105
         db.DateTime, default=datetime.datetime.utcnow, nullable=False
106
         db.DateTime, default=datetime.datetime.utcnow, nullable=False
106
     )
107
     )
108
+    reversed = db.Column(db.Boolean, default=False, nullable=False)
107
 
109
 
108
     def __repr__(self) -> str:
110
     def __repr__(self) -> str:
109
         return f"<Consumption: {self.consumption_type.name} for {self.person.name}>"
111
         return f"<Consumption: {self.consumption_type.name} for {self.person.name}>"
111
     @property
113
     @property
112
     def as_dict(self) -> dict:
114
     def as_dict(self) -> dict:
113
         return {
115
         return {
116
+            "consumption_id": self.consumption_id,
114
             "person_id": self.person_id,
117
             "person_id": self.person_id,
115
             "consumption_type_id": self.consumption_type_id,
118
             "consumption_type_id": self.consumption_type_id,
116
             "settlement_id": self.settlement_id,
119
             "settlement_id": self.settlement_id,
117
             "created_at": self.created_at.isoformat(),
120
             "created_at": self.created_at.isoformat(),
121
+            "reversed": self.reversed,
118
         }
122
         }
119
 
123
 
120
 
124
 
205
     return jsonify(person=person.as_dict, consumption=consumption.as_dict), 201
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
 # ConsumptionType
239
 # ConsumptionType
209
 @app.route("/consumption_types", methods=["GET"])
240
 @app.route("/consumption_types", methods=["GET"])
210
 def get_consumption_types():
241
 def get_consumption_types():

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

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

+ 8 - 2
piket_server/seed.py

4
 
4
 
5
 import argparse
5
 import argparse
6
 import csv
6
 import csv
7
+import os
7
 
8
 
8
 from piket_server import db, Person, Settlement, ConsumptionType, Consumption
9
 from piket_server import db, Person, Settlement, ConsumptionType, Consumption
9
 
10
 
47
         print("All data removed. Recreating database...")
48
         print("All data removed. Recreating database...")
48
         db.create_all()
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
         print("Done.")
57
         print("Done.")
51
         return
58
         return
52
 
59
 
53
-    else:
54
-        print("Aborting.")
60
+    print("Aborting.")
55
 
61
 
56
 
62
 
57
 if __name__ == "__main__":
63
 if __name__ == "__main__":