Skip to content

Commit 3ecee96

Browse files
authored
Merge pull request #228 from MerginMaps/develop
Release 2024.3.0
2 parents 606dfe8 + 32b33b6 commit 3ecee96

9 files changed

Lines changed: 94 additions & 12 deletions

File tree

server/mergin/auth/models.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from sqlalchemy import or_
1111

1212
from .. import db
13-
from ..sync.utils import get_user_agent, get_ip
13+
from ..sync.utils import get_user_agent, get_ip, get_device_id
1414

1515

1616
class User(db.Model):
@@ -219,19 +219,22 @@ class LoginHistory(db.Model):
219219
user_agent = db.Column(db.String, index=True)
220220
ip_address = db.Column(db.String, index=True)
221221
ip_geolocation_country = db.Column(db.String, index=True)
222+
device_id = db.Column(db.String, index=True, nullable=True)
222223

223-
def __init__(self, user_id: int, ua: str, ip: str):
224+
def __init__(self, user_id: int, ua: str, ip: str, device_id: Optional[str] = None):
224225
self.user_id = user_id
225226
self.user_agent = ua
226227
self.ip_address = ip
228+
self.device_id = device_id
227229

228230
@staticmethod
229231
def add_record(user_id: int, req: request) -> None:
230232
ua = get_user_agent(req)
231233
ip = get_ip(req)
234+
device_id = get_device_id(req)
232235
# ignore login attempts coming from urllib - related to db sync tool
233236
if "DB-sync" in ua:
234237
return
235-
lh = LoginHistory(user_id, ua, ip)
238+
lh = LoginHistory(user_id, ua, ip, device_id)
236239
db.session.add(lh)
237240
db.session.commit()

server/mergin/sync/models.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,16 +480,20 @@ class ProjectVersion(db.Model):
480480
"Project",
481481
uselist=False,
482482
)
483+
device_id = db.Column(db.String, index=True, nullable=True)
483484
__table_args__ = (db.UniqueConstraint("project_id", "name"),)
484485

485-
def __init__(self, project, name, author, changes, files, ip, user_agent=None):
486+
def __init__(
487+
self, project, name, author, changes, files, ip, user_agent=None, device_id=None
488+
):
486489
self.project_id = project.id
487490
self.name = name
488491
self.author = author
489492
self.changes = changes
490493
self.files = files
491494
self.user_agent = user_agent
492495
self.ip_address = ip
496+
self.device_id = device_id
493497
self.project_size = sum(f["size"] for f in self.files) if self.files else 0
494498
# clean up changes metadata from chunks upload info
495499
for change in self.changes["updated"] + self.changes["added"]:

server/mergin/sync/public_api_controller.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
get_path_from_files,
7171
get_project_path,
7272
clean_upload,
73+
get_device_id,
7374
)
7475
from .errors import StorageLimitHit
7576
from ..utils import format_time_delta
@@ -155,6 +156,7 @@ def add_project(namespace): # noqa: E501
155156

156157
if request.is_json:
157158
ua = get_user_agent(request)
159+
device_id = get_device_id(request)
158160
workspace = current_app.ws_handler.get_by_name(namespace)
159161
if not workspace:
160162
# return special message if former 'user workspace' was used
@@ -215,11 +217,12 @@ def add_project(namespace): # noqa: E501
215217
p.files,
216218
get_ip(request),
217219
user_agent,
220+
device_id,
218221
)
219222
else:
220223
changes = {"added": [], "updated": [], "removed": []}
221224
version = ProjectVersion(
222-
p, "v0", current_user.username, changes, [], ip, ua
225+
p, "v0", current_user.username, changes, [], ip, ua, device_id
223226
)
224227
p.latest_version = "v0"
225228
try:
@@ -857,6 +860,7 @@ def project_push(namespace, project_name):
857860
flag_modified(project, "files")
858861
project.disk_usage = sum(file["size"] for file in project.files)
859862
user_agent = get_user_agent(request)
863+
device_id = get_device_id(request)
860864
pv = ProjectVersion(
861865
project,
862866
next_version,
@@ -865,6 +869,7 @@ def project_push(namespace, project_name):
865869
project.files,
866870
get_ip(request),
867871
user_agent,
872+
device_id,
868873
)
869874
project.latest_version = next_version
870875
db.session.add(pv)
@@ -1027,6 +1032,7 @@ def push_finish(transaction_id):
10271032
flag_modified(project, "files")
10281033
project.disk_usage = sum(file["size"] for file in project.files)
10291034
user_agent = get_user_agent(request)
1035+
device_id = get_device_id(request)
10301036
pv = ProjectVersion(
10311037
project,
10321038
next_version,
@@ -1035,6 +1041,7 @@ def push_finish(transaction_id):
10351041
project.files,
10361042
get_ip(request),
10371043
user_agent,
1044+
device_id,
10381045
)
10391046
project.latest_version = next_version
10401047
db.session.add(pv)
@@ -1156,10 +1163,19 @@ def clone_project(namespace, project_name): # noqa: E501
11561163

