Browse Source

Monstercommit adding persistency

- Database moved to flask-sqlalchemy's wrapper thing
- Alembic moved up a level since models can't be their own file anymore
- Add Settlement, ConsumptionType
- Make server routes use the database
- Add consumptions

Note: migrating up from base doesn't work because sqlite has no alter
table, sad.
Maarten van den Berg 6 years ago
parent
commit
1564237f7d

+ 6 - 4
piket_client/gui.py

@@ -43,9 +43,9 @@ class NameButton(QPushButton):
43 43
     """ Wraps a QPushButton to provide a counter. """
44 44
 
45 45
     def __init__(self, person: dict, *args, **kwargs) -> None:
46
-        self.person_id = person["id"]
46
+        self.person_id = person["person_id"]
47 47
         self.name = person["name"]
48
-        self.count = person["count"]
48
+        self.count = person["consumptions"]["1"]
49 49
 
50 50
         super().__init__(self.current_label, *args, **kwargs)
51 51
         self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
@@ -65,7 +65,7 @@ class NameButton(QPushButton):
65 65
         if req.status_code == 200:
66 66
             json = req.json()
67 67
             person = json["person"]
68
-            self.count = person["count"]
68
+            self.count = person["consumptions"]["1"]
69 69
             self.setText(self.current_label)
70 70
             plop()
71 71
         else:
@@ -141,7 +141,9 @@ class PiketMainWindow(QMainWindow):
141 141
             True,
142 142
         )
143 143
         if ok and name:
144
-            req = requests.post(urljoin(SERVER_URL, "people"), json={"name": name})
144
+            req = requests.post(
145
+                urljoin(SERVER_URL, "people"), json={"person": {"name": name}}
146
+            )
145 147
 
146 148
             self.main_widget = NameButtons()
147 149
             self.setCentralWidget(self.main_widget)

+ 144 - 32
piket_server/__init__.py

@@ -2,10 +2,123 @@
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
5 9
 from flask import Flask, jsonify, abort, request
10
+from flask_sqlalchemy import SQLAlchemy
11
+
6 12
 
13
+DATA_HOME = os.environ.get("XDG_DATA_HOME", "~/.local/share")
14
+CONFIG_DIR = os.path.join(DATA_HOME, "piket_server")
15
+DB_PATH = os.path.expanduser(os.path.join(CONFIG_DIR, "database.sqlite3"))
16
+DB_URL = f"sqlite:///{DB_PATH}"
7 17
 
8 18
 app = Flask("piket_server")
19
+app.config["SQLALCHEMY_DATABASE_URI"] = DB_URL
20
+app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
21
+db = SQLAlchemy(app)
22
+
23
+
24
+# ---------- Models ----------
25
+class Person(db.Model):
26
+    """ Represents a person to be shown on the lists. """
27
+
28
+    __tablename__ = "people"
29
+
30
+    person_id = db.Column(db.Integer, primary_key=True)
31
+    name = db.Column(db.String, nullable=False)
32
+
33
+    consumptions = db.relationship("Consumption", backref="person", lazy=True)
34
+
35
+    def __repr__(self) -> str:
36
+        return f"<Person {self.person_id}: {self.name}>"
37
+
38
+    @property
39
+    def as_dict(self) -> dict:
40
+        return {
41
+            "person_id": self.person_id,
42
+            "name": self.name,
43
+            "consumptions": {
44
+                ct.consumption_type_id: Consumption.query.filter_by(person=self)
45
+                .filter_by(consumption_type=ct)
46
+                .count()
47
+                for ct in ConsumptionType.query.all()
48
+            },
49
+        }
50
+
51
+
52
+class Settlement(db.Model):
53
+    """ Represents a settlement of the list. """
54
+
55
+    __tablename__ = "settlements"
56
+
57
+    settlement_id = db.Column(db.Integer, primary_key=True)
58
+    name = db.Column(db.String, nullable=False)
59
+
60
+    consumptions = db.relationship("Consumption", backref="settlement", lazy=True)
61
+
62
+    def __repr__(self) -> str:
63
+        return f"<Settlement {self.settlement_id}: {self.name}>"
64
+
65
+
66
+class ConsumptionType(db.Model):
67
+    """ Represents a type of consumption to be counted. """
68
+
69
+    __tablename__ = "consumption_types"
70
+
71
+    consumption_type_id = db.Column(db.Integer, primary_key=True)
72
+    name = db.Column(db.String, nullable=False)
73
+    icon = db.Column(db.String)
74
+
75
+    consumptions = db.relationship("Consumption", backref="consumption_type", lazy=True)
76
+
77
+    def __repr__(self) -> str:
78
+        return f"<ConsumptionType: {self.name}>"
79
+
80
+    @property
81
+    def as_dict(self) -> dict:
82
+        return {
83
+            "consumption_type_id": self.consumption_type_id,
84
+            "name": self.name,
85
+            "icon": self.icon,
86
+        }
87
+
88
+
89
+class Consumption(db.Model):
90
+    """ Represent one consumption to be counted. """
91
+
92
+    __tablename__ = "consumptions"
93
+
94
+    consumption_id = db.Column(db.Integer, primary_key=True)
95
+    person_id = db.Column(db.Integer, db.ForeignKey("people.person_id"), nullable=True)
96
+    consumption_type_id = db.Column(
97
+        db.Integer,
98
+        db.ForeignKey("consumption_types.consumption_type_id"),
99
+        nullable=False,
100
+    )
101
+    settlement_id = db.Column(
102
+        db.Integer, db.ForeignKey("settlements.settlement_id"), nullable=True
103
+    )
104
+    created_at = db.Column(
105
+        db.DateTime, default=datetime.datetime.utcnow, nullable=False
106
+    )
107
+
108
+    def __repr__(self) -> str:
109
+        return f"<Consumption: {self.consumption_type.name} for {self.person.name}>"
110
+
111
+    @property
112
+    def as_dict(self) -> dict:
113
+        return {
114
+            "person_id": self.person_id,
115
+            "consumption_type_id": self.consumption_type_id,
116
+            "settlement_id": self.settlement_id,
117
+            "created_at": self.created_at.isoformat(),
118
+        }
119
+
120
+
121
+# ---------- Models ----------
9 122
 
