Skip to content

Commit fb9cbaa

Browse files
committed
Add post request to explicitely prepare download archive
1 parent 1642bfc commit fb9cbaa

5 files changed

Lines changed: 105 additions & 54 deletions

File tree

server/mergin/sync/private_api.yaml

Lines changed: 28 additions & 8 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+
"200":
428+
description: Archive is already prepared
429+
"202":
430+
description: Accepted
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:

server/mergin/sync/private_api_controller.py

Lines changed: 32 additions & 14 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
@@ -352,17 +352,35 @@ def download_project(id: str, version=None): # noqa: E501 # pylint: disable=W06
352352
f"attachment; filename*=UTF-8''{file_name}"
353353
)
354354
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)
367355

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

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)

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)

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

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,7 @@ export const useProjectStore = defineStore('projectModule', {
707707
},
708708

709709
async downloadArchive(payload: DownloadPayload) {
710+
console.log("You are here")
710711
const notificationStore = useNotificationStore()
711712
await this.cancelDownloadArchive()
712713
this.projectDownloadingVersion = payload.versionId
@@ -719,8 +720,12 @@ export const useProjectStore = defineStore('projectModule', {
719720

720721
const delays = [...Array(3).fill(1000), ...Array(3).fill(3000), 5000]
721722
let retryCount = 0
722-
const pollDownloadArchive = async () => {
723-
try {
723+
try {
724+
// STEP 1: request archive creation
725+
await ProjectApi.prepareArchive(payload.url)
726+
727+
// STEP 2: start polling HEAD for readiness
728+
const pollDownloadArchive = async () => {
724729
if (retryCount > 125) {
725730
notificationStore.warn({
726731
text: exceedMessage,
@@ -729,38 +734,42 @@ export const useProjectStore = defineStore('projectModule', {
729734
await this.cancelDownloadArchive()
730735
return
731736
}
732-
733-
const head = await ProjectApi.getHeadDownloadFile(payload.url)
734-
const polling = head.status == 202
735-
if (polling) {
736-
const delay = delays[Math.min(retryCount, delays.length - 1)] // Select delay based on retry count
737-
retryCount++ // Increment retry count
738-
downloadArchiveTimeout = setTimeout(async () => {
739-
await pollDownloadArchive()
740-
}, delay)
741-
return
742-
}
743-
744-
// Use browser download instead of playing around with the blob
745-
FileSaver.saveAs(payload.url)
746-
notificationStore.closeNotification()
747-
this.cancelDownloadArchive()
748-
} catch (e) {
749-
if (axios.isAxiosError(e) && e.response?.status === 400) {
750-
notificationStore.error({
751-
group: 'download-large-error',
752-
text: '',
753-
life: 6000
754-
})
755-
} else {
756-
notificationStore.error({
757-
text: errorMessage
758-
})
737+
try {
738+
const head = await ProjectApi.getHeadDownloadFile(payload.url)
739+
const polling = head.status === 202
740+
if (polling) {
741+
const delay = delays[Math.min(retryCount, delays.length - 1)] // Select delay based on retry count
742+
retryCount++ // Increment retry count
743+
downloadArchiveTimeout = setTimeout(async () => {
744+
await pollDownloadArchive()
745+
}, delay)
746+
return
747+
}
748+
749+
// Use browser download instead of playing around with the blob
750+
FileSaver.saveAs(payload.url)
751+
notificationStore.closeNotification()
752+
this.cancelDownloadArchive()
753+
} catch (e) {
754+
if (axios.isAxiosError(e) && e.response?.status === 400) {
755+
notificationStore.error({
756+
group: 'download-large-error',
757+
text: '',
758+
life: 6000
759+
})
760+
} else {
761+
notificationStore.error({
762+
text: errorMessage
763+
})
764+
}
765+
this.cancelDownloadArchive()
759766
}
760-
this.cancelDownloadArchive()
761767
}
768+
pollDownloadArchive()
769+
} catch (e) {
770+
notificationStore.error({ text: errorMessage })
771+
this.cancelDownloadArchive()
762772
}
763-
pollDownloadArchive()
764773
},
765774

766775
async cancelDownloadArchive() {

0 commit comments

Comments
 (0)