11571164
version = "v1" if p.files else "v0"
11581165
changes = {"added": p.files, "updated": [], "removed": []}
1166+
# TODO: add user_agent and device_id handling to class
11591167
user_agent = get_user_agent(request)
1168+
device_id = get_device_id(request)
11601169
p.latest_version = version
11611170
version = ProjectVersion(
1162-
p, version, current_user.username, changes, p.files, get_ip(request), user_agent
1171+
p,
1172+
version,
1173+
current_user.username,
1174+
changes,
1175+
p.files,
1176+
get_ip(request),
1177+
user_agent,
1178+
device_id,
11631179
)
11641180
db.session.add(p)
11651181
db.session.add(pa)

server/mergin/sync/schemas.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ def get_diff_summary(self, obj):
252252

253253
class Meta:
254254
model = ProjectVersion
255-
exclude = ["id", "ip_address", "ip_geolocation_country", "project"]
255+
exclude = ["id", "ip_address", "ip_geolocation_country", "project", "device_id"]
256256
load_instance = True
257257

258258

@@ -262,7 +262,7 @@ class FullVersionSchema(ma.SQLAlchemyAutoSchema):
262262

263263
class Meta:
264264
model = ProjectVersion
265-
exclude = ["id"]
265+
exclude = ["id", "device_id"]
266266
load_instance = True
267267

268268

server/mergin/sync/utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from shapely import wkb
1515
from shapely.errors import WKBReadingError
1616
from gevent import sleep
17+
from flask import Request
18+
from typing import Optional
1719

1820

1921
def generate_checksum(file, chunk_size=4096):
@@ -377,3 +379,8 @@ def clean_upload(transaction_id):
377379
db.session.commit()
378380
move_to_tmp(upload_dir, transaction_id)
379381
return NoContent, 200
382+
383+
384+
def get_device_id(request: Request) -> Optional[str]:
385+
"""Get device uuid from http header X-Device-Id"""
386+
return request.headers.get("X-Device-Id")

server/mergin/tests/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import os
66
import tempfile
7+
import uuid
78

89
# constants
910
test_workspace_name = "mergin"
@@ -12,6 +13,10 @@
1213
test_project_dir = os.path.join(
1314
os.path.dirname(os.path.realpath(__file__)), "test_projects", test_project
1415
)
15-
json_headers = {"Content-Type": "application/json", "Accept": "application/json"}
16+
json_headers = {
17+
"Content-Type": "application/json",
18+
"Accept": "application/json",
19+
"X-Device-Id": f"{uuid.uuid4()}",
20+
}
1621
DEFAULT_USER = ("mergin", "ilovemergin") # username, password - is a super user
1722
TMP_DIR = tempfile.gettempdir()

server/mergin/tests/test_auth.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ def client(app):
3535
({"login": "mergin", "password": "ilovemergin"}, json_headers, 200),
3636
({"login": "mergin ", "password": "ilovemergin"}, json_headers, 200),
3737
({"login": "mergin@mergin.com", "password": "ilovemergin"}, json_headers, 200),
38+
(
39+
{"login": "mergin", "password": "ilovemergin"},
40+
{**json_headers, "X-Device-Id": None},
41+
200,
42+
),
3843
({"login": "mergin", "password": "ilovemergi"}, json_headers, 401),
3944
({"login": "mergin"}, json_headers, 401),
4045
({"login": "mergin", "password": "ilovemergin"}, {}, 415),
@@ -57,6 +62,7 @@ def test_login(client, data, headers, expected):
5762
.first()
5863
)
5964
assert login_history
65+
assert login_history.device_id == str(headers.get("X-Device-Id"))
6066

