Coverage for gws-app/gws/plugin/qfieldcloud/action.py: 96%
426 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
1from typing import Optional, cast
3import re
4import os
5import hashlib
7import gws
8import gws.base.auth
9import gws.base.job
10import gws.base.shape
11import gws.base.action
12import gws.lib.mime
13import gws.lib.jsonx
14import gws.lib.datetimex as dtx
15import gws.lib.osx as osx
17from . import core, packager, patcher, api, caps
19gws.ext.new.action('qfieldcloud')
22class Config(gws.ConfigWithAccess):
23 """QField Cloud action."""
25 projects: list[core.ProjectConfig]
26 """QField Cloud projects."""
27 auth: Optional[gws.base.auth.method.Config]
28 """Options for the token authorization method. (added in 8.4)"""
31class Props(gws.base.action.Props):
32 pass
35class Request(gws.Data):
36 req: gws.WebRequester
37 """The original web request."""
38 route: str
39 """Request method and path."""
40 parts: dict
41 """Variables from the path."""
42 qs: dict
43 """Query string parameters."""
44 post: dict
45 """POST payload."""
46 project: gws.Project
47 """GWS Project context."""
48 qfcProject: core.QfcProject
49 """QField Cloud Project context."""
50 user: gws.User
51 """Authenticated user."""
52 sess: gws.AuthSession
53 """Authentication session."""
54 token: str
55 """Authentication token."""
58class WorkerPayload(gws.Data):
59 actionUid: str
60 jobType: str
61 qfcProjectUid: str
62 projectUid: str
65def route(pattern: str):
66 def decorator(fn):
67 fn._route_pattern = pattern
68 return fn
70 return decorator
73class Object(gws.base.action.Object):
74 """QField Cloud API action."""
76 qfcProjects: list[core.QfcProject]
77 capsCache: dict[str, caps.Caps]
78 method: gws.AuthMethod
80 def configure(self):
81 self.method = cast(gws.AuthMethod, self.create_child(gws.ext.object.authMethod, self.cfg('auth'), type='qfieldcloud'))
82 self.root.app.authMgr.add_method(self.method)
83 self.qfcProjects = []
84 for p in self.cfg('projects') or []:
85 qp = self.create_child(core.QfcProject, p)
86 if qp:
87 self.qfcProjects.append(cast(core.QfcProject, qp))
89 def __getstate__(self):
90 return gws.u.omit(vars(self), 'capsCache')
92 @gws.ext.command.raw('qfieldcloudApi')
93 def raw_request(self, req: gws.WebRequester, p: gws.Request) -> gws.ContentResponse:
94 path = req.path().strip('/')
95 if not path:
96 raise gws.NotFoundError('API path not specified')
98 path_parts = path.split('/')
99 project = cast(gws.Project, self.find_closest(gws.ext.object.project))
101 if path_parts[0] == 'projectUid':
102 try:
103 path_parts.pop(0)
104 uid = path_parts.pop(0)
105 path = '/'.join(path_parts)
106 except IndexError:
107 raise gws.NotFoundError('gws project UID not specified')
108 if not project:
109 project = req.user.require_project(uid)
110 elif uid != project.uid:
111 raise gws.NotFoundError(f'gws project UID mismatch: {uid=} != {project.uid=}')
113 if not project:
114 raise gws.NotFoundError('gws project not found')
116 path = path.strip('/')
117 route = f'{req.method} {path}'
119 for name in dir(self):
120 fn = getattr(self, name)
121 if callable(fn) and hasattr(fn, '_route_pattern'):
122 m = re.match(f'^{fn._route_pattern}$', route)
123 if m:
124 rx = Request(
125 req=req,
126 project=project,
127 route=route,
128 parts=m.groupdict(),
129 post={},
130 qs=req.query_params(),
131 )
132 return self._handle_route(fn, rx)
134 raise gws.NotFoundError(f'API {route=} not found')
136 _public_routes = [
137 'GET api/v1/auth/providers',
138 'POST api/v1/auth/token',
139 ]
141 def _handle_route(self, fn, rx: Request) -> gws.ContentResponse:
142 if rx.req.isApi:
143 rx.post = rx.req.struct()
144 elif rx.req.isForm:
145 rx.post = dict(rx.req.form())
146 elif rx.req.isPost:
147 rx.post = dict(raw=rx.req.data())
149 gws.log.debug(f'API_REQUEST {rx.route=} -> {fn.__name__} {rx=}')
151 if rx.route not in self._public_routes:
152 self.authorize_from_token(rx)
154 res = fn(rx)
156 if not res:
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 mime=gws.lib.mime.JSON,
166 )
168 raise gws.Error(f'API {rx.route=} invalid response type: {type(res)}')
170 ##
172 @route('POST api/v1/auth/logout')
173 def on_post_auth_logout(self, rx: Request):
174 am = self.root.app.authMgr
175 am.sessionMgr.delete(rx.sess)
176 gws.log.debug(f'{self=} {rx=}')
178 @route('GET api/v1/auth/providers')
179 def on_get_auth_providers(self, rx: Request) -> list[api.AuthProvider]:
180 return [
181 api.AuthProvider(type='credentials', id='credentials', name='Username / Password'),
182 ]
184 @route('POST api/v1/auth/token')
185 def on_post_auth_token(self, rx: Request) -> api.AuthToken:
186 self.authorize_from_credentials(
187 gws.Data(
188 username=rx.post.get('username', ''),
189 password=rx.post.get('password', ''),
190 ),
191 rx,
192 )
193 am = self.root.app.authMgr
194 return api.AuthToken(
195 token=rx.token,
196 expires_at=dtx.to_iso_string(dtx.add(seconds=am.sessionMgr.lifeTime)),
197 username=rx.user.loginName,
198 type=api.UserType.person,
199 full_name=rx.user.displayName,
200 avatar_url='',
201 email='',
202 first_name='',
203 last_name='',
204 )
206 @route('GET api/v1/auth/user')
207 def on_get_auth_user(self, rx: Request) -> api.CompleteUser:
208 return api.CompleteUser(
209 username=rx.user.loginName,
210 type=api.UserType.person,
211 full_name=rx.user.displayName,
212 avatar_url='',
213 email='',
214 first_name='',
215 last_name='',
216 )
218 @route('GET api/v1/projects')
219 def on_get_projects(self, rx: Request) -> list[api.Project]:
220 limit = int(rx.qs.get('limit', 100))
221 offset = int(rx.qs.get('offset', 0))
222 qps = self.get_qfc_projects(rx.user)
223 return [_format_project(qp, rx) for qp in qps[offset : offset + limit]]
225 @route('GET api/v1/projects/(?P<project_id>[^/]+)')
226 def on_get_projects_id(self, rx: Request) -> api.Project:
227 self.set_qfc_project_from_parts(rx)
228 return _format_project(rx.qfcProject, rx)
230 @route('POST api/v1/jobs')
231 def on_post_jobs(self, rx: Request) -> api.Job:
232 project_id = rx.post.get('project_id', '')
233 type = rx.post.get('type', '')
234 self.set_qfc_project(project_id, rx)
235 if type != api.TypeEnum.package:
236 raise gws.Error(f'Unsupported job type: {type!r}')
238 job = self.create_package_job(rx)
239 return _format_job(job, rx)
241 @route('GET api/v1/jobs/(?P<job_id>[^/]+)')
242 def on_get_jobs_id(self, rx: Request) -> api.Job:
243 job_id = rx.parts.get('job_id', '')
244 job = self.get_job(job_id, rx.project, rx.user)
245 if not job:
246 raise gws.NotFoundError(f'Job {job_id!r} not found')
247 return _format_job(job, rx)
249 @route('GET api/v1/packages/(?P<project_id>[^/]+)/(?P<package_version>[^/]+)')
250 def on_get_package(self, rx: Request) -> api.Package:
251 self.set_qfc_project_from_parts(rx)
253 # @TODO do we need versions?
254 # @TODO do we need layers?
255 # package_version = rx.parts.get('package_version', '')
257 path_map = self.get_latest_package_path_map(rx)
258 return api.Package(
259 files=_format_files(path_map),
260 layers=[],
261 status=api.JobStatusEnum.finished,
262 package_id=rx.qfcProject.uid,
263 packaged_at=dtx.to_iso_string(),
264 data_last_updated_at=dtx.to_iso_string(),
265 )
267 @route('GET api/v1/packages/(?P<project_id>[^/]+)/(?P<package_version>[^/]+)/files/(?P<file_name>.+)')
268 def on_get_package_file(self, rx: Request) -> gws.ContentResponse:
269 self.set_qfc_project_from_parts(rx)
271 file_name = rx.parts.get('file_name', '')
272 path_map = self.get_latest_package_path_map(rx)
273 for fname, p in path_map.items():
274 if file_name == fname:
275 return gws.ContentResponse(contentPath=p)
276 raise gws.NotFoundError(f'file {file_name!r} not found')
278 @route('GET api/v1/files/(?P<project_id>[^/]+)')
279 def on_get_files(self, rx: Request) -> list[api.PackageFile]:
280 self.set_qfc_project_from_parts(rx)
281 path_map = self.get_latest_package_path_map(rx)
282 return _format_files(path_map)
284 @route('POST api/v1/deltas/(?P<project_id>[^/]+)')
285 def on_post_deltas(self, rx: Request):
286 self.set_qfc_project_from_parts(rx)
288 # deltas come as a multipart file upload
289 try:
290 js = gws.lib.jsonx.from_string(rx.post['file'].stream.read().decode('utf-8'))
291 payload = api.DeltasPayload(
292 deltas=js['deltas'],
293 files=js.get('files', []),
294 id=js['id'],
295 project=js['project'],
296 version=js['version'],
297 )
298 except Exception as exc:
299 raise gws.BadRequestError(f'invalid delta file content: {exc}')
301 self.store_delta_payload(payload, rx)
303 changes = []
304 for d in payload.deltas:
305 new = d.get('new', {})
306 old = d.get('old', {})
307 chg = patcher.Change(
308 uid=d['uuid'],
309 type=d['method'],
310 layerUid=d['localLayerId'],
311 newAtts=new['attributes'] if new else {},
312 oldAtts=old['attributes'] if old else {},
313 wkt=new.get('geometry', ''),
314 )
315 changes.append(chg)
317 args = patcher.Args(
318 qfcProject=rx.qfcProject,
319 caps=self.get_caps(rx.qfcProject),
320 project=rx.project,
321 user=rx.user,
322 baseDir='',
323 changes=changes,
324 )
325 self.get_patcher().apply_changes(self.root, args)
327 self.set_delta_payload_applied(payload.id, rx)
329 @route('GET api/v1/deltas/(?P<project_id>[^/]+)/(?P<payload_id>.+)')
330 def on_get_deltas(self, rx: Request) -> list[api.StoredDelta]:
331 self.set_qfc_project_from_parts(rx)
333 # the content of the delta does not seem to matter much, only the ID and status=applied
334 # see QField/src/core/qfieldcloud/qfieldcloudproject.cpp : getDeltaStatus()
336 payload_id = rx.parts.get('payload_id', '')
337 sds = self.get_delta_payload(payload_id, rx)
338 if not sds:
339 raise gws.NotFoundError(f'delta {payload_id=} not found')
340 return sds
342 @route('POST api/v1/files/(?P<project_id>[^/]+)/(?P<path>.+)')
343 def on_post_file(self, rx: Request):
344 self.set_qfc_project_from_parts(rx)
346 path = rx.parts.get('path', '')
347 try:
348 fc = rx.post['file'].stream.read()
349 except Exception as exc:
350 raise gws.BadRequestError(f'invalid file upload: {exc}')
352 args = patcher.Args(
353 qfcProject=rx.qfcProject,
354 caps=self.get_caps(rx.qfcProject),
355 project=rx.project,
356 user=rx.user,
357 baseDir='',
358 filePath=path,
359 fileContent=fc,
360 )
361 self.get_patcher().apply_upload(self.root, args)
363 ##
365 def get_packager(self) -> packager.Object:
366 return packager.Object()
368 def get_patcher(self) -> patcher.Object:
369 return patcher.Object()
371 ##
373 def get_caps(self, qfc_project: core.QfcProject) -> caps.Caps:
374 if not hasattr(self, 'capsCache'):
375 self.capsCache = {}
376 cs = self.get_cached_caps(qfc_project)
377 if cs:
378 return cs
380 pa = caps.Parser(qfc_project)
381 pa.parse()
382 gws.u.serialize_to_path(pa.caps, f'{self.fs_project_cache_dir(qfc_project)}/caps.pickle')
383 pa.create_models()
384 pa.assign_path_props()
386 self.capsCache[qfc_project.uid] = pa.caps
387 gws.log.debug(f'get_caps: {qfc_project.uid=}: created')
388 return pa.caps
390 def get_cached_caps(self, qfc_project: core.QfcProject) -> Optional[caps.Caps]:
391 cs = self.capsCache.get(qfc_project.uid)
392 if not cs:
393 gws.log.debug(f'get_caps: {qfc_project.uid=}: not found')
394 return
396 qp = qfc_project.qgisProvider.qgis_project()
397 if qp.sourceHash != cs.sourceHash:
398 gws.log.debug(f'get_caps: {qfc_project.uid=}: hash changed: {cs.sourceHash=} != {qp.sourceHash=}')
399 self.capsCache.pop(qfc_project.uid, None)
400 return
402 gws.log.debug(f'get_caps: {qfc_project.uid=}: CACHED!')
403 return cs
405 def authorize_from_credentials(self, credentials: gws.Data, rx: Request):
406 am = self.root.app.authMgr
407 user = am.authenticate(self.method, credentials, rx.req)
408 if not user:
409 raise gws.ForbiddenError('invalid username or password')
410 rx.sess = am.sessionMgr.create(self.method, user)
411 rx.user = user
412 rx.token = rx.sess.uid
414 def authorize_from_token(self, rx: Request):
415 h = rx.req.header('Authorization', '')
416 m = re.match(r'^Token (.+)$', h)
417 if not m:
418 raise gws.ForbiddenError('token_auth: missing or invalid Authorization header')
419 token = m.group(1)
420 am = self.root.app.authMgr
421 if not am.can_use_method(rx.req, self.method):
422 raise gws.ForbiddenError('token_auth: insecure_context')
423 sess = am.sessionMgr.get(token)
424 if not sess:
425 raise gws.ForbiddenError(f'token_auth: invalid or expired {token=}')
426 if not sess.method or sess.method.uid != self.method.uid:
427 raise gws.ForbiddenError(f'token_auth: wrong method {sess.method=}')
428 rx.sess = sess
429 rx.user = sess.user
430 rx.token = sess.uid
431 am.sessionMgr.touch(sess)
432 gws.log.debug(f'token_auth: ok: {rx.token=} {rx.user.uid=} {rx.user.loginName=}')
434 ##
436 def set_qfc_project(self, uid: str, rx: Request):
437 qp = self.get_qfc_project(uid, rx.user)
438 if not qp:
439 raise gws.NotFoundError(f'project {uid!r} not found')
440 rx.qfcProject = qp
442 def set_qfc_project_from_parts(self, rx: Request):
443 uid = rx.parts.get('project_id', '')
444 self.set_qfc_project(uid, rx)
446 ##
448 def get_qfc_projects(self, user: gws.User) -> list[core.QfcProject]:
449 return [p for p in self.qfcProjects if user.can_use(p)]
451 def get_qfc_project(self, qfc_project_uid: str, user: gws.User) -> Optional[core.QfcProject]:
452 for qp in self.get_qfc_projects(user):
453 if qp.uid == qfc_project_uid:
454 return qp
456 ##
458 def create_package_job(self, rx: Request) -> gws.Job:
459 mgr = self.root.app.jobMgr
460 p = WorkerPayload(
461 actionUid=self.uid,
462 jobType='package',
463 qfcProjectUid=rx.qfcProject.uid,
464 projectUid=rx.project.uid,
465 )
466 job = mgr.create_job(
467 PackageWorker,
468 rx.user,
469 payload=gws.u.to_dict(p),
470 )
471 return mgr.schedule_job(job)
473 def create_package_from_worker(self, worker: 'PackageWorker', pa: WorkerPayload):
474 project = worker.user.require_project(pa.projectUid)
475 qfc_project = gws.u.require(self.get_qfc_project(pa.qfcProjectUid, worker.user))
477 self.fs_cleanup_old_packages(qfc_project)
479 uid = dtx.to_basic_string(with_ms=True)
480 pkg_dir = self.fs_new_package_dir(qfc_project, uid)
481 args = packager.Args(
482 uid=uid,
483 qfcProject=qfc_project,
484 caps=self.get_caps(qfc_project),
485 project=project,
486 user=worker.user,
487 packageDir=pkg_dir,
488 mapCacheDir=self.fs_project_cache_dir(qfc_project),
489 withBaseMap=True,
490 withData=True,
491 withMedia=True,
492 withQgis=True,
493 )
494 self.get_packager().create_package(self.root, args)
496 def create_package_from_cli(self, qfc_project_uid: str, target_dir: str, project: gws.Project, user: gws.User):
497 qfc_project = self.get_qfc_project(qfc_project_uid, user)
498 if not qfc_project:
499 raise gws.NotFoundError(f'project {qfc_project_uid!r} not found')
501 args = packager.Args(
502 uid='cli',
503 qfcProject=qfc_project,
504 caps=self.get_caps(qfc_project),
505 project=project,
506 user=user,
507 packageDir=target_dir,
508 mapCacheDir=self.fs_project_cache_dir(qfc_project),
509 withBaseMap=True,
510 withData=True,
511 withMedia=True,
512 withQgis=True,
513 )
514 self.get_packager().create_package(self.root, args)
516 def get_job(self, job_id: str, project: gws.Project, user: gws.User) -> Optional[gws.Job]:
517 return self.root.app.jobMgr.get_job(job_id, user=user)
519 ##
521 def store_delta_payload(self, payload: api.DeltasPayload, rx: Request):
522 self.fs_cleanup_old_deltas(rx.qfcProject)
523 sds = [
524 api.StoredDelta(
525 id=delta['uuid'],
526 deltafile_id=payload.id,
527 created_by=rx.user.loginName,
528 created_at=dtx.to_iso_string(),
529 updated_at=dtx.to_iso_string(),
530 status='STATUS_PENDING',
531 client_id=delta['clientId'],
532 output=None,
533 last_status='pending',
534 last_feedback=None,
535 content=delta,
536 )
537 for delta in payload.deltas
538 ]
539 gws.lib.jsonx.to_path(
540 self.fs_delta_payload_path(rx.qfcProject, payload.id),
541 sds,
542 )
544 def set_delta_payload_applied(self, payload_id: str, rx: Request):
545 sds = self.get_delta_payload(payload_id, rx)
546 if not sds:
547 return
548 for sd in sds:
549 sd.status = 'STATUS_APPLIED'
550 sd.last_status = 'applied'
551 sd.updated_at = dtx.to_iso_string()
552 gws.lib.jsonx.to_path(
553 self.fs_delta_payload_path(rx.qfcProject, payload_id),
554 sds,
555 )
557 def get_delta_payload(self, payload_id: str, rx: Request) -> Optional[list[api.StoredDelta]]:
558 path = self.fs_delta_payload_path(rx.qfcProject, payload_id)
559 if not os.path.exists(path):
560 gws.log.warning(f'stored delta {payload_id=}: not found: {path=}')
561 return
562 try:
563 sds = [api.StoredDelta(d) for d in gws.lib.jsonx.from_path(path)]
564 except Exception as exc:
565 gws.log.warning(f'stored delta {payload_id=}: failed to load {path=}: {exc}')
566 return
567 for sd in sds:
568 if sd.created_by != rx.user.loginName:
569 gws.log.warning(f'stored delta {payload_id=}: user mismatch: {sd.created_by=} != {rx.user.loginName=}')
570 return
571 return sds
573 ##
575 def fs_project_base_dir(self, qfc_project: core.QfcProject) -> str:
576 return gws.u.ensure_dir(f'{gws.c.VAR_DIR}/qfieldcloud/projects/{qfc_project.uid}')
578 def fs_latest_package_dir(self, qfc_project: core.QfcProject) -> Optional[str]:
579 base_dir = self.fs_project_base_dir(qfc_project)
580 for pkg in sorted(osx.find_directories(base_dir, deep=False), reverse=True):
581 m = re.search(r'package_(\d+)', pkg)
582 if m and gws.u.is_file(f'{pkg}/{packager.COMPLETE_FILE}'):
583 return pkg
585 def fs_new_package_dir(self, qfc_project: core.QfcProject, uid: str) -> str:
586 base_dir = self.fs_project_base_dir(qfc_project)
587 pkg_dir = gws.u.ensure_dir(f'{base_dir}/package_{uid}')
588 return pkg_dir
590 def fs_cleanup_old_packages(self, qfc_project: core.QfcProject, keep_seconds: int = 3600):
591 base_dir = self.fs_project_base_dir(qfc_project)
592 now = dtx.now().timestamp()
593 for pkg in osx.find_directories(base_dir, deep=False):
594 m = re.search(r'package_(\d+)', pkg)
595 if not m:
596 continue
597 t = osx.file_mtime(pkg)
598 if now - t > keep_seconds:
599 gws.log.info(f'fs_cleanup_old_packages: removing old package: {pkg=}')
600 osx.rmdir(pkg)
602 def fs_project_cache_dir(self, qfc_project: core.QfcProject) -> str:
603 base_dir = self.fs_project_base_dir(qfc_project)
604 return gws.u.ensure_dir(f'{base_dir}/cache')
606 def fs_project_deltas_dir(self, qfc_project: core.QfcProject) -> str:
607 base_dir = self.fs_project_base_dir(qfc_project)
608 return gws.u.ensure_dir(f'{base_dir}/deltas')
610 def fs_delta_payload_path(self, qfc_project: core.QfcProject, payload_id: str) -> str:
611 d = self.fs_project_deltas_dir(qfc_project)
612 u = gws.u.to_uid(payload_id)
613 return f'{d}/{u}.json'
615 def fs_cleanup_old_deltas(self, qfc_project: core.QfcProject, keep_seconds: int = 3600):
616 d = self.fs_project_deltas_dir(qfc_project)
617 now = dtx.now().timestamp()
618 for f in osx.find_files(d, deep=False):
619 t = osx.file_mtime(f)
620 if now - t > keep_seconds:
621 gws.log.info(f'fs_cleanup_old_deltas: removing old delta: {f=}')
622 osx.unlink(f)
624 def get_latest_package_path_map(self, rx: Request) -> dict[str, str]:
625 d = self.fs_latest_package_dir(rx.qfcProject)
626 try:
627 return gws.lib.jsonx.from_path(f'{d}/{packager.PATH_MAP_FILE}')
628 except Exception:
629 return {}
632##
635class PackageWorker(gws.base.job.worker.Object):
636 @classmethod
637 def run(cls, root: gws.Root, job: gws.Job):
638 w = cls(root, job.user, job)
639 w.work()
641 def work(self):
642 self.update_job(state=gws.JobState.running)
643 pa = WorkerPayload(gws.u.require(self.get_job()).payload)
644 action = cast(Object, self.root.get(pa.actionUid))
645 action.create_package_from_worker(self, pa)
646 self.update_job(state=gws.JobState.complete)
649##
652_DATE_CREATED = '2025-10-10T14:00:00'
655def _format_project(qp: core.QfcProject, rx: Request) -> api.Project:
656 return api.Project(
657 id=qp.uid,
658 name=qp.title,
659 owner=rx.user.loginName,
660 description='',
661 private=True,
662 is_public=False,
663 created_at=_DATE_CREATED,
664 updated_at=dtx.to_iso_string(),
665 data_last_packaged_at=None,
666 data_last_updated_at=dtx.to_iso_string(),
667 can_repackage=True,
668 needs_repackaging=True,
669 status='ok',
670 user_role='admin',
671 user_role_origin='project_owner',
672 shared_datasets_project_id=None,
673 is_shared_datasets_project=False,
674 is_featured=False,
675 is_attachment_download_on_demand=False,
676 )
679def _format_files(path_map: dict[str, str]):
680 return [
681 api.PackageFile(
682 name=fname,
683 size=osx.file_size(p),
684 uploaded_at=_get_time_iso(p),
685 is_attachment=False,
686 md5sum=_get_md5sum(p),
687 last_modified=_get_time_iso(p),
688 sha256=_get_sha256(p),
689 )
690 for fname, p in path_map.items()
691 ]
694def _format_job(job: gws.Job, rx: Request) -> api.Job:
695 status_map = {
696 gws.JobState.open: api.JobStatusEnum.pending,
697 gws.JobState.running: api.JobStatusEnum.started,
698 gws.JobState.complete: api.JobStatusEnum.finished,
699 gws.JobState.error: api.JobStatusEnum.failed,
700 }
702 return api.Job(
703 id=job.uid,
704 type=job.payload.get('jobType', ''),
705 created_at=dtx.to_iso_string(job.timeCreated),
706 created_by=1,
707 project_id=job.payload.get('qfcProjectUid', ''),
708 status=status_map.get(job.state, api.JobStatusEnum.pending),
709 updated_at=dtx.to_iso_string(job.timeUpdated),
710 started_at=dtx.to_iso_string(job.timeUpdated) if job.state == gws.JobState.running else None,
711 finished_at=dtx.to_iso_string(job.timeUpdated) if job.state == gws.JobState.complete else None,
712 )
715def _get_sha256(path: str) -> str:
716 with open(path, 'rb') as f:
717 return hashlib.file_digest(f, 'sha256').hexdigest()
720def _get_time_iso(path: str) -> str:
721 t = osx.file_mtime(path)
722 return dtx.to_iso_string(dtx.from_timestamp(t))
725def _get_md5sum(path: str) -> str:
726 with open(path, 'rb') as f:
727 return _get_md5sum_file(f)
730def _get_md5sum_file(fp, part_size: int = 8 * 1024 * 1024) -> str:
731 """Compute file hash matching QField's fileEtag implementation.
732 Returns simple MD5 for files <= part_size, or S3-style multipart ETag for larger files.
733 """
734 fp.seek(0, 2)
735 file_size = fp.tell()
736 fp.seek(0)
738 if file_size <= part_size:
739 hash = hashlib.md5()
740 hash.update(fp.read())
741 return hash.hexdigest()
743 md5_sums = b''
744 read_size = 0
746 while read_size < file_size:
747 hash = hashlib.md5()
748 hash.update(fp.read(part_size))
749 md5_sums += hash.digest()
750 read_size += part_size
752 hash = hashlib.md5()
753 hash.update(md5_sums)
754 return f'{hash.hexdigest()}-{read_size // part_size}'