Skip to content

Commit 076202d

Browse files
authored
Add post request to explicitely prepare download archive (#499)
1 parent 1642bfc commit 076202d

6 files changed

Lines changed: 175 additions & 136 deletions

File tree

server/mergin/sync/private_api.yaml

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -384,20 +384,20 @@ paths:
384384
$ref: "#/components/responses/NotFoundResp"
385385
x-openapi-router-controller: mergin.sync.private_api_controller
386386
/projects/{id}/download:
387+
parameters:
388+
- $ref: "#/components/parameters/ProjectId"
389+
- name: version
390+
in: query
391+
description: Particular version to download
392+
required: false
393+
schema:
394+
$ref: "#/components/schemas/VersionName"
387395
get:
388396
tags:
389397
- project
390398
summary: Download full project
391399
description: Download whole project folder as zip file
392400
operationId: download_project
393-
parameters:
394-
- $ref: "#/components/parameters/ProjectId"
395-
- name: version
396-
in: query
397-
description: Particular version to download
398-
required: false
399-
schema:
400-
$ref: "#/components/schemas/VersionName"
401401
responses:
402402
"200":
403403
description: Zip file
@@ -417,6 +417,26 @@ paths:
417417
"404":
418418
$ref: "#/components/responses/NotFoundResp"
419419
x-openapi-router-controller: mergin.sync.private_api_controller
420+
post:
421+
tags:
422+
- project
423+
summary: Prepare project archive
424+
description: Prepare project zip archive to download
425+
operationId: prepare_archive
426+
responses:
427+
"201":
428+
description: Project archive creation started
429+
"204":
430+
$ref: "#/components/responses/NoContent"
431+
"400":
432+
$ref: "#/components/responses/BadStatusResp"
433+
"422":
434+
$ref: "#/components/responses/UnprocessableEntity"
435+
"403":
436+
$ref: "#/components/responses/Forbidden"
437+
"404":
438+
$ref: "#/components/responses/NotFoundResp"
439+
x-openapi-router-controller: mergin.sync.private_api_controller
420440
components:
421441
responses:
422442
UnauthorizedError:
@@ -436,7 +456,9 @@ components:
436456
UnsupportedMediaType:
437457
description: Payload format is in an unsupported format.
438458
ConflictResp:
439-
description: Request could not be processed becuase of conflict in resources
459+
description: Request could not be processed because of conflict in resources
460+
NoContent:
461+
description: Success. No content returned.
440462
parameters:
441463
Page:
442464
name: page

server/mergin/sync/private_api_controller.py

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ def get_project_access(id: str):
322322

323323
def download_project(id: str, version=None): # noqa: E501 # pylint: disable=W0622
324324
"""Download whole project folder as zip file in any version
325-
Return zip file if it exists, otherwise trigger background job to create it"""
325+
Return zip file if it exists, otherwise return 202"""
326326
project = require_project_by_uuid(id, ProjectPermissions.Read)
327327
lookup_version = (
328328
ProjectVersion.from_v_name(version) if version else project.latest_version
@@ -331,9 +331,6 @@ def download_project(id: str, version=None): # noqa: E501 # pylint: disable=W06
331331
project_id=project.id, name=lookup_version
332332
).first_or_404("Project version does not exist")
333333

334-
if project_version.project_size > current_app.config["MAX_DOWNLOAD_ARCHIVE_SIZE"]:
335-
abort(400)
336-
337334
# check zip is already created
338335
if os.path.exists(project_version.zip_path):
339336
if current_app.config["USE_X_ACCEL"]:
@@ -352,17 +349,36 @@ def download_project(id: str, version=None): # noqa: E501 # pylint: disable=W06
352349
f"attachment; filename*=UTF-8''{file_name}"
353350
)
354351
return resp
355-
# GET request triggers background job if no partial zip or expired one
356-
if request.method == "GET":
357-
temp_zip_path = project_version.zip_path + ".partial"
358-
# create zip if it does not exist yet or has expired
359-
partial_exists = os.path.exists(temp_zip_path)
360-
is_expired = partial_exists and datetime.fromtimestamp(
361-
os.path.getmtime(temp_zip_path), tz=timezone.utc
362-
) < datetime.now(timezone.utc) - timedelta(
363-
seconds=current_app.config["PARTIAL_ZIP_EXPIRATION"]
364-
)
365-
if not partial_exists or is_expired:
366-
create_project_version_zip.delay(project_version.id)
367352

368-
return "Project zip being prepared, please try again later", 202
353+
return "Project zip being prepared", 202
354+
355+
356+
def prepare_archive(id: str, version=None):
357+
"""Triggers background job to create project archive"""
358+
project = require_project_by_uuid(id, ProjectPermissions.Read)
359+
lookup_version = (
360+
ProjectVersion.from_v_name(version) if version else project.latest_version
361+
)
362+
pv = ProjectVersion.query.filter_by(
363+
project_id=project.id, name=lookup_version
364+
).first_or_404()
365+
366+
if pv.project_size > current_app.config["MAX_DOWNLOAD_ARCHIVE_SIZE"]:
367+
abort(400)
368+
369+
if os.path.exists(pv.zip_path):
370+
return NoContent, 204
371+
372+
# trigger job if no recent partial
373+
temp_zip_path = pv.zip_path + ".partial"
374+
partial_exists = os.path.exists(temp_zip_path)
375+
is_expired = partial_exists and datetime.fromtimestamp(
376+
os.path.getmtime(temp_zip_path), tz=timezone.utc
377+
) < datetime.now(timezone.utc) - timedelta(
378+
seconds=current_app.config["PARTIAL_ZIP_EXPIRATION"]
379+
)
380+
if partial_exists and not is_expired:
381+
return NoContent, 204
382+
383+
create_project_version_zip.delay(pv.id)
384+
return "Project zip creation started", 201

server/mergin/sync/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def create_project_version_zip(version_id: int):
123123
# partial zip is recent -> another job is likely running
124124
return
125125
else:
126-
# partial zip is too old -> remove and creating new one
126+
# partial zip is too old -> remove and create new one
127127
os.remove(zip_path)
128128

129129
os.makedirs(os.path.dirname(zip_path), exist_ok=True)

server/mergin/tests/test_private_project_api.py

Lines changed: 67 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -424,27 +424,81 @@ def test_admin_project_list(client):
424424
assert len(resp.json["items"]) == 14
425425

426426

427-
test_download_proj_data = [
428-
# zips do not exist, version not specified -> call celery task to create zip with latest version
429-
(0, 0, 0, None, 202, 1),
427+
def test_download_project(
428+
client,
429+
diff_project,
430+
):
431+
"""Test download endpoint responses"""
432+
resp = client.head(
433+
url_for(
434+
"/app.mergin_sync_private_api_controller_download_project",
435+
id=diff_project.id,
436+
version="",
437+
)
438+
)
439+
# zip archive does not exist yet
440+
assert resp.status_code == 202
441+
project_version = diff_project.get_latest_version()
442+
# pretend archive has been created
443+
zip_path = Path(project_version.zip_path)
444+
if zip_path.parent.exists():
445+
shutil.rmtree(zip_path.parent, ignore_errors=True)
446+
zip_path.parent.mkdir(parents=True, exist_ok=True)
447+
zip_path.touch()
448+
resp = client.head(
449+
url_for(
450+
"/app.mergin_sync_private_api_controller_download_project",
451+
id=diff_project.id,
452+
version="",
453+
)
454+
)
455+
# zip archive is ready -> download can start
456+
assert resp.status_code == 200
457+
458+
459+
def test_prepare_large_project_fail(client, diff_project):
460+
"""Test asking for too large project is refused"""
461+
resp = client.post(
462+
url_for(
463+
"/app.mergin_sync_private_api_controller_prepare_archive",
464+
id=diff_project.id,
465+
version="v1",
466+
)
467+
)
468+
assert resp.status_code == 201
469+
# pretend testing project to be too large by lowering limit
470+
client.application.config["MAX_DOWNLOAD_ARCHIVE_SIZE"] = 10
471+
resp = client.post(
472+
url_for(
473+
"/app.mergin_sync_private_api_controller_prepare_archive",
474+
id=diff_project.id,
475+
version="v1",
476+
)
477+
)
478+
assert resp.status_code == 400
479+
480+
481+
test_prepare_proj_data = [
482+
# zips do not exist, version not specified -> trigger celery to create zip with latest version
483+
(0, 0, 0, None, 201, 1),
430484
# expired partial zip exists -> call celery task
431-
(0, 1, 1, None, 202, 1),
432-
# valid partial zip exists -> return, do not call celery
433-
(0, 1, 0, None, 202, 0),
485+
(0, 1, 1, None, 201, 1),
486+
# valid partial zip exists -> do not call celery
487+
(0, 1, 0, None, 204, 0),
434488
# zips do not exist, version specified -> call celery task with specified version
435-
(0, 0, 0, "v1", 202, 1),
489+
(0, 0, 0, "v1", 201, 1),
436490
# specified version does not exist -> 404
437491
(0, 0, 0, "v100", 404, 0),
438-
# zip is ready to download
439-
(1, 0, 0, None, 200, 0),
492+
# zip is already prepared to download -> do not call celery
493+
(1, 0, 0, None, 204, 0),
440494
]
441495

442496

443497
@pytest.mark.parametrize(
444-
"zipfile,partial,expired,version,exp_resp,exp_call", test_download_proj_data
498+
"zipfile,partial,expired,version,exp_resp,exp_call", test_prepare_proj_data
445499
)
446500
@patch("mergin.sync.tasks.create_project_version_zip.delay")
447-
def test_download_project(
501+
def test_prepare_archive(
448502
mock_create_zip,
449503
client,
450504
zipfile,
@@ -455,7 +509,7 @@ def test_download_project(
455509
exp_call,
456510
diff_project,
457511
):
458-
"""Test download endpoint responses and celery task calling"""
512+
"""Test prepare archive endpoint responses and celery task calling"""
459513
# prepare initial state according to testcase
460514
project_version = diff_project.get_latest_version()
461515
if zipfile:
@@ -474,7 +528,7 @@ def test_download_project(
474528
seconds=current_app.config["PARTIAL_ZIP_EXPIRATION"] + 1
475529
)
476530
modify_file_times(temp_zip_path, new_time)
477-
resp = client.get(
531+
resp = client.post(
478532
url_for(
479533
"/app.mergin_sync_private_api_controller_download_project",
480534
id=diff_project.id,
@@ -487,68 +541,3 @@ def test_download_project(
487541
call_args, _ = mock_create_zip.call_args
488542
args = call_args[0]
489543
assert args == diff_project.latest_version
490-
491-
492-
def test_large_project_download_fail(client, diff_project):
493-
"""Test downloading too large project is refused"""
494-
resp = client.get(
495-
url_for(
496-
"/app.mergin_sync_private_api_controller_download_project",
497-
id=diff_project.id,
498-
version="v1",
499-
)
500-
)
501-
assert resp.status_code == 202
502-
# pretend testing project to be too large by lowering limit
503-
client.application.config["MAX_DOWNLOAD_ARCHIVE_SIZE"] = 10
504-
resp = client.get(
505-
url_for(
506-
"/app.mergin_sync_private_api_controller_download_project",
507-
id=diff_project.id,
508-
version="v1",
509-
)
510-
)
511-
assert resp.status_code == 400
512-
513-
514-
@patch("mergin.sync.tasks.create_project_version_zip.delay")
515-
def test_remove_abandoned_zip(mock_prepare_zip, client, diff_project):
516-
"""Test project download removes partial zip which is inactive for some time"""
517-
latest_version = diff_project.get_latest_version()
518-
# pretend an incomplete zip remained
519-
partial_zip_path = latest_version.zip_path + ".partial"
520-
os.makedirs(os.path.dirname(partial_zip_path), exist_ok=True)
521-
os.mknod(partial_zip_path)
522-
assert os.path.exists(partial_zip_path)
523-
# pretend abandoned partial zip by lowering the expiration limit
524-
client.application.config["PARTIAL_ZIP_EXPIRATION"] = 0
525-
# download should remove it (move to temp folder) and call a celery task which will try to create a correct zip
526-
resp = client.get(
527-
url_for(
528-
"/app.mergin_sync_private_api_controller_download_project",
529-
id=diff_project.id,
530-
)
531-
)
532-
assert mock_prepare_zip.called
533-
assert resp.status_code == 202
534-
535-
536-
@patch("mergin.sync.tasks.create_project_version_zip.delay")
537-
def test_download_project_request_method(mock_prepare_zip, client, diff_project):
538-
"""Test head request does not create a celery job"""
539-
resp = client.head(
540-
url_for(
541-
"/app.mergin_sync_private_api_controller_download_project",
542-
id=diff_project.id,
543-
)
544-
)
545-
assert not mock_prepare_zip.called
546-
assert resp.status_code == 202
547-
resp = client.get(
548-
url_for(
549-
"/app.mergin_sync_private_api_controller_download_project",
550-
id=diff_project.id,
551-
)
552-
)
553-
assert mock_prepare_zip.called
554-
assert resp.status_code == 202

web-app/packages/lib/src/modules/project/projectApi.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,10 @@ export const ProjectApi = {
311311
return ProjectModule.httpService.get(url, { responseType: 'blob' })
312312
},
313313

314+
async prepareArchive(url: string): Promise<AxiosResponse<void>> {
315+
return ProjectModule.httpService.post(url)
316+
},
317+
314318
/** Request head of file download */
315319
async getHeadDownloadFile(url: string): Promise<AxiosResponse<void>> {
316320
return ProjectModule.httpService.head(url)

0 commit comments

Comments
 (0)