From 7224af052614608877799fb7145ab6f7ba144dad Mon Sep 17 00:00:00 2001 From: MarcelGeo Date: Wed, 16 Sep 2026 10:53:34 +0200 Subject: [PATCH 1/7] Install telemetry as server is failing when not installed --- development.md | 1 + server/.pre-commit-config.yaml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/development.md b/development.md index 72c79ec4..87a42a68 100644 --- a/development.md +++ b/development.md @@ -21,6 +21,7 @@ $ cd server # Install dependencies with pipenv # Note: You can append --three flag in older versions of pipenv (< 3.16.8 2023-02-04) $ pipenv install --dev +$ pipenv install --categories="telemetry $ pipenv run pre-commit install $ pipenv run pre-commit run --all-files $ export FLASK_APP=application; export COLLECT_STATISTICS=0 diff --git a/server/.pre-commit-config.yaml b/server/.pre-commit-config.yaml index 64802134..e843287d 100644 --- a/server/.pre-commit-config.yaml +++ b/server/.pre-commit-config.yaml @@ -4,4 +4,4 @@ repos: rev: 25.1.0 hooks: - id: black - language_version: python3.10 \ No newline at end of file + language_version: python3.12 From 839c4962315980cbd2490cc85c74b38526d2256b Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 16 Sep 2026 17:43:03 +0300 Subject: [PATCH 2/7] Add CITATION.cff for standardised citation --- CITATION.cff | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 CITATION.cff diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 00000000..b51618fe --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,9 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +title: "Mergin Maps Server" +type: software +authors: + - name: "Lutra Consulting Ltd." +url: "https://merginmaps.com" +repository-code: "https://github.com/MerginMaps/server" +license: AGPL-3.0-only From 27b1f28ad8db6f473a987e4aaca24d9b5fbff6fe Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Thu, 17 Sep 2026 15:41:45 +0200 Subject: [PATCH 3/7] Add option to ignore some user agents in login history --- server/mergin/auth/config.py | 8 +++++++- server/mergin/auth/models.py | 14 ++++++++++++++ server/mergin/tests/test_auth.py | 26 ++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/server/mergin/auth/config.py b/server/mergin/auth/config.py index 24cbc50f..9b9c5c50 100644 --- a/server/mergin/auth/config.py +++ b/server/mergin/auth/config.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -from decouple import config +from decouple import config, Csv class Configuration(object): @@ -19,3 +19,9 @@ class Configuration(object): LOCKOUT_POLICY = config("LOCKOUT_POLICY", default="5:300,10:3600") # trailing window in seconds over which failed login attempts are counted LOCKOUT_WINDOW = config("LOCKOUT_WINDOW", default=3600, cast=int) + # comma-separated substrings (case-insensitive) of user agents excluded from login history logging + LOGIN_HISTORY_EXCLUDED_USER_AGENTS = config( + "LOGIN_HISTORY_EXCLUDED_USER_AGENTS", + default="media-sync,work-packages,DB-sync", + cast=Csv(), + ) diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index f80ccbe5..daee3868 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -111,6 +111,9 @@ def record_failed_login(self) -> Optional[int]: counting only failed attempts within the trailing LOCKOUT_WINDOW and since the last successful login (whichever bound is more recent). + Note: failed attempts from a user agent excluded from login_history logging + are not recorded at all, so they do not count toward lockout either. + Returns the lockout duration in seconds if a new lock was just applied, else None. """ LoginHistory.add_record(self.id, request, successful=False) @@ -369,9 +372,20 @@ def __init__( self.successful = successful self.timestamp = datetime.datetime.now(tz=datetime.timezone.utc) + @staticmethod + def is_excluded_user_agent(ua: Optional[str]) -> bool: + """Return True if the user agent matches one of the configured exclusions + and should not be logged in the login history.""" + if not ua: + return False + excluded = current_app.config.get("LOGIN_HISTORY_EXCLUDED_USER_AGENTS", []) + return any(pattern.lower() in ua.lower() for pattern in excluded) + @staticmethod def add_record(user_id: int, req: request, successful: bool = True) -> None: ua = get_user_agent(req) + if LoginHistory.is_excluded_user_agent(ua): + return ip = get_ip(req) device_id = get_device_id(req) lh = LoginHistory(user_id, ua, ip, device_id, successful=successful) diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index 69a2e4fd..6bb3d934 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -835,11 +835,33 @@ def test_api_login(client, data, headers, expected): assert user.last_signed_in == login_history.timestamp +@pytest.mark.parametrize( + "ua", ["DB-sync/0.1", "media-sync/1.0", "work-packages-agent/2.0", "db-sync/0.1"] +) +def test_api_login_excluded_user_agent(client, ua): + """Logins from user agents on the LOGIN_HISTORY_EXCLUDED_USER_AGENTS list (matched + case-insensitively as a substring) are not recorded in LoginHistory""" + with patch("mergin.auth.models.get_user_agent") as mock: + mock.return_value = ua + user_before = User.query.filter_by(username=DEFAULT_USER[0]).first() + last_signed_in_before = user_before.last_signed_in + resp = client.post( + "/v1/auth/login", + data=json.dumps({"login": "mergin", "password": "ilovemergin"}), + headers=json_headers, + ) + assert resp.status_code == 200 + user = User.query.filter_by(username=DEFAULT_USER[0]).first() + login_history = LoginHistory.query.filter_by(user_id=user.id).first() + assert login_history is None + assert user.last_signed_in == last_signed_in_before + + def test_api_login_from_urllib(client): - """DB-sync logins are recorded in LoginHistory just like any other client, + """Non-excluded clients are recorded in LoginHistory just like any other client, to keep a full picture of login activity (including for lockout purposes).""" with patch("mergin.auth.models.get_user_agent") as mock: - mock.return_value = "DB-sync/0.1" + mock.return_value = "python-urllib/3.9" resp = client.post( "/v1/auth/login", data=json.dumps({"login": "mergin", "password": "ilovemergin"}), From 5c1844cc8734bd497a4eb7dc041ae7a95c9cf6f8 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Fri, 18 Sep 2026 08:34:18 +0200 Subject: [PATCH 4/7] Limit server-side diff construction for forced update Try to create a diff from full gpkg file on client's behalf only for certain file sizes. For large files do force update automatically. --- deployment/community/.env.template | 2 + deployment/enterprise/.env.template | 2 + server/mergin/sync/config.py | 4 + server/mergin/sync/models.py | 12 ++- .../mergin/tests/test_project_controller.py | 96 +++++++++++++++++++ 5 files changed, 115 insertions(+), 1 deletion(-) diff --git a/deployment/community/.env.template b/deployment/community/.env.template index cfb526ac..e786e52f 100644 --- a/deployment/community/.env.template +++ b/deployment/community/.env.template @@ -121,6 +121,8 @@ LOCAL_PROJECTS=/data #MAX_CHUNK_SIZE=10 * 1024 * 1024 # 10485760 in bytes +#MAX_DIFFABLE_FORCE_UPDATE_SIZE=512 * 1024 * 1024 # 536870912 in bytes - max size of an uploaded full .gpkg for which server tries to construct a diff on force update, above this it falls back to a plain full-file force update + # data download #MAX_DOWNLOAD_ARCHIVE_SIZE=1024 * 1024 * 1024 * 10 # max total files size in bytes for archive download - 10 GB diff --git a/deployment/enterprise/.env.template b/deployment/enterprise/.env.template index ebdb8716..a11aa575 100644 --- a/deployment/enterprise/.env.template +++ b/deployment/enterprise/.env.template @@ -118,6 +118,8 @@ LOCAL_PROJECTS=/data #MAX_CHUNK_SIZE=10 * 1024 * 1024 # 10485760 in bytes +#MAX_DIFFABLE_FORCE_UPDATE_SIZE=512 * 1024 * 1024 # 536870912 in bytes - max size of an uploaded full .gpkg for which server tries to construct a diff on force update, above this it falls back to a plain full-file force update + # data download #MAX_DOWNLOAD_ARCHIVE_SIZE=1024 * 1024 * 1024 * 10 # max total files size in bytes for archive download diff --git a/server/mergin/sync/config.py b/server/mergin/sync/config.py index a5c8167a..9b5648fa 100644 --- a/server/mergin/sync/config.py +++ b/server/mergin/sync/config.py @@ -88,3 +88,7 @@ class Configuration(object): ) # max batch size for fetch projects in batch endpoint MAX_BATCH_SIZE = config("MAX_BATCH_SIZE", default=100, cast=int) + # max size (in bytes) of an uploaded full .gpkg file for which server will try to construct a diff + MAX_DIFFABLE_FORCE_UPDATE_SIZE = config( + "MAX_DIFFABLE_FORCE_UPDATE_SIZE", default=512 * 1024 * 1024, cast=int + ) diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py index 3817acd6..d8bb55fc 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -2163,7 +2163,11 @@ def process_chunks( errors[f.path] = ( f"{FileSyncErrorType.SYNC_ERROR.value}: project {self.project.workspace.name}/{self.project.name}, {result.value}" ) - else: + elif ( + expected_size + <= current_app.config["MAX_DIFFABLE_FORCE_UPDATE_SIZE"] + ): + # gpkg small enough - try to construct diff server-side diff_name = mergin_secure_filename( f.path + "-diff-" + str(uuid.uuid4()) ) @@ -2188,6 +2192,12 @@ def process_chunks( logging.warning( f"Geodiff: create changeset error {result.value}" ) + else: + # gpkg too large - skip diff construction and keep it as a plain force update + logging.info( + f"Skipping diff construction for {f.path} in project {project_path}: " + f"file size {expected_size} exceeds MAX_DIFFABLE_FORCE_UPDATE_SIZE" + ) return file_changes, errors diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 1a0c76aa..fb8657f5 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -1755,6 +1755,102 @@ def copy_file_failing_for_geodiff(src, dest): assert "diff" not in updated_file +def test_push_force_update_size_limit(client): + """Server should only try to construct a diff for a force-updated (full gpkg, + no diff sent) upload when its size is within MAX_DIFFABLE_FORCE_UPDATE_SIZE; + above the limit it should skip diff construction and keep it as a plain + force update.""" + working_dir = os.path.join(TMP_DIR, "test_push_force_update_size_limit") + # cleanup + if os.path.exists(working_dir): + shutil.rmtree(working_dir) + + shutil.copytree(test_project_dir, working_dir) + # mimic base.gpkg was updated with inserted_1_A.gpkg (but no diff is created) + shutil.copy( + os.path.join(working_dir, "inserted_1_A.gpkg"), + os.path.join(working_dir, "base.gpkg"), + ) + base_gpkg_size = os.path.getsize(os.path.join(working_dir, "base.gpkg")) + changes = { + "added": [], + "removed": [], + "updated": [ + file_info(working_dir, "base.gpkg", chunk_size=CHUNK_SIZE), + file_info(working_dir, "test.txt", chunk_size=CHUNK_SIZE), + ], + } + + # below limit -> diff is still constructed server-side + upload, upload_dir = create_transaction("mergin", changes) + upload_chunks(upload_dir, upload.changes, src_dir=working_dir) + with patch.dict( + client.application.config, + {"MAX_DIFFABLE_FORCE_UPDATE_SIZE": base_gpkg_size + 1}, + ): + resp = client.post(f"/v1/project/push/finish/{upload.transaction_id}") + assert resp.status_code == 200 + latest_version = upload.project.get_latest_version() + assert ( + latest_version.changes.filter( + FileHistory.change == PushChangeType.UPDATE.value + ).count() + == 1 + ) + assert ( + latest_version.changes.filter( + FileHistory.change == PushChangeType.UPDATE_DIFF.value + ).count() + == 1 + ) + file_meta = latest_version.changes.filter( + FileHistory.change == PushChangeType.UPDATE_DIFF.value + ).first() + assert file_meta.diff_file is not None + assert os.path.exists( + os.path.join(upload.project.storage.project_dir, file_meta.diff_file.location) + ) + + # above limit -> diff construction is skipped, plain force update + working_file = os.path.join(working_dir, "base.gpkg") + sql = "INSERT INTO simple (geometry, name) VALUES (GeomFromText('POINT(24.5, 38.2)', 4326), 'insert_test')" + execute_query(working_file, sql) + updated_gpkg_size = os.path.getsize(working_file) + changes["updated"] = [ + file_info(working_dir, "base.gpkg", chunk_size=CHUNK_SIZE), + file_info(working_dir, "test.txt", chunk_size=CHUNK_SIZE), + ] + upload, upload_dir = create_transaction("mergin", changes, version=2) + upload_chunks(upload_dir, upload.changes, src_dir=working_dir) + with patch.dict( + client.application.config, + {"MAX_DIFFABLE_FORCE_UPDATE_SIZE": updated_gpkg_size - 1}, + ): + resp = client.post(f"/v1/project/push/finish/{upload.transaction_id}") + assert resp.status_code == 200 + latest_version = upload.project.get_latest_version() + assert ( + latest_version.changes.filter( + FileHistory.change == PushChangeType.UPDATE.value + ).count() + == 2 + ) + assert not latest_version.changes.filter( + FileHistory.change == PushChangeType.UPDATE_DIFF.value + ).count() + assert all( + file_meta.diff_file is None + for file_meta in latest_version.changes.filter( + FileHistory.change == PushChangeType.UPDATE.value + ).all() + ) + version_files = os.listdir( + os.path.join(upload.project.storage.project_dir, f"v{latest_version.name}") + ) + diff_files = [f for f in version_files if re.findall("-diff-", f)] + assert not diff_files + + clone_project_data = [ ({"project": " clone "}, "mergin", 200), # clone own project ( From 8bc1a427330666799055ad9627711727b461cc0e Mon Sep 17 00:00:00 2001 From: MarcelGeo Date: Tue, 22 Sep 2026 14:50:24 +0200 Subject: [PATCH 5/7] Move project version exists after static checks of project --- .../mergin/sync/public_api_v2_controller.py | 8 ++--- server/mergin/tests/test_public_api_v2.py | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index e7806865..823d6e5f 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -234,10 +234,6 @@ def create_project_version(id): v_next_version = ProjectVersion.to_v_name(next_version) version_dir = os.path.join(project.storage.project_dir, v_next_version) - pv = project.get_latest_version() - if pv and pv.name != version: - return ProjectVersionExists(version, pv.name).response(409) - try: ChangesSchema().validate(changes) upload_changes = ChangesSchema().dump(changes) @@ -283,6 +279,10 @@ def create_project_version(id): if requested_storage > project.workspace.storage: return StorageLimitHit(current_usage, project.workspace.storage).response(422) + pv = project.get_latest_version() + if pv and pv.name != version: + return ProjectVersionExists(version, pv.name).response(409) + # we have done all checks but this request is just a dry-run if request.json.get("check_only", False): return NoContent, 204 diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 56caa7ff..976ffe86 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -1079,6 +1079,41 @@ def test_create_version_failures(client): assert response.status_code == 409 +def test_create_version_permanent_error_takes_priority(client): + """Permanent errors (e.g. storage limit) must be reported before a + version conflict, otherwise clients would rebase/retry an upload that + is bound to fail anyway.""" + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + + data = { + "version": "v0", + "changes": _get_changes_without_added(test_project_dir), + "check_only": True, + } + with patch.object( + Configuration, + "GLOBAL_STORAGE", + 0, + ): + response = client.post(f"v2/projects/{project.id}/versions", json=data) + assert response.status_code == 422 + assert response.json["code"] == StorageLimitHit.code + + # same must hold for the real (non check_only) upload + data["check_only"] = False + with patch.object( + Configuration, + "GLOBAL_STORAGE", + 0, + ): + response = client.post(f"v2/projects/{project.id}/versions", json=data) + assert response.status_code == 422 + assert response.json["code"] == StorageLimitHit.code + assert project.latest_version == 1 + + def test_upload_chunk(client): """Test pushing a chunk to a project""" project = Project.query.filter_by( From 536b9f8111f4d816950d2890ce5dcb5bb4f6d4e1 Mon Sep 17 00:00:00 2001 From: MarcelGeo Date: Tue, 22 Sep 2026 15:15:20 +0200 Subject: [PATCH 6/7] Diff files path validation --- server/.pre-commit-config.yaml | 2 +- server/mergin/sync/files.py | 6 +++ .../mergin/tests/test_project_controller.py | 37 +++++++++++++++++++ server/mergin/tests/test_public_api_v2.py | 32 +++++++++++++++- 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/server/.pre-commit-config.yaml b/server/.pre-commit-config.yaml index 64802134..e843287d 100644 --- a/server/.pre-commit-config.yaml +++ b/server/.pre-commit-config.yaml @@ -4,4 +4,4 @@ repos: rev: 25.1.0 hooks: - id: black - language_version: python3.10 \ No newline at end of file + language_version: python3.12 diff --git a/server/mergin/sync/files.py b/server/mergin/sync/files.py index d22358d5..880d744f 100644 --- a/server/mergin/sync/files.py +++ b/server/mergin/sync/files.py @@ -227,6 +227,12 @@ def validate(self, data, **kwargs): f"Unsupported file type detected: '{file_path}'. " f"Please remove the file or try compressing it into a ZIP file before uploading.", ) + + diff = file.get("diff") + if diff and not is_valid_path(diff["path"]): + raise ValidationError( + f"Unsupported file name detected: '{diff['path']}'. Please remove the invalid characters." + ) # new checks must restrict only new files not to block existing projects for file in data["added"]: file_path = file["path"] diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 1a0c76aa..88095409 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -2629,6 +2629,43 @@ def test_filepath_manipulation(client): ) +def test_diff_filepath_manipulation(client): + """Test path validation of the nested diff file during file upload""" + push_start_url = url_for( + f"/v1.mergin_sync_public_api_controller_project_push", + namespace=test_workspace_name, + project_name=test_project, + ) + filename = "data.gpkg" + with open(os.path.join(TMP_DIR, filename), "w") as f: + f.write("Hello, Mergin!") + changes = { + "added": [], + "updated": [file_info(TMP_DIR, filename, chunk_size=CHUNK_SIZE)], + "removed": [], + } + # Manipulate the diff's path by prepending ../../ + manipulated_diff_path = "../../" + filename + changes["updated"][0]["diff"] = { + "path": manipulated_diff_path, + "checksum": changes["updated"][0]["checksum"], + "size": changes["updated"][0]["size"], + } + # Block upload in push_start because of the invalid diff path + resp = client.post( + push_start_url, + data=json.dumps( + {"version": "v1", "changes": changes}, cls=DateTimeEncoder + ).encode("utf-8"), + headers=json_headers, + ) + assert resp.status_code == 400 + assert ( + resp.json["detail"] + == f"Unsupported file name detected: '{manipulated_diff_path}'. Please remove the invalid characters." + ) + + def test_supported_file_upload(client): """Test rejecting unsupported file based on extension and its mime type""" push_start_url = url_for( diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 56caa7ff..90a9bec9 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -857,6 +857,23 @@ def test_get_project(client): assert response.status_code == 400 +def _get_changes_with_diff_updated(project_dir): + changes = _get_changes_with_diff(project_dir) + # path traversal in the diff file's path must be rejected + changes["updated"][2]["diff"]["path"] = ( + "../../" + changes["updated"][2]["diff"]["path"] + ) + return changes + + +# Simulation of worst case if validation works +def _get_changes_with_diff_added(project_dir): + changes = _get_changes_with_diff_updated(project_dir) + changes["added"] = changes["updated"] + changes["updated"] = [] + return changes + + push_data = [ # success ( @@ -897,6 +914,14 @@ def test_get_project(client): 422, UploadError.code, ), + ( + { + "version": "v1", + "changes": _get_changes_with_diff_updated(test_project_dir), + }, + 422, + UploadError.code, + ), # contains already uploaded file ( {"version": "v1", "changes": _get_changes(test_project_dir)}, @@ -942,7 +967,12 @@ def test_get_project(client): 422, UploadError.code, ), - # inconsistent changes, a file which does not exist cannot be deleted + # inconsistent changes, a file can not be uploaded with the added diff + ( + {"version": "v1", "changes": _get_changes_with_diff_added(test_project_dir)}, + 422, + UploadError.code, + ), ( { "version": "v1", From 68d8fd91a284a4c7263d75ac59b1d1c6bb8be19a Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Tue, 22 Sep 2026 15:32:28 +0200 Subject: [PATCH 7/7] Add more logging for zip archive with gpkg restores --- server/mergin/sync/storages/disk.py | 8 +++++++- server/mergin/sync/tasks.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/server/mergin/sync/storages/disk.py b/server/mergin/sync/storages/disk.py index 80715ed6..223b501c 100644 --- a/server/mergin/sync/storages/disk.py +++ b/server/mergin/sync/storages/disk.py @@ -407,13 +407,19 @@ def restore_versioned_file(self, file: str, version: int): if not (base_meta and diffs): return + diffs_size = sum(os.path.getsize(d.abs_path) for d in diffs) + logging.info( + f"restore_versioned_file: file={file} target_version={version} " + f"basefile={base_meta.abs_path} basefile_size={base_meta.size} " + f"diffs={len(diffs)} diffs_total_size={diffs_size}" + ) + start = time.time() with self.geodiff_copy(base_meta.abs_path) as restored_file: copy_time = time.time() - start logging.info( f"Restore file: {base_meta.abs_path} copied to {restored_file} in {copy_time} s" ) - logging.info(f"Restoring gpkg file with {len(diffs)} diffs") try: self.flush_geodiff_logger() # clean geodiff logger changeset = os.path.join( diff --git a/server/mergin/sync/tasks.py b/server/mergin/sync/tasks.py index 480222e6..1bbf708b 100644 --- a/server/mergin/sync/tasks.py +++ b/server/mergin/sync/tasks.py @@ -121,6 +121,12 @@ def create_project_version_zip(version_id: int): if not project_version: return + total_files = len(project_version.files) + logging.info( + f"create_project_version_zip: project_id={project_version.project_id} version={project_version.name} " + f"files={total_files} project_size={project_version.project_size}" + ) + zip_path = project_version.zip_path + ".partial" if os.path.exists(zip_path): mtime = datetime.fromtimestamp(os.path.getmtime(zip_path), tz=timezone.utc) @@ -151,6 +157,9 @@ def create_project_version_zip(version_id: int): ) # move zip file to final location os.rename(zip_path, project_version.zip_path) + logging.info( + f"create_project_version_zip: finished project_id={project_version.project_id} version={project_version.name}" + ) finally: # remove partial zip file if exists if os.path.exists(zip_path):