6167

6268
def test_logout(client):

server/mergin/tests/test_project_controller.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,6 @@ def test_add_project(client, app, data, expected):
400400
)
401401
upload_chunks(upload_dir, upload.changes)
402402
resp = client.post("/v1/project/push/finish/{}".format(upload.id))
403-
print(resp.json)
404403
assert resp.status_code == 200
405404

406405
# add TEMPLATES user and make him creator of test_project (to become template)
@@ -434,6 +433,7 @@ def test_add_project(client, app, data, expected):
434433
)
435434
pv = project.get_latest_version()
436435
assert pv.user_agent is not None
436+
assert pv.device_id == json_headers["X-Device-Id"]
437437
# check if there is no diffs in cloned files
438438
assert not any("diff" in file for file in project.files)
439439
assert not any("diff" in file for file in pv.files)
@@ -459,6 +459,7 @@ def test_versioning(client):
459459
pv = project.get_latest_version()
460460
assert pv.name == "v0"
461461
assert pv.project_size == 0
462+
assert pv.device_id == json_headers["X-Device-Id"]
462463

463464

464465
def test_delete_project(client):
@@ -1367,7 +1368,7 @@ def test_push_finish(client):
13671368
upload, upload_dir = create_transaction("mergin", changes)
13681369
url = "/v1/project/push/finish/{}".format(upload.id)
13691370

1370-
resp = client.post(url)
1371+
resp = client.post(url, headers=json_headers)
13711372
assert resp.status_code == 422
13721373
assert "corrupted_files" in resp.json["detail"].keys()
13731374
assert not os.path.exists(os.path.join(upload_dir, "files", "test.txt"))
@@ -1384,11 +1385,12 @@ def test_push_finish(client):
13841385
with open(os.path.join(upload_dir, "chunks", chunk), "wb") as out_file:
13851386
out_file.write(in_file.read(CHUNK_SIZE))
13861387

1387-
resp2 = client.post(url)
1388+
resp2 = client.post(url, headers={**json_headers, "User-Agent": "Werkzeug"})
13881389
assert resp2.status_code == 200
13891390
assert not os.path.exists(upload_dir)
13901391
version = upload.project.get_latest_version()
13911392
assert version.user_agent
1393+
assert version.device_id == json_headers["X-Device-Id"]
13921394
# chunks is only temporal information, it should not be in db
13931395
assert "chunks" not in version.changes["added"][0].keys()
13941396
assert "chunks" not in version.changes["updated"][0].keys()
@@ -1699,6 +1701,7 @@ def test_clone_project(client, data, username, expected):
16991701
assert not any("diff" in file for file in pv.files)
17001702
assert pv.changes.get("removed") == []
17011703
assert pv.changes.get("updated") == []
1704+
assert pv.device_id == json_headers["X-Device-Id"]
17021705
assert "diff" not in pv.changes.get("added")
17031706
# cleanup
17041707
shutil.rmtree(project.storage.project_dir)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Add device id
2+
3+
Revision ID: 0e3fc92aeaaa
4+
Revises: a5d4defded55
5+
Create Date: 2024-05-03 11:24:46.040646
6+
7+
"""
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "0e3fc92aeaaa"
15+
down_revision = "a5d4defded55"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.add_column("login_history", sa.Column("device_id", sa.String(), nullable=True))
22+
op.create_index(
23+
op.f("ix_login_history_device_id"), "login_history", ["device_id"], unique=False
24+
)
25+
op.add_column("project_version", sa.Column("device_id", sa.String(), nullable=True))
26+
op.create_index(
27+
op.f("ix_project_version_device_id"),
28+
"project_version",
29+
["device_id"],
30+
unique=False,
31+
)
32+
33+
34+
def downgrade():
35+
op.drop_index(op.f("ix_project_version_device_id"), table_name="project_version")
36+
op.drop_column("project_version", "device_id")
37+
op.drop_index(op.f("ix_login_history_device_id"), table_name="login_history")
38+
op.drop_column("login_history", "device_id")

0 commit comments

Comments
 (0)