Skip to content

Commit c0c23f7

Browse files
authored
Merge pull request #598 from MerginMaps/develop
Release 2026.3.1
2 parents d467006 + 7f81a54 commit c0c23f7

File tree

8 files changed

+281
-79
lines changed

8 files changed

+281
-79
lines changed

server/mergin/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ class Configuration(object):
7979
MERGIN_BASE_URL = config("MERGIN_BASE_URL", default="")
8080
# for link to logo in emails
8181
MERGIN_LOGO_URL = config("MERGIN_LOGO_URL", default="")
82+
# for link to logos in EE branding
83+
DASHBOARD_LOGO_URL = config("DASHBOARD_LOGO_URL", default=MERGIN_LOGO_URL)
8284

8385
MERGIN_SUBSCRIPTIONS = config("MERGIN_SUBSCRIPTIONS", default=False, cast=bool)
8486
MERGIN_TESTING = config("MERGIN_TESTING", default=False, cast=bool)

server/mergin/sync/models.py

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,36 @@ def workspace(self):
135135
project_workspace = current_app.ws_handler.get(self.workspace_id)
136136
return project_workspace
137137

138+
def get_latest_files_cache(self) -> List[int]:
139+
"""Get latest file history ids either from cached table or calculate them on the fly"""
140+
if self.latest_project_files.file_history_ids is not None:
141+
return self.latest_project_files.file_history_ids
142+
143+
query = f"""
144+
WITH latest_changes AS (
145+
SELECT
146+
fp.id,
147+
pv.project_id,
148+
max(pv.name) AS version
149+
FROM
150+
project_version pv
151+
LEFT OUTER JOIN file_history fh ON fh.version_id = pv.id
152+
LEFT OUTER JOIN project_file_path fp ON fp.id = fh.file_path_id
153+
WHERE
154+
pv.project_id = :project_id
155+
AND pv.name <= :latest_version
156+
GROUP BY
157+
fp.id, pv.project_id
158+
)
159+
SELECT
160+
fh.id
161+
FROM latest_changes ch
162+
LEFT OUTER JOIN file_history fh ON (fh.file_path_id = ch.id AND fh.project_version_name = ch.version AND fh.change != 'delete')
163+
WHERE fh.id IS NOT NULL;
164+
"""
165+
params = {"project_id": self.id, "latest_version": self.latest_version}
166+
return [row.id for row in db.session.execute(text(query), params).fetchall()]
167+
138168
def cache_latest_files(self) -> None:
139169
"""Get project files from changes (FileHistory) and save them for later use."""
140170
if self.latest_version is None:
@@ -514,7 +544,11 @@ def generate_diff_name(self):
514544

515545

516546
class LatestProjectFiles(db.Model):
517-
"""Store project latest version files history ids"""
547+
"""Store project latest version files history ids.
548+
549+
This is a caching table to store the latest relevant files history ids for further use in
550+
Project.files and ProjectVersion.files. It is updated when ProjectVersion itself is created.
551+
"""
518552

