Coverage for gws-app/gws/plugin/qfieldcloud/action_handler.py: 98%
299 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""QField Cloud API request handler."""
3from typing import Optional, cast
5import re
6import hashlib
8import gws
9import gws.lib.mime
10import gws.lib.jsonx
11import gws.lib.sqlitex
12import gws.lib.datetimex as dtx
13import gws.lib.osx as osx
15from . import core, patcher, api, action_base, delta, auth
17DELTA_LOCK_TIMEOUT = 10
18"""Seconds to wait for the delta lock of a QField project."""
21def route(pattern: str):
22 """Decorator that marks a method as an API route handler.
24 Args:
25 pattern: Regular expression matched against ``"<METHOD> <path>"``. Named groups are passed to the handler in ``Handler.parts``.
27 Returns:
28 The decorator.
29 """
31 def decorator(fn):
32 fn._route_pattern = pattern
33 return fn
35 return decorator
38class Handler:
39 """Handler for a single QFieldCloud API request.
41 Finds the route for the request, authenticates the user and calls the route method.
42 The handler holds the state of the request only, persistent data is accessed via ``action``.
43 """
45 action: action_base.BaseAction
46 """The action that received the request."""
47 req: gws.WebRequester
48 """The original web request."""
49 params: gws.Request
50 """Command request."""
51 apiRoute: str
52 """Request method and path."""
53 parts: dict
54 """Variables from the path."""
55 qs: dict
56 """Query string parameters."""
57 post: dict
58 """POST payload."""
59 project: Optional[gws.Project]
60 """GWS Project context."""
61 qfcProject: core.QfcProject
62 """QField Cloud Project context."""
63 auth: auth.Result
64 """Authentication result: user, session and token."""
66 def __init__(self, action: action_base.BaseAction):
67 """Create a handler.
69 Args:
70 action: The action that received the request.
71 """
72 self.action = action
73 self.apiRoute = ''
74 self.parts = {}
75 self.qs = {}
76 self.post = {}
77 self.project = None
79 def handle(self, req: gws.WebRequester, params: gws.Request) -> gws.ContentResponse:
80 """Find the route method for a request and call it.
82 The path can start with ``projectUid/<uid>`` to set the GWS project.
83 If the action belongs to a project, that project is used instead.
85 Args:
86 req: Web requester.
87 params: Command request.
89 Returns:
90 The response.
92 Raises:
93 ``gws.NotFoundError``: If the path is empty, the GWS project is not found or no route matches.
94 ``gws.ForbiddenError``: If the user cannot use the GWS project.
95 """
96 self.req = req
97 self.params = params
99 path = self.req.path().strip('/')
100 if not path:
101 raise gws.NotFoundError('API path not specified')
103 path_parts = path.split('/')
104 uid = self.params.get('projectUid')
106 if path_parts[0] == 'projectUid':
107 if len(path_parts) < 2:
108 raise gws.NotFoundError('gws project UID not specified')
109 uid = path_parts[1]
110 path = '/'.join(path_parts[2:])
112 project = cast(Optional[gws.Project], self.action.find_closest(gws.ext.object.project))
113 project_uid = project.uid if project else uid
114 if project_uid:
115 project = self.req.user.require_project(project_uid)
117 path = path.strip('/')
118 route = f'{self.req.method} {path}'
120 for name in dir(self):
121 fn = getattr(self, name)
122 if callable(fn) and hasattr(fn, '_route_pattern'):
123 m = re.match(f'^{fn._route_pattern}$', route)
124 if m:
125 self.project = project
126 self.apiRoute = route
127 self.parts = m.groupdict()
128 self.qs = self.req.query_params()
129 return self._handle_route(fn)
131 raise gws.NotFoundError(f'API {route=} not found')
133 _public_routes = (
134 'GET api/v1/auth/providers',
135 'POST api/v1/auth/token',
136 'GET api/v1/server/info',
137 'GET api/v1/status',
138 )
140 def _handle_route(self, fn) -> gws.ContentResponse:
141 """Read the request payload, authenticate non-public routes, call the route method and convert its result to a response."""
142 if self.req.isApi:
143 self.post = self.req.struct()
144 elif self.req.isForm:
145 self.post = dict(self.req.form())
146 elif self.req.isPost:
147 self.post = dict(raw=self.req.data())
149 gws.log.debug(f'API_REQUEST {self.apiRoute=} -> {fn.__name__} {self.parts=} {self.qs=}')
151 if self.apiRoute not in self._public_routes:
152 self.auth = self.action.authMethod.authenticate_from_token(self.req)
154 res = fn()
156 if res is None:
157 return gws.ContentResponse(content='')
159 if isinstance(res, gws.ContentResponse):
160 return res
162 if isinstance(res, (list, dict, gws.Data)):
163 return gws.ContentResponse(
164 content=gws.lib.jsonx.to_string(res),
165 mimeType=gws.lib.mime.JSON,
166 )
168 raise gws.Error(f'API {self.apiRoute=} invalid response type: {type(res)}')
170 ##
172 @route('POST api/v1/auth/logout')
173 def on_post_auth_logout(self):
174 """Log out, deleting the session."""
175 am = self.action.root.app.authMgr
176 am.sessionMgr.delete(self.auth.sess)
177 gws.log.debug(f'logout: {self.auth.user.loginName=}')
179 @route('GET api/v1/auth/providers')
180 def on_get_auth_providers(self) -> list[api.AuthProvider]:
181 """Return the authentication providers.
183 Returns:
184 A single username and password provider.
185 """
186 return [
187 api.AuthProvider(type='credentials', id='credentials', name='Username / Password'),
188 ]
190 @route('GET api/v1/server/info')
191 def on_get_server_info(self) -> api.ServerInfo:
192 """Return the server information.
194 Returns:
195 Server information.
196 """
197 return api.ServerInfo(
198 version=self.action.root.app.version,
199 auth_providers=self.on_get_auth_providers(),
200 signup_url='',
201 whitelabel={},
202 )
204 @route('GET api/v1/status')
205 def on_get_status(self) -> api.Status:
206 """Return the server status.
208 Returns:
209 Server status, always ``ok``.
210 """
211 return api.Status(
212 version=self.action.root.app.version,
213 database='ok',
214 storage='ok',
215 status_page_url=None,
216 incident_message=None,
217 incident_timestamp_utc=None,
218 maintenance_message=None,
219 maintenance_start_timestamp_utc=None,
220 maintenance_end_timestamp_utc=None,
221 )
223 @route('GET api/v1/users/(?P<username>[^/]+)/organizations')
224 def on_get_user_organizations(self) -> list:
225 """Return the organizations of a user.
227 Returns:
228 An empty list, organizations are not supported.
229 """
230 return []
232 @route('GET api/v1/subscriptions/(?P<username>[^/]+)/current')
233 def on_get_subscription(self) -> api.Subscription:
234 """Return the subscription of a user.
236 Returns:
237 A dummy active subscription.
238 """
239 return api.Subscription(
240 plan_display_name='',
241 active_storage_total_bytes=0,
242 storage_used_bytes=0,
243 plan_storage_threshold_warning_bytes=0,
244 plan_storage_threshold_critical_bytes=0,
245 status='active_paid',
246 )
248 @route('POST api/v1/auth/token')
249 def on_post_auth_token(self) -> api.AuthToken:
250 """Log in with username and password and create a session.
252 Returns:
253 Session token and user information.
255 Raises:
256 ``gws.AuthenticationError``: If the credentials are invalid.
257 """
258 self.auth = self.action.authMethod.authenticate_from_credentials(
259 gws.Data(
260 username=self.post.get('username', ''),
261 password=self.post.get('password', ''),
262 ),
263 self.req,
264 )
265 return api.AuthToken(
266 token=self.auth.token,
267 expires_at=dtx.to_iso_string(self.auth.expiresAt),
268 username=self.auth.user.loginName,
269 type=api.UserType.person,
270 full_name=self.auth.user.displayName,
271 avatar_url='',
272 email='',
273 first_name='',
274 last_name='',
275 )
277 @route('GET api/v1/auth/user')
278 def on_get_auth_user(self) -> api.CompleteUser:
279 """Return the authenticated user.
281 Returns:
282 User information.
283 """
284 return api.CompleteUser(
285 username=self.auth.user.loginName,
286 type=api.UserType.person,
287 full_name=self.auth.user.displayName,
288 avatar_url='',
289 email='',
290 first_name='',
291 last_name='',
292 )
294 @route('GET api/v1/projects')
295 def on_get_projects(self) -> list[api.Project]:
296 """Return the QField projects the user can use.
298 Returns:
299 Projects, paged by the ``limit`` and ``offset`` query parameters.
300 """
301 limit = int(self.qs.get('limit', 100))
302 offset = int(self.qs.get('offset', 0))
303 qps = self.action.get_qfc_projects(self.auth.user)
304 return [_format_project(qp, self.auth.user) for qp in qps[offset : offset + limit]]
306 @route('POST api/v1/projects')
307 def on_post_projects(self):
308 """Create a project. Not supported.
310 Raises:
311 ``gws.BadRequestError``: Always.
312 """
313 raise gws.BadRequestError('creating projects is not supported')
315 @route('GET api/v1/projects/(?P<project_id>[^/]+)')
316 def on_get_projects_id(self) -> api.Project:
317 """Return a QField project.
319 Returns:
320 Project.
322 Raises:
323 ``gws.NotFoundError``: If the QField project is not found.
324 """
325 self.set_qfc_project_from_parts()
326 return _format_project(self.qfcProject, self.auth.user)
328 @route('POST api/v1/jobs')
329 def on_post_jobs(self) -> api.Job:
330 """Create a job. Only ``package`` jobs are supported.
332 Returns:
333 The scheduled job.
335 Raises:
336 ``gws.NotFoundError``: If the QField project is not found.
337 ``gws.BadRequestError``: If the job type is not supported.
338 """
339 project_id = self.post.get('project_id', '')
340 type = self.post.get('type', '')
341 self.set_qfc_project(project_id)
342 if type != api.TypeEnum.package:
343 raise gws.BadRequestError(f'unsupported job type: {type!r}')
345 job = self.create_package_job()
346 return _format_job(job)
348 @route('GET api/v1/jobs')
349 def on_get_jobs(self):
350 """List jobs. Not supported.
352 Raises:
353 ``gws.BadRequestError``: Always.
354 """
355 raise gws.BadRequestError('listing jobs is not supported')
357 @route('GET api/v1/jobs/(?P<job_id>[^/]+)')
358 def on_get_jobs_id(self) -> api.Job:
359 """Return a job.
361 Returns:
362 Job.
364 Raises:
365 ``gws.NotFoundError``: If the job is not found.
366 """
367 job_id = self.parts.get('job_id', '')
368 job = self.action.get_job(job_id, self.auth.user)
369 if not job:
370 raise gws.NotFoundError(f'Job {job_id!r} not found')
371 return _format_job(job)
373 @route('GET api/v1/packages/(?P<project_id>[^/]+)/(?P<package_version>[^/]+)')
374 def on_get_package(self) -> api.Package:
375 """Return the latest package of a QField project.
377 Returns:
378 Package with its files. The package version is ignored.
380 Raises:
381 ``gws.NotFoundError``: If the QField project is not found.
382 """
383 self.set_qfc_project_from_parts()
385 # @TODO do we need versions?
386 # @TODO do we need layers?
387 # package_version = self.parts.get('package_version', '')
389 path_map = self.action.latest_package_path_map(self.qfcProject)
390 return api.Package(
391 files=_format_files(path_map),
392 layers=[],
393 status=api.JobStatusEnum.finished,
394 package_id=self.qfcProject.uid,
395 packaged_at=dtx.to_iso_string(),
396 data_last_updated_at=dtx.to_iso_string(),
397 )
399 @route('GET api/v1/packages/(?P<project_id>[^/]+)/(?P<package_version>[^/]+)/files/(?P<file_name>.+)')
400 def on_get_package_file(self) -> gws.ContentResponse:
401 """Return a file from the latest package.
403 Returns:
404 File content. A request with a ``Range`` header gets status 416, so that QField downloads the full file.
406 Raises:
407 ``gws.NotFoundError``: If the QField project is not found.
408 ``gws.NotFoundError``: If the file is not in the package.
409 """
410 self.set_qfc_project_from_parts()
412 file_name = self.parts.get('file_name', '')
413 path_map = self.action.latest_package_path_map(self.qfcProject)
414 for fname, p in path_map.items():
415 if file_name == fname:
416 # QField resumes interrupted downloads with "Range: bytes=N-" and appends the body to its partial file.
417 # Ranges are not supported, so refuse with 416: QField then drops the partial file and downloads in full.
418 if self.req.header('Range'):
419 return gws.ContentResponse(
420 status=416,
421 content='',
422 headers={'Content-Range': f'bytes */{osx.file_size(p)}'},
423 )
424 return gws.ContentResponse(contentPath=p)
425 raise gws.NotFoundError(f'file {file_name!r} not found')
427 @route('GET api/v1/files/(?P<project_id>[^/]+)')
428 def on_get_files(self) -> list[api.PackageFile]:
429 """Return the files of the latest package.
431 Returns:
432 Package files.
434 Raises:
435 ``gws.NotFoundError``: If the QField project is not found.
436 """
437 self.set_qfc_project_from_parts()
438 path_map = self.action.latest_package_path_map(self.qfcProject)
439 return _format_files(path_map)
441 @route('GET api/v1/files/thumbnails/(?P<project_id>[^/]+)')
442 def on_get_thumbnail(self) -> gws.ContentResponse:
443 """Return the thumbnail of a QField project.
445 Returns:
446 Thumbnail image.
448 Raises:
449 ``gws.NotFoundError``: If the QField project is not found.
450 ``gws.NotFoundError``: If the project has no thumbnail.
451 """
452 self.set_qfc_project_from_parts()
453 path = self.qfcProject.thumbnail
454 if not path or not gws.u.is_file(path):
455 raise gws.NotFoundError(f'no thumbnail for {self.qfcProject.uid!r}')
456 return gws.ContentResponse(contentPath=path)
458 @route('POST api/v1/deltas/(?P<project_id>[^/]+)')
459 def on_post_deltas(self):
460 """Store a delta payload and apply its changes.
462 The payload is uploaded as a multipart file. Deltas are identified by their content,
463 so a delta pushed again unchanged, also under another payload id, is not applied again.
464 The deltas that are new, or failed before, or have been pending for longer than
465 ``delta.PENDING_TIMEOUT``, are applied all together or not at all.
467 Raises:
468 ``gws.NotFoundError``: If the QField project is not found.
469 ``gws.BadRequestError``: If the payload cannot be read.
470 ``core.DeltaApplyError``: If a delta is still being applied, or the deltas cannot be applied.
471 """
472 self.set_qfc_project_from_parts()
474 try:
475 text = self.post['file'].stream.read().decode('utf-8')
476 payload = delta.parse_payload(text)
477 except Exception as exc:
478 raise gws.BadRequestError(f'invalid delta file: {exc}')
480 try:
481 with gws.u.server_lock(f'qfieldcloud_deltas_{self.qfcProject.uid}', DELTA_LOCK_TIMEOUT):
482 drs = self.store_pending_deltas(payload)
483 except gws.LockBusyError as exc:
484 raise core.DeltaApplyError(f'changes {payload.id!r}: server busy, try again later') from exc
486 if not drs:
487 gws.log.info(f'deltas {payload.id!r}: already applied')
488 return
490 try:
491 args = patcher.Args(
492 qfcProject=self.qfcProject,
493 caps=self.action.get_caps(self.qfcProject),
494 db=self.action.get_db_provider(self.qfcProject),
495 project=self.project,
496 user=self.auth.user,
497 baseDir='',
498 changes=delta.extract_changes(drs),
499 )
500 self.action.get_patcher().apply_changes(self.action.root, args)
501 except Exception as exc:
502 gws.log.exception(f'deltas {payload.id!r}: failed to apply')
503 delta.set_status_for_all(self.delta_db(), drs, api.DeltaStatusType.error, str(exc))
504 raise core.DeltaApplyError(f'changes {payload.id!r} could not be applied, see the server log') from exc
506 delta.set_status_for_all(self.delta_db(), drs, api.DeltaStatusType.applied)
508 def store_pending_deltas(self, payload: api.DeltasPayload) -> list[delta.Record]:
509 """Store the deltas of a payload that need to be applied, with the status pending.
511 Must be called under the delta lock of the QField project, so that concurrent
512 requests with the same deltas do not both apply them.
514 Args:
515 payload: Delta payload.
517 Returns:
518 The stored delta records, empty if all deltas are already applied.
520 Raises:
521 ``core.DeltaApplyError``: If a delta is still being applied.
522 """
523 user_name = self.auth.user.loginName
524 delta.cleanup(self.delta_db(), self.action.deltaLifeTime)
526 uids = []
527 drs = []
529 for d in payload.deltas:
530 uid = gws.u.sha256([user_name, d])
531 uids.append(uid)
532 dr = delta.get_one(self.delta_db(), uid, user_name)
533 if dr:
534 ds = delta.status(dr, payload.id)
535 if ds.status == api.DeltaStatusType.applied:
536 continue
537 if ds.status == api.DeltaStatusType.pending:
538 raise core.DeltaApplyError(f'changes {payload.id!r} are being applied, try again later')
539 drs.append(delta.new_record(uid, d, user_name))
541 delta.link_payload(self.delta_db(), payload.id, uids, user_name)
542 delta.store_all(self.delta_db(), drs)
543 return drs
545 @route('GET api/v1/deltas/(?P<project_id>[^/]+)')
546 def on_get_deltas(self) -> gws.ContentResponse:
547 """Return the deltas the user has sent for a QField project, newest first.
549 The list is paged by the ``limit`` and ``offset`` query parameters. If there are more deltas,
550 the response has an ``X-Next-Page`` header. The ``ordering`` parameter is ignored.
552 Returns:
553 Delta statuses as JSON.
555 Raises:
556 ``gws.NotFoundError``: If the QField project is not found.
557 ``gws.BadRequestError``: If ``limit`` or ``offset`` is not a number.
558 """
559 self.set_qfc_project_from_parts()
561 try:
562 limit = int(self.qs.get('limit', 50))
563 offset = int(self.qs.get('offset', 0))
564 except ValueError as exc:
565 raise gws.BadRequestError(f'invalid paging parameters: {exc}')
567 rs = delta.get_history(self.delta_db(), self.auth.user.loginName, limit + 1, offset)
568 dss = [delta.status(dr, payload_id) for dr, payload_id in rs[:limit]]
570 headers = {}
571 if len(rs) > limit:
572 headers['X-Next-Page'] = str(offset + limit)
574 return gws.ContentResponse(
575 content=gws.lib.jsonx.to_string(dss),
576 mimeType=gws.lib.mime.JSON,
577 headers=headers,
578 )
580 @route('GET api/v1/deltas/(?P<project_id>[^/]+)/(?P<payload_id>.+)')
581 def on_get_deltas_in_payload(self) -> list[core.DeltaStatus]:
582 """Return the status of the deltas of a payload.
584 Returns:
585 Delta statuses.
587 Raises:
588 ``gws.NotFoundError``: If the QField project is not found.
589 ``gws.NotFoundError``: If the payload is not found.
590 """
591 self.set_qfc_project_from_parts()
593 payload_id = self.parts.get('payload_id', '')
594 drs = delta.get_for_payload(self.delta_db(), payload_id, self.auth.user.loginName)
595 dss = [delta.status(dr, payload_id) for dr in drs]
596 if not dss:
597 raise gws.NotFoundError(f'delta {payload_id=} not found')
598 return dss
600 @route('POST api/v1/files/(?P<project_id>[^/]+)/(?P<path>.+)')
601 def on_post_file(self) -> gws.ContentResponse:
602 """Apply a file upload.
604 Returns:
605 An empty response with status 201.
607 Raises:
608 ``gws.NotFoundError``: If the QField project is not found.
609 ``gws.NotFoundError``: If no feature refers to the file.
610 ``gws.BadRequestError``: If the upload cannot be read.
611 """
612 self.set_qfc_project_from_parts()
614 path = self.parts.get('path', '')
615 try:
616 fc = self.post['file'].stream.read()
617 except Exception as exc:
618 raise gws.BadRequestError(f'invalid file upload: {exc}')
620 args = patcher.Args(
621 qfcProject=self.qfcProject,
622 caps=self.action.get_caps(self.qfcProject),
623 project=self.project,
624 user=self.auth.user,
625 baseDir='',
626 filePath=path,
627 fileContent=fc,
628 )
629 if not self.action.get_patcher().apply_upload(self.action.root, args):
630 raise gws.NotFoundError(f'no feature found for {path!r}')
631 return gws.ContentResponse(status=201, content='')
633 ##
635 ##
637 def set_qfc_project(self, uid: str):
638 """Set the QField project of the request.
640 Args:
641 uid: QField project uid.
643 Raises:
644 ``gws.NotFoundError``: If the project is not found or the user cannot use it.
645 """
646 qp = self.action.get_qfc_project(uid, self.auth.user)
647 if not qp:
648 raise gws.NotFoundError(f'project {uid!r} not found')
649 self.qfcProject = qp
651 def set_qfc_project_from_parts(self):
652 """Set the QField project of the request from the ``project_id`` path variable.
654 Raises:
655 ``gws.NotFoundError``: If the project is not found or the user cannot use it.
656 """
657 uid = self.parts.get('project_id', '')
658 self.set_qfc_project(uid)
660 ##
662 def create_package_job(self) -> gws.Job:
663 """Create and schedule a packaging job for the QField project of the request.
665 Returns:
666 The scheduled job.
667 """
668 mgr = self.action.root.app.jobMgr
669 p = action_base.WorkerPayload(
670 actionUid=self.action.uid,
671 jobType='package',
672 qfcProjectUid=self.qfcProject.uid,
673 projectUid=self.project.uid if self.project else None,
674 )
675 job = mgr.create_job(
676 action_base.PackageWorker,
677 self.auth.user,
678 payload=gws.u.to_dict(p),
679 )
680 return mgr.schedule_job(job)
682 def delta_db(self) -> gws.lib.sqlitex.Object:
683 """Return the delta database of the request's QField project.
685 Returns:
686 Database wrapper.
687 """
689 return gws.lib.sqlitex.Object(
690 self.action.deltas_db_path(self.qfcProject),
691 init_ddl=delta.STORAGE_DDL,
692 )
695##
697_DATE_CREATED = '2025-10-10T14:00:00'
700def _format_project(qp: core.QfcProject, user: gws.User) -> api.Project:
701 """Convert a QField project to an API project."""
702 return api.Project(
703 id=qp.uid,
704 name=qp.title,
705 owner=user.loginName,
706 description='',
707 private=True,
708 is_public=False,
709 created_at=_DATE_CREATED,
710 updated_at=dtx.to_iso_string(),
711 data_last_packaged_at=None,
712 data_last_updated_at=dtx.to_iso_string(),
713 can_repackage=True,
714 needs_repackaging=True,
715 status='ok',
716 user_role='admin',
717 user_role_origin='project_owner',
718 shared_datasets_project_id=None,
719 is_shared_datasets_project=False,
720 is_featured=False,
721 is_attachment_download_on_demand=False,
722 )
725def _format_files(path_map: dict[str, str]):
726 """Convert a package path map to a list of API package files."""
727 return [
728 api.PackageFile(
729 name=fname,
730 size=osx.file_size(p),
731 uploaded_at=_get_time_iso(p),
732 is_attachment=False,
733 md5sum=_get_md5sum(p),
734 last_modified=_get_time_iso(p),
735 sha256=_get_sha256(p),
736 )
737 for fname, p in path_map.items()
738 ]
741def _format_job(job: gws.Job) -> api.Job:
742 """Convert a GWS job to an API job."""
743 status_map = {
744 gws.JobState.open: api.JobStatusEnum.pending,
745 gws.JobState.running: api.JobStatusEnum.started,
746 gws.JobState.complete: api.JobStatusEnum.finished,
747 gws.JobState.error: api.JobStatusEnum.failed,
748 }
750 return api.Job(
751 id=job.uid,
752 type=job.payload.get('jobType', ''),
753 created_at=dtx.to_iso_string(job.timeCreated),
754 created_by=1,
755 project_id=job.payload.get('qfcProjectUid', ''),
756 status=status_map.get(job.state, api.JobStatusEnum.pending),
757 updated_at=dtx.to_iso_string(job.timeUpdated),
758 started_at=dtx.to_iso_string(job.timeUpdated) if job.state == gws.JobState.running else None,
759 finished_at=dtx.to_iso_string(job.timeUpdated) if job.state == gws.JobState.complete else None,
760 )
763def _get_sha256(path: str) -> str:
764 """Return the SHA-256 hash of a file."""
765 with open(path, 'rb') as f:
766 return hashlib.file_digest(f, 'sha256').hexdigest()
769def _get_time_iso(path: str) -> str:
770 """Return the modification time of a file as an ISO string."""
771 t = osx.file_mtime(path)
772 return dtx.to_iso_string(dtx.from_timestamp(t))
775def _get_md5sum(path: str) -> str:
776 """Return the MD5 hash of a file, as computed by QField."""
777 with open(path, 'rb') as f:
778 return _get_md5sum_file(f)
781def _get_md5sum_file(fp, part_size: int = 8 * 1024 * 1024) -> str:
782 """Return a plain MD5 for small files, or an S3-style multipart ETag for larger ones, as QField's ``fileEtag``."""
783 fp.seek(0, 2)
784 file_size = fp.tell()
785 fp.seek(0)
787 if file_size <= part_size:
788 hash = hashlib.md5()
789 hash.update(fp.read())
790 return hash.hexdigest()
792 md5_sums = b''
793 read_size = 0
795 while read_size < file_size:
796 hash = hashlib.md5()
797 hash.update(fp.read(part_size))
798 md5_sums += hash.digest()
799 read_size += part_size
801 hash = hashlib.md5()
802 hash.update(md5_sums)
803 return f'{hash.hexdigest()}-{read_size // part_size}'