10 123
 
11 124
 @app.route("/ping")
@@ -33,19 +146,16 @@ NEXT_ID = len(PEOPLE)
33 146
 @app.route("/people", methods=["GET"])
34 147
 def get_people():
35 148
     """ Return a list of currently known people. """
36
-    people = [p for p in PEOPLE.values()]
37
-    people.sort(key=lambda p: p["name"])
38
-    return jsonify(people=people)
149
+    people = Person.query.order_by(Person.name).all()
150
+    result = [person.as_dict for person in people]
151
+    return jsonify(people=result)
39 152
 
40 153
 
41 154
 @app.route("/people/<int:person_id>", methods=["GET"])
42 155
 def get_person(person_id: int):
43
-    person = PEOPLE.get(person_id)
44
-
45
-    if not person:
46
-        abort(404)
156
+    person = Person.query.get_or_404(person_id)
47 157
 
48
-    return jsonify(person=person)
158
+    return jsonify(person=person.as_dict)
49 159
 
50 160
 
51 161
 @app.route("/people", methods=["POST"])
@@ -56,34 +166,36 @@ def add_person():
56 166
     Required parameters:
57 167
     - name (str)
58 168
     """
59
-    global NEXT_ID
60
-
61
-    data = request.get_json()
62
-
63
-    if not data:
64
-        abort(400)
65
-
66
-    name = data.get("name")
169
+    json = request.get_json()
67 170
 
68
-    if not name:
69
-        abort(400)
70
-    person = {"id": NEXT_ID, "name": name, "count": 0}
171
+    if not json:
172
+        return jsonify({"error": "Could not parse JSON."}), 400
71 173
 
72
-    PEOPLE[NEXT_ID] = person
174
+    data = json.get("person") or {}
175
+    person = Person(name=data.get("name"))
176
+    try:
177
+        db.session.add(person)
178
+        db.session.commit()
179
+    except SQLAlchemyError:
180
+        return jsonify({"error": "Invalid arguments for Person."})
73 181
 
74
-    NEXT_ID += 1
75
-
76
-    return jsonify(person=person)
182
+    return jsonify(person=person.as_dict)
77 183
 
78 184
 
79 185
 @app.route("/people/<int:person_id>/add_consumption", methods=["POST"])
80 186
 def add_consumption(person_id: int):
81
-    person = PEOPLE.get(person_id)
82
-
83
-    if not person:
84
-        abort(404)
85
-
86
-    increment = int(request.form.get("amount", 1))
87
-    person["count"] += increment
88
-
89
-    return jsonify(person=person)
187
+    person = Person.query.get_or_404(person_id)
188
+
189
+    consumption = Consumption(person=person, consumption_type_id=1)
190
+    try:
191
+        db.session.add(consumption)
192
+        db.session.commit()
193
+    except SQLAlchemyError:
194
+        return (
195
+            jsonify(
196
+                {"error": "Invalid Consumption parameters.", "person": person.as_dict}
197
+            ),
198
+            400,
199
+        )
200
+
201
+    return jsonify(person=person.as_dict, consumption=consumption.as_dict)

piket_server/database/alembic.ini → piket_server/alembic.ini


piket_server/database/alembic/README → piket_server/alembic/README


+ 5 - 8
piket_server/database/alembic/env.py

@@ -16,20 +16,17 @@ fileConfig(config.config_file_name)
16 16
 # for 'autogenerate' support
17 17
 # from myapp import mymodel
18 18
 # target_metadata = mymodel.Base.metadata
19
-import piket_server.database.schema
20
-target_metadata = piket_server.database.schema.BASE.metadata
19
+import piket_server
20
+target_metadata = piket_server.db.Model.metadata
21 21
 
22 22
 # other values from the config, defined by the needs of env.py,
23 23
 # can be acquired:
24 24
 # my_important_option = config.get_main_option("my_important_option")
25 25
 # ... etc.
26
-data_home = os.environ.get('XDG_DATA_HOME', '~/.local/share')
27
-config_dir = os.path.join(data_home, 'piket_server')
28
-os.makedirs(os.path.expanduser(config_dir), mode=0o744, exist_ok=True)
29
-db_path = os.path.expanduser(os.path.join(config_dir, 'database.sqlite3'))
26
+from piket_server import CONFIG_DIR, DB_URL
27
+os.makedirs(os.path.expanduser(CONFIG_DIR), mode=0o744, exist_ok=True)
30 28
 
31
-config.file_config['alembic']['sqlalchemy.url'] = f'sqlite:///{db_path}'
32
-print(config.get_main_option('sqlalchemy.url'))
29
+config.file_config['alembic']['sqlalchemy.url'] = DB_URL
33 30
 
34 31
 
35 32
 def run_migrations_offline():

piket_server/database/alembic/script.py.mako → piket_server/alembic/script.py.mako


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

@@ -0,0 +1,39 @@
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', sa.Integer,
30
+            sa.ForeignKey('settlements.settlement_id'),
31
+            nullable=True
32
+        )
33
+    )
34
+
35
+
36
+def downgrade():
37
+    op.drop_column('consumptions', 'settlement_id')
38
+
39
+    op.drop_table('settlements')

piket_server/database/alembic/versions/a09086bfe84c_create_persons_table.py → piket_server/alembic/versions/a09086bfe84c_create_persons_table.py


+ 42 - 0
piket_server/alembic/versions/de101d627237_create_consumptions_consumption_types.py

@@ -0,0 +1,42 @@
1
+"""Create consumptions, consumption_types
2
+
3
+Revision ID: de101d627237
4
+Revises: a09086bfe84c
5
+Create Date: 2018-08-21 12:06:29.135898
6
+
7
+"""
8
+from alembic import op
9
+import sqlalchemy as sa
10
+
11
+
12
+# revision identifiers, used by Alembic.
13
+revision = 'de101d627237'
14
+down_revision = 'a09086bfe84c'
15
+branch_labels = None
16
+depends_on = None
17
+
18
+
19
+def upgrade():
20
+    op.create_table(
21
+        'consumption_types',
22
+        sa.Column('consumption_type_id', sa.Integer, nullable=False,
23
+            primary_key=True),
24
+        sa.Column('name', sa.String, nullable=False),
25
+        sa.Column('icon', sa.String, nullable=True),
26
+    )
27
+
28
+    op.create_table(
29
+        'consumptions',
30
+        sa.Column('consumption_id', sa.Integer, primary_key=True),
31
+        sa.Column('person_id', sa.Integer, nullable=True),
32
+        sa.Column('consumption_type_id', sa.Integer, nullable=False),
33
+        sa.Column('created_at', sa.DateTime, nullable=False),
34
+        sa.ForeignKeyConstraint(['consumption_type_id'], ['consumption_types.consumption_type_id'], ),
35
+        sa.ForeignKeyConstraint(['person_id'], ['people.person_id'], ),
36
+        sa.PrimaryKeyConstraint('consumption_id')
37
+    )
38
+
39
+
40
+def downgrade():
41
+    op.drop_table('consumptions')
42
+    op.drop_table('consumption_types')

+ 0 - 20
piket_server/database/schema.py

@@ -1,20 +0,0 @@
1
-"""
2
-Defines the database schema for the backend.
3
-"""
4
-
5
-from sqlalchemy import Column, Integer, String
6
-from sqlalchemy.ext.declarative import declarative_base
7
-
8
-
9
-BASE = declarative_base()
10
-
11
-
12
-class Person(BASE):
13
-    ''' Represents a person to be shown on the lists. '''
14
-    __tablename__ = 'people'
15
-
16
-    person_id = Column(Integer, primary_key=True)
17
-    name = Column(String, nullable=False)
18
-
19
-    def __repr__(self):
20
-        return f"<Person(id={self.person_id}, name={self.name})>"

+ 1 - 0
setup.py

@@ -29,6 +29,7 @@ setup(
29 29
             'server': [
30 30
                 'Flask',
31 31
                 'SQLAlchemy',
32
+                'Flask-SQLAlchemy',
32 33
                 'alembic',
33 34
             ],
34 35
             'client': [