519553
project_id = db.Column(
520554
UUID(as_uuid=True),
@@ -743,22 +777,21 @@ def diffs_chain(
743777
return None, []
744778

745779
diffs = []
746-
cached_items = Checkpoint.get_checkpoints(
747-
basefile.project_version_name, version
748-
)
780+
checkpoints = Checkpoint.get_checkpoints(basefile.project_version_name, version)
749781
expected_diffs = (
750782
FileDiff.query.filter_by(
751783
basefile_id=basefile.id,
752784
)
753785
.filter(
754786
tuple_(FileDiff.rank, FileDiff.version).in_(
755-
[(item.rank, item.end) for item in cached_items]
787+
[(item.rank, item.end) for item in checkpoints]
756788
)
757789
)
758790
.all()
759791
)
760792

761-
for item in cached_items:
793+
for item in checkpoints:
794+
diff_needs_to_be_created = False
762795
diff = next(
763796
(
764797
d
@@ -767,25 +800,38 @@ def diffs_chain(
767800
),
768801
None,
769802
)
770-
if diff and os.path.exists(diff.abs_path):
771-
diffs.append(diff)
772-
elif item.rank > 0:
773-
# fallback if checkpoint does not exist: replace merged diff with individual diffs
774-
individual_diffs = (
775-
FileDiff.query.filter_by(
776-
basefile_id=basefile.id,
777-
rank=0,
803+
if diff:
804+
if os.path.exists(diff.abs_path):
805+
diffs.append(diff)
806+
else:
807+
diff_needs_to_be_created = True
808+
else:
809+
# we do not have record in DB, create a checkpoint if it makes sense
810+
if item.rank > 0 and FileDiff.can_create_checkpoint(file_id, item):
811+
diff = FileDiff(
812+
basefile=basefile,
813+
version=item.end,
814+
rank=item.rank,
815+
path=basefile.file.generate_diff_name(),
816+
size=None,
817+
checksum=None,
778818
)
779-
.filter(
780-
FileDiff.version >= item.start, FileDiff.version <= item.end
819+
db.session.add(diff)
820+
db.session.commit()
821+
diff_needs_to_be_created = True
822+
else:
823+
# we asked for checkpoint where there was no change
824+
continue
825+
826+
if diff_needs_to_be_created:
827+
diff_created = diff.construct_checkpoint()
828+
if diff_created:
829+
diffs.append(diff)
830+
else:
831+
logging.error(
832+
f"Failed to create a diff for file {basefile.file.path} at version {basefile.project_version_name} of rank {item.rank}."
781833
)
782-
.order_by(FileDiff.version)
783-
.all()
784-
)
785-
diffs.extend(individual_diffs)
786-
else:
787-
# we asked for individual diff but there is no such diff as there was not change at that version
788-
continue
834+
return None, []
789835

790836
return basefile, diffs
791837

@@ -924,9 +970,10 @@ def construct_checkpoint(self) -> bool:
924970
return True
925971

926972
if self.rank == 0:
927-
raise ValueError(
973+
logging.error(
928974
"Checkpoint of rank 0 should be created by user upload, cannot be constructed"
929975
)
976+
return False
930977

931978
# merged diffs can only be created for certain versions
932979
if self.version % LOG_BASE:
@@ -1434,7 +1481,7 @@ def __init__(
14341481
latest_files_map = {
14351482
fh.path: fh.id
14361483
for fh in FileHistory.query.filter(
1437-
FileHistory.id.in_(self.project.latest_project_files.file_history_ids)
1484+
FileHistory.id.in_(self.project.get_latest_files_cache())
14381485
).all()
14391486
}
14401487

@@ -1565,6 +1612,10 @@ def _files_from_end(self):
15651612
files that were delete after the version (and thus not necessarily present now). From these candidates
15661613
get the latest file change before or at the specific version. If that change was not 'delete', file is present.
15671614
"""
1615+
# if we do not have cached file history ids use different strategy where it is not necessary
1616+
if self.project.latest_project_files.file_history_ids is None:
1617+
return self._files_from_start()
1618+
15681619
query = f"""
15691620
WITH files_changes_before_version AS (
15701621
WITH files_candidates AS (

server/mergin/sync/public_api_controller.py

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import os
99
import logging
1010
from dataclasses import asdict
11+
from enum import Enum
1112
from typing import Dict
1213
from datetime import datetime
1314

@@ -80,6 +81,7 @@
8081
generate_location,
8182
is_valid_uuid,
8283
get_device_id,
84+
is_versioned_file,
8385
prepare_download_response,
8486
get_device_id,
8587
wkb2wkt,
@@ -287,6 +289,12 @@ def delete_project(namespace, project_name): # noqa: E501
287289
return NoContent, 200
288290

289291

292+
class DowloadFileAction(Enum):
293+
FULL = "full"
294+
FULL_GPKG = "full_gpkg"
295+
DIFF = "diff"
296+
297+
290298
def download_project_file(
291299
project_name, namespace, file, version=None, diff=None
292300
): # noqa: E501
@@ -307,10 +315,20 @@ def download_project_file(
307315
308316
:rtype: file
309317
"""
310-
project = require_project(namespace, project_name, ProjectPermissions.Read)
311-
if diff and not version:
318+
if not is_versioned_file(file):
319+
action = DowloadFileAction.FULL
320+
elif diff:
321+
action = DowloadFileAction.DIFF
322+
else:
323+
action = DowloadFileAction.FULL_GPKG
324+
325+
if action is DowloadFileAction.DIFF and not version:
312326
abort(400, f"Changeset must be requested for particular file version")
313327

328+
if action is DowloadFileAction.FULL and diff is True:
329+
abort(404, f"No diff in particular file {file})")
330+
331+
project = require_project(namespace, project_name, ProjectPermissions.Read)
314332
lookup_version = (
315333
ProjectVersion.from_v_name(version) if version else project.latest_version
316334
)
@@ -329,24 +347,30 @@ def download_project_file(
329347
if not fh or fh.change == PushChangeType.DELETE.value:
330348
abort(404, f"File {file} not found")
331349

332-
if diff and version:
333-
# get specific version of geodiff file modified in requested version
334-
if not fh.diff:
335-
abort(404, f"No diff in particular file {file} version")
336-
file_path = fh.diff_file.location
337-
else:
338-
file_path = fh.location
339-
340-
if version and not diff:
341-
project.storage.restore_versioned_file(
342-
file, ProjectVersion.from_v_name(version)
343-
)
350+
# user asked for diff, but there is no diff at that version
351+
if action is DowloadFileAction.DIFF and not fh.diff:
352+
abort(404, f"No diff in particular file {file} version")
344353

354+
file_path = (
355+
fh.diff_file.location if action is DowloadFileAction.DIFF else fh.location
356+
)
345357
abs_path = os.path.join(project.storage.project_dir, file_path)
346-
# check file exists (e.g. there might have been issue with restore)
358+
347359
if not os.path.exists(abs_path):
348-
logging.error(f"Missing file {namespace}/{project_name}/{file_path}")
349-
abort(404)
360+
if action is DowloadFileAction.FULL_GPKG:
361+
project.storage.restore_versioned_file(
362+
file, ProjectVersion.from_v_name(version)
363+
)
364+
365+
# check again after restore
366+
if not os.path.exists(abs_path):
367+
logging.error(
368+
f"Failed to restore {namespace}/{project_name}/{file_path}"
369+
)
370+
abort(404)
371+
else:
372+
logging.error(f"Missing file {namespace}/{project_name}/{file_path}")
373+
abort(404)
350374

351375
response = prepare_download_response(project.storage.project_dir, file_path)
352376
return response

server/mergin/tests/test_file_restore.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88

99
from ..app import db
1010
from ..auth.models import User
11-
from ..sync.models import ProjectVersion, Project, GeodiffActionHistory
11+
from ..sync.models import (
12+
FileDiff,
13+
ProjectFilePath,
14+
ProjectVersion,
15+
Project,
16+
GeodiffActionHistory,
17+
)
1218
from . import test_project_dir, TMP_DIR
1319
from .utils import (
1420
create_project,
@@ -163,6 +169,19 @@ def test_version_file_restore(diff_project):
163169
diff_project.storage.restore_versioned_file("base.gpkg", 7)
164170
assert os.path.exists(test_file)
165171
assert gpkgs_are_equal(test_file, test_file + "_backup")
172+
# no merged diffs needed
173+
file_path_id = (
174+
ProjectFilePath.query.filter_by(project_id=diff_project.id, path="base.gpkg")
175+
.first()
176+
.id
177+
)
178+
assert (
179+
FileDiff.query.filter_by(file_path_id=file_path_id)
180+
.filter(FileDiff.rank > 0)
181+
.count()
182+
== 0
183+
)
184+
166185
# check we track performance of reconstruction
167186
gh = GeodiffActionHistory.query.filter_by(
168187
project_id=diff_project.id, target_version="v7"
@@ -198,3 +217,44 @@ def test_version_file_restore(diff_project):
198217
diff_project.storage.restore_versioned_file("test.txt", 1)
199218
assert not os.path.exists(test_file)
200219
assert not os.path.exists(diff_project.storage.geodiff_working_dir)
220+
221+
# let's add some dummy changes to test.gpkg so we can restore full gpkg using checkpoints created on demand
222+
file_path_id = (
223+
ProjectFilePath.query.filter_by(project_id=diff_project.id, path="test.gpkg")
224+
.first()
225+
.id
226+
)
227+
base_gpkg = os.path.join(diff_project.storage.project_dir, "test.gpkg")
228+
shutil.copy(
229+
os.path.join(diff_project.storage.project_dir, "v9", "test.gpkg"), base_gpkg
230+
)
231+
for i in range(23):
232+
sql = f"UPDATE simple SET rating={i}"
233+
execute_query(base_gpkg, sql)
234+
pv = push_change(
235+
diff_project, "updated", "test.gpkg", diff_project.storage.project_dir
236+
)
237+
assert diff_project.latest_version == pv.name == (11 + i)
238+
file_diff = FileDiff.query.filter_by(
239+
file_path_id=file_path_id, version=pv.name, rank=0
240+
).first()
241+
assert file_diff and os.path.exists(file_diff.abs_path)
242+
243+
assert (
244+
FileDiff.query.filter_by(file_path_id=file_path_id)
245+
.filter(FileDiff.rank > 0)
246+
.count()
247+
== 0
248+
)
249+
250+
test_file = os.path.join(diff_project.storage.project_dir, "v30", "test.gpkg")
251+
os.rename(test_file, test_file + "_backup")
252+
diff_project.storage.restore_versioned_file("test.gpkg", 30)
253+
assert os.path.exists(test_file)
254+
assert gpkgs_are_equal(test_file, test_file + "_backup")
255+
assert (
256+
FileDiff.query.filter_by(file_path_id=file_path_id)
257+
.filter(FileDiff.rank > 0)
258+
.count()
259+
> 0
260+
)

server/mergin/tests/test_project_controller.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,14 @@ def test_download_diff_file(client, diff_project):
880880

881881
# download full version after file was removed
882882
os.remove(os.path.join(diff_project.storage.project_dir, file_change.location))
883+
file_path_id = (
884+
ProjectFilePath.query.filter_by(project_id=diff_project.id, path=test_file)
885+
.first()
886+
.id
887+
)
888+
basefile, diffs = FileHistory.diffs_chain(file_path_id, 4)
889+
# we construct full gpkg from basefile and single diff v4
890+
assert basefile is not None and len(diffs) == 1
883891
resp = client.get(
884892
"/v1/project/raw/{}/{}?file={}&version=v4".format(
885893
test_workspace_name, test_project, test_file
@@ -1902,11 +1910,17 @@ def test_file_diffs_chain(diff_project):
19021910
assert len(diffs) == 1
19031911
assert diffs[0].version == 6
19041912

1905-
# diff was used in v7, nothing happened in v8 (=v7)
1906-
basefile, diffs = FileHistory.diffs_chain(file_id, 8)
1913+
# diff was used in v7
1914+
basefile, diffs = FileHistory.diffs_chain(file_id, 7)
19071915
assert basefile.version.name == 5
19081916
assert len(diffs) == 2
19091917

1918+
# nothing happened in v8 (=v7) but we have now merged diff in chain v5-v8
1919+
basefile, diffs = FileHistory.diffs_chain(file_id, 8)
1920+
assert basefile.version.name == 5
1921+
assert len(diffs) == 1
1922+
assert diffs[0].rank == 1 and diffs[0].version == 8
1923+
19101924
# file was removed in v9
19111925
basefile, diffs = FileHistory.diffs_chain(file_id, 9)
19121926
assert not basefile

0 commit comments

Comments
 (0)