Coverage for gws-app/gws/plugin/qfieldcloud/action.py: 97%
453 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""QField Cloud API action."""
3from typing import Optional, cast
5import re
6import os
7import hashlib
9import gws
10import gws.base.auth
11import gws.base.job
12import gws.lib.shape
13import gws.base.action
14import gws.lib.mime
15import gws.lib.jsonx
16import gws.lib.datetimex as dtx
17import gws.lib.osx as osx
19from . import core, packager, patcher, api, caps
22@gws.ext.config.action('qfieldcloud')
23class Config(gws.ConfigWithAccess):
24 """Endpoint for QField clients that emulates the QFieldCloud API."""
26 projects: list[core.ProjectConfig]
27 """Projects offered to QField clients."""
28 auth: Optional[gws.base.auth.method.Config]
29 """Token authentication method for QField clients."""
32@gws.ext.props.action('qfieldcloud')
33class Props(gws.base.action.Props):
34 pass
37class Request(gws.Data):
38 """An API request, passed to the route handlers."""
40 req: gws.WebRequester
41 """The original web request."""
42 route: str
43 """Request method and path."""
44 parts: dict
45 """Variables from the path."""
46 qs: dict
47 """Query string parameters."""
48 post: dict
49 """POST payload."""
50 project: Optional[gws.Project]
51 """GWS Project context."""
52 qfcProject: core.QfcProject
53 """QField Cloud Project context."""
54 user: gws.User
55 """Authenticated user."""
56 sess: gws.AuthSession
57 """Authentication session."""
58 token: str
59 """Authentication token."""
62class WorkerPayload(gws.Data):
63 """Job payload for the package worker."""
65 actionUid: str
66 """Uid of the action."""
67 jobType: str
68 """Job type, ``package``."""
69 qfcProjectUid: str
70 """QField project uid."""
71 projectUid: Optional[str]
72 """GWS project uid."""
75def route(pattern: str):
76 """Decorator that marks a method as an API route handler.
78 Args:
79 pattern: Regular expression matched against ``"<METHOD> <path>"``. Named groups are passed to the handler in ``Request.parts``.
81 Returns:
82 The decorator.
83 """
84 def decorator(fn):
85 fn._route_pattern = pattern
86 return fn
88 return decorator
91@gws.ext.object.action('qfieldcloud')
92class Object(gws.base.action.Object):
93 """QField Cloud API action.
95 Emulates the QFieldCloud API for the QField app: authenticates clients by
96 token, lists the configured QField projects, creates packages in background
97 jobs and applies deltas and file uploads from the devices.
98 """
100 qfcProjects: list[core.QfcProject]
101 """Configured QField projects."""
102 capsCache: dict[str, caps.Caps]
103 """Capabilities by QField project uid. Not serialized."""
104 method: gws.AuthMethod
105 """Authorization method for QField clients."""
107 def configure(self):
108 self.method = cast(gws.AuthMethod, self.create_child(gws.ext.object.authMethod, self.cfg('auth'), type='qfieldcloud'))
109 self.root.app.authMgr.add_method(self.method)
110 self.qfcProjects = []
111 for p in self.cfg('projects') or []:
112 qp = self.create_child(core.QfcProject, p)
113 if qp:
114 self.qfcProjects.append(cast(core.QfcProject, qp))
116 def __getstate__(self):
117 """Return the state for pickling, without the caps cache."""
118 return gws.u.omit(vars(self), 'capsCache')
120 @gws.ext.command.raw('qfieldcloudApi')
121 def raw_request(self, req: gws.WebRequester, p: gws.Request) -> gws.ContentResponse:
122 """Handle a QFieldCloud API request."""
123 try:
124 return self.dispatch_request(req, p)
125 except gws.NotFoundError as exc:
126 return _error_response(404, 'object_not_found', exc)
127 except gws.AuthenticationError as exc:
128 return _error_response(401, 'authentication_failed', exc)
129 except gws.ForbiddenError as exc:
130 return _error_response(403, 'permission_denied', exc)
131 except gws.BadRequestError as exc:
132 return _error_response(400, 'validation_error', exc)
134 def dispatch_request(self, req: gws.WebRequester, p: gws.Request) -> gws.ContentResponse:
135 """Find the route handler for a request and call it.
137 The path can start with ``projectUid/<uid>`` to set the GWS project.
138 If the action belongs to a project, that project is used instead.
140 Args:
141 req: Web requester.
142 p: Command request.
144 Returns:
145 The response.
147 Raises:
148 ``gws.NotFoundError``: If the path is empty, the GWS project is not found or no route matches.
149 ``gws.ForbiddenError``: If the user cannot use the GWS project.
150 """
151 path = req.path().strip('/')
152 if not path:
153 raise gws.NotFoundError('API path not specified')
155 path_parts = path.split('/')
156 uid = p.get('projectUid')
158 if path_parts[0] == 'projectUid':
159 if len(path_parts) < 2:
160 raise gws.NotFoundError('gws project UID not specified')
161 uid = path_parts[1]
162 path = '/'.join(path_parts[2:])
164 project = cast(Optional[gws.Project], self.find_closest(gws.ext.object.project))
165 project_uid = project.uid if project else uid
166 if project_uid:
167 project = req.user.require_project(project_uid)
169 path = path.strip('/')
170 route = f'{req.method} {path}'
172 for name in dir(self):
173 fn = getattr(self, name)
174 if callable(fn) and hasattr(fn, '_route_pattern'):
175 m = re.match(f'^{fn._route_pattern}$', route)
176 if m:
177 rx = Request(
178 req=req,
179 project=project,
180 route=route,
181 parts=m.groupdict(),
182 post={},
183 qs=req.query_params(),
184 )
185 return self._handle_route(fn, rx)
187 raise gws.NotFoundError(f'API {route=} not found')
189 _public_routes = (
190 'GET api/v1/auth/providers',
191 'POST api/v1/auth/token',
192 'GET api/v1/server/info',
193 'GET api/v1/status',
194 )
196 def _handle_route(self, fn, rx: Request) -> gws.ContentResponse:
197 """Read the request payload, authorize non-public routes, call the handler and convert its result to a response."""
198 if rx.req.isApi:
199 rx.post = rx.req.struct()
200 elif rx.req.isForm:
201 rx.post = dict(rx.req.form())
202 elif rx.req.isPost:
203 rx.post = dict(raw=rx.req.data())
205 gws.log.debug(f'API_REQUEST {rx.route=} -> {fn.__name__} {rx=}')
207 if rx.route not in self._public_routes:
208 self.authorize_from_token(rx)
210 res = fn(rx)
212 if res is None:
213 return gws.ContentResponse(content='')
215 if isinstance(res, gws.ContentResponse):
216 return res
218 if isinstance(res, (list, dict, gws.Data)):
219 return gws.ContentResponse(
220 content=gws.lib.jsonx.to_string(res),
221 mimeType=gws.lib.mime.JSON,
222 )
224 raise gws.Error(f'API {rx.route=} invalid response type: {type(res)}')
226 ##
228 @route('POST api/v1/auth/logout')
229 def on_post_auth_logout(self, rx: Request):
230 """Log out, deleting the session.
232 Args:
233 rx: API request.
234 """
235 am = self.root.app.authMgr
236 am.sessionMgr.delete(rx.sess)
237 gws.log.debug(f'{self=} {rx=}')
239 @route('GET api/v1/auth/providers')
240 def on_get_auth_providers(self, rx: Request) -> list[api.AuthProvider]:
241 """Return the authentication providers.
243 Args:
244 rx: API request.
246 Returns:
247 A single username and password provider.
248 """
249 return [
250 api.AuthProvider(type='credentials', id='credentials', name='Username / Password'),
251 ]
253 @route('GET api/v1/server/info')
254 def on_get_server_info(self, rx: Request) -> api.ServerInfo:
255 """Return the server information.
257 Args:
258 rx: API request.
260 Returns:
261 Server information.
262 """
263 return api.ServerInfo(
264 version=self.root.app.version,
265 auth_providers=self.on_get_auth_providers(rx),
266 signup_url='',
267 whitelabel={},
268 )
270 @route('GET api/v1/status')
271 def on_get_status(self, rx: Request) -> api.Status:
272 """Return the server status.
274 Args:
275 rx: API request.
277 Returns:
278 Server status, always ``ok``.
279 """
280 return api.Status(
281 version=self.root.app.version,
282 database='ok',
283 storage='ok',
284 status_page_url=None,
285 incident_message=None,
286 incident_timestamp_utc=None,
287 maintenance_message=None,
288 maintenance_start_timestamp_utc=None,
289 maintenance_end_timestamp_utc=None,
290 )
292 @route('GET api/v1/users/(?P<username>[^/]+)/organizations')
293 def on_get_user_organizations(self, rx: Request) -> list:
294 """Return the organizations of a user.
296 Args:
297 rx: API request.
299 Returns:
300 An empty list, organizations are not supported.
301 """
302 return []
304 @route('GET api/v1/subscriptions/(?P<username>[^/]+)/current')
305 def on_get_subscription(self, rx: Request) -> api.Subscription:
306 """Return the subscription of a user.
308 Args:
309 rx: API request.
311 Returns:
312 A dummy active subscription.
313 """
314 return api.Subscription(
315 plan_display_name='',
316 active_storage_total_bytes=0,
317 storage_used_bytes=0,
318 plan_storage_threshold_warning_bytes=0,
319 plan_storage_threshold_critical_bytes=0,
320 status='active_paid',
321 )
323 @route('POST api/v1/auth/token')
324 def on_post_auth_token(self, rx: Request) -> api.AuthToken:
325 """Log in with username and password and create a session.
327 Args:
328 rx: API request.
330 Returns:
331 Session token and user information.
333 Raises:
334 ``gws.AuthenticationError``: If the credentials are invalid.
335 """
336 self.authorize_from_credentials(
337 gws.Data(
338 username=rx.post.get('username', ''),
339 password=rx.post.get('password', ''),
340 ),
341 rx,
342 )
343 am = self.root.app.authMgr
344 return api.AuthToken(
345 token=rx.token,
346 expires_at=dtx.to_iso_string(dtx.add(seconds=am.sessionMgr.lifeTime)),
347 username=rx.user.loginName,
348 type=api.UserType.person,
349 full_name=rx.user.displayName,
350 avatar_url='',
351 email='',
352 first_name='',
353 last_name='',
354 )
356 @route('GET api/v1/auth/user')
357 def on_get_auth_user(self, rx: Request) -> api.CompleteUser:
358 """Return the authenticated user.
360 Args:
361 rx: API request.
363 Returns:
364 User information.
365 """
366 return api.CompleteUser(
367 username=rx.user.loginName,
368 type=api.UserType.person,
369 full_name=rx.user.displayName,
370 avatar_url='',
371 email='',
372 first_name='',
373 last_name='',
374 )
376 @route('GET api/v1/projects')
377 def on_get_projects(self, rx: Request) -> list[api.Project]:
378 """Return the QField projects the user can use.
380 Args:
381 rx: API request.
383 Returns:
384 Projects, paged by the ``limit`` and ``offset`` query parameters.
385 """
386 limit = int(rx.qs.get('limit', 100))
387 offset = int(rx.qs.get('offset', 0))
388 qps = self.get_qfc_projects(rx.user)
389 return [_format_project(qp, rx) for qp in qps[offset : offset + limit]]
391 @route('POST api/v1/projects')
392 def on_post_projects(self, rx: Request):
393 """Create a project. Not supported.
395 Args:
396 rx: API request.
398 Raises:
399 ``gws.BadRequestError``: Always.
400 """
401 raise gws.BadRequestError('creating projects is not supported')
403 @route('GET api/v1/projects/(?P<project_id>[^/]+)')
404 def on_get_projects_id(self, rx: Request) -> api.Project:
405 """Return a QField project.
407 Args:
408 rx: API request.
410 Returns:
411 Project.
413 Raises:
414 ``gws.NotFoundError``: If the QField project is not found.
415 """
416 self.set_qfc_project_from_parts(rx)
417 return _format_project(rx.qfcProject, rx)
419 @route('POST api/v1/jobs')
420 def on_post_jobs(self, rx: Request) -> api.Job:
421 """Create a job. Only ``package`` jobs are supported.
423 Args:
424 rx: API request.
426 Returns:
427 The scheduled job.
429 Raises:
430 ``gws.NotFoundError``: If the QField project is not found.
431 ``gws.BadRequestError``: If the job type is not supported.
432 """
433 project_id = rx.post.get('project_id', '')
434 type = rx.post.get('type', '')
435 self.set_qfc_project(project_id, rx)
436 if type != api.TypeEnum.package:
437 raise gws.BadRequestError(f'unsupported job type: {type!r}')
439 job = self.create_package_job(rx)
440 return _format_job(job, rx)
442 @route('GET api/v1/jobs')
443 def on_get_jobs(self, rx: Request):
444 """List jobs. Not supported.
446 Args:
447 rx: API request.
449 Raises:
450 ``gws.BadRequestError``: Always.
451 """
452 raise gws.BadRequestError('listing jobs is not supported')
454 @route('GET api/v1/jobs/(?P<job_id>[^/]+)')
455 def on_get_jobs_id(self, rx: Request) -> api.Job:
456 """Return a job.
458 Args:
459 rx: API request.
461 Returns:
462 Job.
464 Raises:
465 ``gws.NotFoundError``: If the job is not found.
466 """
467 job_id = rx.parts.get('job_id', '')
468 job = self.get_job(job_id, rx.project, rx.user)
469 if not job:
470 raise gws.NotFoundError(f'Job {job_id!r} not found')
471 return _format_job(job, rx)
473 @route('GET api/v1/packages/(?P<project_id>[^/]+)/(?P<package_version>[^/]+)')
474 def on_get_package(self, rx: Request) -> api.Package:
475 """Return the latest package of a QField project.
477 Args:
478 rx: API request.
480 Returns:
481 Package with its files. The package version is ignored.
483 Raises:
484 ``gws.NotFoundError``: If the QField project is not found.
485 """
486 self.set_qfc_project_from_parts(rx)
488 # @TODO do we need versions?
489 # @TODO do we need layers?
490 # package_version = rx.parts.get('package_version', '')
492 path_map = self.get_latest_package_path_map(rx)
493 return api.Package(
494 files=_format_files(path_map),
495 layers=[],
496 status=api.JobStatusEnum.finished,
497 package_id=rx.qfcProject.uid,
498 packaged_at=dtx.to_iso_string(),
499 data_last_updated_at=dtx.to_iso_string(),
500 )
502 @route('GET api/v1/packages/(?P<project_id>[^/]+)/(?P<package_version>[^/]+)/files/(?P<file_name>.+)')
503 def on_get_package_file(self, rx: Request) -> gws.ContentResponse:
504 """Return a file from the latest package.
506 Args:
507 rx: API request.
509 Returns:
510 File content. A request with a ``Range`` header gets status 416, so that QField downloads the full file.
512 Raises:
513 ``gws.NotFoundError``: If the QField project is not found.
514 ``gws.NotFoundError``: If the file is not in the package.
515 """
516 self.set_qfc_project_from_parts(rx)
518 file_name = rx.parts.get('file_name', '')
519 path_map = self.get_latest_package_path_map(rx)
520 for fname, p in path_map.items():
521 if file_name == fname:
522 # QField resumes interrupted downloads with "Range: bytes=N-" and appends the body to its partial file.
523 # Ranges are not supported, so refuse with 416: QField then drops the partial file and downloads in full.
524 if rx.req.header('Range'):
525 return gws.ContentResponse(
526 status=416,
527 content='',
528 headers={'Content-Range': f'bytes */{osx.file_size(p)}'},
529 )
530 return gws.ContentResponse(contentPath=p)
531 raise gws.NotFoundError(f'file {file_name!r} not found')
533 @route('GET api/v1/files/(?P<project_id>[^/]+)')
534 def on_get_files(self, rx: Request) -> list[api.PackageFile]:
535 """Return the files of the latest package.
537 Args:
538 rx: API request.
540 Returns:
541 Package files.
543 Raises:
544 ``gws.NotFoundError``: If the QField project is not found.
545 """
546 self.set_qfc_project_from_parts(rx)
547 path_map = self.get_latest_package_path_map(rx)
548 return _format_files(path_map)
550 @route('GET api/v1/files/thumbnails/(?P<project_id>[^/]+)')
551 def on_get_thumbnail(self, rx: Request) -> gws.ContentResponse:
552 """Return the thumbnail of a QField project.
554 Args:
555 rx: API request.
557 Returns:
558 Thumbnail image.
560 Raises:
561 ``gws.NotFoundError``: If the QField project is not found.
562 ``gws.NotFoundError``: If the project has no thumbnail.
563 """
564 self.set_qfc_project_from_parts(rx)
565 path = rx.qfcProject.thumbnail
566 if not path or not gws.u.is_file(path):
567 raise gws.NotFoundError(f'no thumbnail for {rx.qfcProject.uid!r}')
568 return gws.ContentResponse(contentPath=path)
570 @route('POST api/v1/deltas/(?P<project_id>[^/]+)')
571 def on_post_deltas(self, rx: Request):
572 """Store a delta payload and apply its changes.
574 The payload is uploaded as a multipart file. After the patcher returns, all deltas of the payload are marked as applied.
576 Args:
577 rx: API request.
579 Raises:
580 ``gws.NotFoundError``: If the QField project is not found.
581 ``gws.BadRequestError``: If the payload cannot be read.
582 """
583 self.set_qfc_project_from_parts(rx)
585 # deltas come as a multipart file upload
586 try:
587 js = gws.lib.jsonx.from_string(rx.post['file'].stream.read().decode('utf-8'))
588 payload = api.DeltasPayload(
589 deltas=js['deltas'],
590 files=js.get('files', []),
591 id=js['id'],
592 project=js['project'],
593 version=js['version'],
594 )
595 except Exception as exc:
596 raise gws.BadRequestError(f'invalid delta file content: {exc}')
598 self.store_delta_payload(payload, rx)
600 changes = []
601 for d in payload.deltas:
602 new = d.get('new', {})
603 old = d.get('old', {})
604 chg = patcher.Change(
605 uid=d['uuid'],
606 type=d['method'],
607 layerUid=d['localLayerId'],
608 newAtts=new['attributes'] if new else {},
609 oldAtts=old['attributes'] if old else {},
610 wkt=new.get('geometry', ''),
611 )
612 changes.append(chg)
614 args = patcher.Args(
615 qfcProject=rx.qfcProject,
616 caps=self.get_caps(rx.qfcProject),
617 project=rx.project,
618 user=rx.user,
619 baseDir='',
620 changes=changes,
621 )
622 self.get_patcher().apply_changes(self.root, args)
624 self.set_delta_payload_applied(payload.id, rx)
626 @route('GET api/v1/deltas/(?P<project_id>[^/]+)/(?P<payload_id>.+)')
627 def on_get_deltas(self, rx: Request) -> list[api.StoredDelta]:
628 """Return the stored deltas of a payload.
630 Args:
631 rx: API request.
633 Returns:
634 Stored deltas.
636 Raises:
637 ``gws.NotFoundError``: If the QField project is not found.
638 ``gws.NotFoundError``: If the payload is not found.
639 """
640 self.set_qfc_project_from_parts(rx)
642 # the content of the delta does not seem to matter much, only the ID and status=applied
643 # see QField/src/core/qfieldcloud/qfieldcloudproject.cpp : getDeltaStatus()
645 payload_id = rx.parts.get('payload_id', '')
646 sds = self.get_delta_payload(payload_id, rx)
647 if not sds:
648 raise gws.NotFoundError(f'delta {payload_id=} not found')
649 return sds
651 @route('POST api/v1/files/(?P<project_id>[^/]+)/(?P<path>.+)')
652 def on_post_file(self, rx: Request):
653 """Apply a file upload.
655 Args:
656 rx: API request.
658 Raises:
659 ``gws.NotFoundError``: If the QField project is not found.
660 ``gws.BadRequestError``: If the upload cannot be read.
661 """
662 self.set_qfc_project_from_parts(rx)
664 path = rx.parts.get('path', '')
665 try:
666 fc = rx.post['file'].stream.read()
667 except Exception as exc:
668 raise gws.BadRequestError(f'invalid file upload: {exc}')
670 args = patcher.Args(
671 qfcProject=rx.qfcProject,
672 caps=self.get_caps(rx.qfcProject),
673 project=rx.project,
674 user=rx.user,
675 baseDir='',
676 filePath=path,
677 fileContent=fc,
678 )
679 self.get_patcher().apply_upload(self.root, args)
681 ##
683 def get_packager(self) -> packager.Object:
684 """Return a new packager. Override to use a custom packager.
686 Returns:
687 Packager.
688 """
689 return packager.Object()
691 def get_patcher(self) -> patcher.Object:
692 """Return a new patcher. Override to use a custom patcher.
694 Returns:
695 Patcher.
696 """
697 return patcher.Object()
699 ##
701 def get_caps(self, qfc_project: core.QfcProject) -> caps.Caps:
702 """Return the capabilities of a QField project, from the cache if they are still valid.
704 New capabilities are also written to ``caps.pickle`` in the project cache directory.
706 Args:
707 qfc_project: QField project.
709 Returns:
710 Capabilities.
711 """
712 if not hasattr(self, 'capsCache'):
713 self.capsCache = {}
714 cs = self.get_cached_caps(qfc_project)
715 if cs:
716 return cs
718 pa = caps.Parser(qfc_project)
719 pa.parse()
720 gws.u.serialize_to_path(pa.caps, f'{self.fs_project_cache_dir(qfc_project)}/caps.pickle')
721 pa.create_models()
722 pa.assign_path_props()
724 self.capsCache[qfc_project.uid] = pa.caps
725 gws.log.debug(f'get_caps: {qfc_project.uid=}: created')
726 return pa.caps
728 def get_cached_caps(self, qfc_project: core.QfcProject) -> Optional[caps.Caps]:
729 """Return cached capabilities, if the QGIS project source has not changed.
731 Args:
732 qfc_project: QField project.
734 Returns:
735 Capabilities, or ``None`` if not cached or outdated.
736 """
737 cs = self.capsCache.get(qfc_project.uid)
738 if not cs:
739 gws.log.debug(f'get_caps: {qfc_project.uid=}: not found')
740 return
742 qp = qfc_project.qgisProvider.qgis_project()
743 if qp.sourceHash != cs.sourceHash:
744 gws.log.debug(f'get_caps: {qfc_project.uid=}: hash changed: {cs.sourceHash=} != {qp.sourceHash=}')
745 self.capsCache.pop(qfc_project.uid, None)
746 return
748 gws.log.debug(f'get_caps: {qfc_project.uid=}: CACHED!')
749 return cs
751 def authorize_from_credentials(self, credentials: gws.Data, rx: Request):
752 """Authenticate a user and create a session.
754 Sets ``sess``, ``user`` and ``token`` in the request.
756 Args:
757 credentials: Data with ``username`` and ``password``.
758 rx: API request.
760 Raises:
761 ``gws.AuthenticationError``: If the credentials are invalid.
762 """
763 am = self.root.app.authMgr
764 user = am.authenticate(self.method, credentials, rx.req)
765 if not user:
766 raise gws.AuthenticationError('invalid username or password')
767 rx.sess = am.sessionMgr.create(self.method, user)
768 rx.user = user
769 rx.token = rx.sess.uid
771 def authorize_from_token(self, rx: Request):
772 """Authorize a request by the ``Authorization: Token ...`` header.
774 Sets ``sess``, ``user`` and ``token`` in the request and touches the session.
776 Args:
777 rx: API request.
779 Raises:
780 ``gws.AuthenticationError``: If the header is missing, or the token is invalid or belongs to another method.
781 ``gws.ForbiddenError``: If the method cannot be used in this context, e.g. without a secure connection.
782 """
783 h = rx.req.header('Authorization', '')
784 m = re.match(r'^Token (.+)$', h)
785 if not m:
786 raise gws.AuthenticationError('token_auth: missing or invalid Authorization header')
787 token = m.group(1)
788 am = self.root.app.authMgr
789 if not am.can_use_method(rx.req, self.method):
790 raise gws.ForbiddenError('token_auth: insecure_context')
791 sess = am.sessionMgr.get(token)
792 if not sess:
793 raise gws.AuthenticationError('token_auth: invalid or expired token')
794 if not sess.method or sess.method.uid != self.method.uid:
795 raise gws.AuthenticationError(f'token_auth: wrong method {sess.method=}')
796 rx.sess = sess
797 rx.user = sess.user
798 rx.token = sess.uid
799 am.sessionMgr.touch(sess)
800 gws.log.debug(f'token_auth: ok: {rx.token=} {rx.user.uid=} {rx.user.loginName=}')
802 ##
804 def set_qfc_project(self, uid: str, rx: Request):
805 """Set the QField project of a request.
807 Args:
808 uid: QField project uid.
809 rx: API request.
811 Raises:
812 ``gws.NotFoundError``: If the project is not found or the user cannot use it.
813 """
814 qp = self.get_qfc_project(uid, rx.user)
815 if not qp:
816 raise gws.NotFoundError(f'project {uid!r} not found')
817 rx.qfcProject = qp
819 def set_qfc_project_from_parts(self, rx: Request):
820 """Set the QField project of a request from the ``project_id`` path variable.
822 Args:
823 rx: API request.
825 Raises:
826 ``gws.NotFoundError``: If the project is not found or the user cannot use it.
827 """
828 uid = rx.parts.get('project_id', '')
829 self.set_qfc_project(uid, rx)
831 ##
833 def get_qfc_projects(self, user: gws.User) -> list[core.QfcProject]:
834 """Return the QField projects a user can use.
836 Args:
837 user: User.
839 Returns:
840 QField projects.
841 """
842 return [p for p in self.qfcProjects if user.can_use(p)]
844 def get_qfc_project(self, qfc_project_uid: str, user: gws.User) -> Optional[core.QfcProject]:
845 """Return a QField project if the user can use it.
847 Args:
848 qfc_project_uid: QField project uid.
849 user: User.
851 Returns:
852 QField project, or ``None`` if not found.
853 """
854 for qp in self.get_qfc_projects(user):
855 if qp.uid == qfc_project_uid:
856 return qp
858 ##
860 def create_package_job(self, rx: Request) -> gws.Job:
861 """Create and schedule a packaging job for the QField project of a request.
863 Args:
864 rx: API request.
866 Returns:
867 The scheduled job.
868 """
869 mgr = self.root.app.jobMgr
870 p = WorkerPayload(
871 actionUid=self.uid,
872 jobType='package',
873 qfcProjectUid=rx.qfcProject.uid,
874 projectUid=rx.project.uid if rx.project else None,
875 )
876 job = mgr.create_job(
877 PackageWorker,
878 rx.user,
879 payload=gws.u.to_dict(p),
880 )
881 return mgr.schedule_job(job)
883 def create_package_from_worker(self, worker: 'PackageWorker', pa: WorkerPayload):
884 """Create a package in a new package directory. Called by the package worker.
886 Packages older than an hour are removed first.
888 Args:
889 worker: Package worker.
890 pa: Job payload.
891 """
892 project = worker.user.require_project(pa.projectUid) if pa.projectUid else None
893 qfc_project = gws.u.require(self.get_qfc_project(pa.qfcProjectUid, worker.user))
895 self.fs_cleanup_old_packages(qfc_project)
897 uid = dtx.to_basic_string(with_ms=True)
898 pkg_dir = self.fs_new_package_dir(qfc_project, uid)
899 args = packager.Args(
900 uid=uid,
901 qfcProject=qfc_project,
902 caps=self.get_caps(qfc_project),
903 project=project,
904 user=worker.user,
905 packageDir=pkg_dir,
906 mapCacheDir=self.fs_project_cache_dir(qfc_project),
907 withBaseMap=True,
908 withData=True,
909 withMedia=True,
910 withQgis=True,
911 )
912 self.get_packager().create_package(self.root, args)
914 def create_package_from_cli(self, qfc_project_uid: str, target_dir: str, project: Optional[gws.Project], user: gws.User):
915 """Create a package in a given directory.
917 Args:
918 qfc_project_uid: QField project uid.
919 target_dir: Directory to write the package into.
920 project: GWS project context.
921 user: User the package is created for.
923 Raises:
924 ``gws.NotFoundError``: If the QField project is not found.
925 """
926 qfc_project = self.get_qfc_project(qfc_project_uid, user)
927 if not qfc_project:
928 raise gws.NotFoundError(f'project {qfc_project_uid!r} not found')
930 args = packager.Args(
931 uid='cli',
932 qfcProject=qfc_project,
933 caps=self.get_caps(qfc_project),
934 project=project,
935 user=user,
936 packageDir=target_dir,
937 mapCacheDir=self.fs_project_cache_dir(qfc_project),
938 withBaseMap=True,
939 withData=True,
940 withMedia=True,
941 withQgis=True,
942 )
943 self.get_packager().create_package(self.root, args)
945 def get_job(self, job_id: str, project: Optional[gws.Project], user: gws.User) -> Optional[gws.Job]:
946 """Return a job of a user.
948 Args:
949 job_id: Job uid.
950 project: GWS project context (not used).
951 user: User.
953 Returns:
954 The job, or ``None`` if not found.
955 """
956 return self.root.app.jobMgr.get_job(job_id, user=user)
958 ##
960 def store_delta_payload(self, payload: api.DeltasPayload, rx: Request):
961 """Store the deltas of a payload with the pending status.
963 Stored payloads older than an hour are removed first.
965 Args:
966 payload: Delta payload.
967 rx: API request.
968 """
969 self.fs_cleanup_old_deltas(rx.qfcProject)
970 sds = [
971 api.StoredDelta(
972 id=delta['uuid'],
973 deltafile_id=payload.id,
974 created_by=rx.user.loginName,
975 created_at=dtx.to_iso_string(),
976 updated_at=dtx.to_iso_string(),
977 status='STATUS_PENDING',
978 client_id=delta['clientId'],
979 output=None,
980 last_status='pending',
981 last_feedback=None,
982 content=delta,
983 )
984 for delta in payload.deltas
985 ]
986 gws.lib.jsonx.to_path(
987 self.fs_delta_payload_path(rx.qfcProject, payload.id),
988 sds,
989 )
991 def set_delta_payload_applied(self, payload_id: str, rx: Request):
992 """Mark all stored deltas of a payload as applied.
994 Args:
995 payload_id: Payload id.
996 rx: API request.
997 """
998 sds = self.get_delta_payload(payload_id, rx)
999 if not sds:
1000 return
1001 for sd in sds:
1002 sd.status = 'STATUS_APPLIED'
1003 sd.last_status = 'applied'
1004 sd.updated_at = dtx.to_iso_string()
1005 gws.lib.jsonx.to_path(
1006 self.fs_delta_payload_path(rx.qfcProject, payload_id),
1007 sds,
1008 )
1010 def get_delta_payload(self, payload_id: str, rx: Request) -> Optional[list[api.StoredDelta]]:
1011 """Load the stored deltas of a payload.
1013 Args:
1014 payload_id: Payload id.
1015 rx: API request.
1017 Returns:
1018 Stored deltas, or ``None`` if not found, unreadable or stored by another user.
1019 """
1020 path = self.fs_delta_payload_path(rx.qfcProject, payload_id)
1021 if not os.path.exists(path):
1022 gws.log.warning(f'stored delta {payload_id=}: not found: {path=}')
1023 return
1024 try:
1025 sds = [api.StoredDelta(d) for d in gws.lib.jsonx.from_path(path)]
1026 except Exception as exc:
1027 gws.log.warning(f'stored delta {payload_id=}: failed to load {path=}: {exc}')
1028 return
1029 for sd in sds:
1030 if sd.created_by != rx.user.loginName:
1031 gws.log.warning(f'stored delta {payload_id=}: user mismatch: {sd.created_by=} != {rx.user.loginName=}')
1032 return
1033 return sds
1035 ##
1037 def fs_project_base_dir(self, qfc_project: core.QfcProject) -> str:
1038 """Return the base directory of a QField project, creating it if needed.
1040 The directory is ``<VAR_DIR>/qfieldcloud/projects/<uid>``. Packages, caches
1041 and stored deltas are kept below it.
1043 Args:
1044 qfc_project: QField project.
1046 Returns:
1047 Directory path.
1048 """
1049 return gws.u.ensure_dir(f'{gws.c.VAR_DIR}/qfieldcloud/projects/{qfc_project.uid}')
1051 def fs_latest_package_dir(self, qfc_project: core.QfcProject) -> Optional[str]:
1052 """Return the directory of the latest complete package.
1054 Args:
1055 qfc_project: QField project.
1057 Returns:
1058 Directory path, or ``None`` if there is no complete package.
1059 """
1060 base_dir = self.fs_project_base_dir(qfc_project)
1061 for pkg in sorted(osx.find_directories(base_dir, deep=False), reverse=True):
1062 m = re.search(r'package_(\d+)', pkg)
1063 if m and gws.u.is_file(f'{pkg}/{packager.COMPLETE_FILE}'):
1064 return pkg
1066 def fs_new_package_dir(self, qfc_project: core.QfcProject, uid: str) -> str:
1067 """Create a directory for a new package.
1069 Args:
1070 qfc_project: QField project.
1071 uid: Package uid.
1073 Returns:
1074 Directory path.
1075 """
1076 base_dir = self.fs_project_base_dir(qfc_project)
1077 pkg_dir = gws.u.ensure_dir(f'{base_dir}/package_{uid}')
1078 return pkg_dir
1080 def fs_cleanup_old_packages(self, qfc_project: core.QfcProject, keep_seconds: int = 3600):
1081 """Remove package directories older than ``keep_seconds``.
1083 Args:
1084 qfc_project: QField project.
1085 keep_seconds: Maximum age in seconds.
1086 """
1087 base_dir = self.fs_project_base_dir(qfc_project)
1088 now = dtx.now().timestamp()
1089 for pkg in osx.find_directories(base_dir, deep=False):
1090 m = re.search(r'package_(\d+)', pkg)
1091 if not m:
1092 continue
1093 t = osx.file_mtime(pkg)
1094 if now - t > keep_seconds:
1095 gws.log.info(f'fs_cleanup_old_packages: removing old package: {pkg=}')
1096 osx.rmdir(pkg)
1098 def fs_project_cache_dir(self, qfc_project: core.QfcProject) -> str:
1099 """Return the cache directory of a QField project, creating it if needed.
1101 Args:
1102 qfc_project: QField project.
1104 Returns:
1105 Directory path.
1106 """
1107 base_dir = self.fs_project_base_dir(qfc_project)
1108 return gws.u.ensure_dir(f'{base_dir}/cache')
1110 def fs_project_deltas_dir(self, qfc_project: core.QfcProject) -> str:
1111 """Return the deltas directory of a QField project, creating it if needed.
1113 Args:
1114 qfc_project: QField project.
1116 Returns:
1117 Directory path.
1118 """
1119 base_dir = self.fs_project_base_dir(qfc_project)
1120 return gws.u.ensure_dir(f'{base_dir}/deltas')
1122 def fs_delta_payload_path(self, qfc_project: core.QfcProject, payload_id: str) -> str:
1123 """Return the path of a stored delta payload.
1125 Args:
1126 qfc_project: QField project.
1127 payload_id: Payload id.
1129 Returns:
1130 File path.
1131 """
1132 d = self.fs_project_deltas_dir(qfc_project)
1133 u = gws.u.to_uid(payload_id)
1134 return f'{d}/{u}.json'
1136 def fs_cleanup_old_deltas(self, qfc_project: core.QfcProject, keep_seconds: int = 3600):
1137 """Remove stored delta payloads older than ``keep_seconds``.
1139 Args:
1140 qfc_project: QField project.
1141 keep_seconds: Maximum age in seconds.
1142 """
1143 d = self.fs_project_deltas_dir(qfc_project)
1144 now = dtx.now().timestamp()
1145 for f in osx.find_files(d, deep=False):
1146 t = osx.file_mtime(f)
1147 if now - t > keep_seconds:
1148 gws.log.info(f'fs_cleanup_old_deltas: removing old delta: {f=}')
1149 osx.unlink(f)
1151 def get_latest_package_path_map(self, rx: Request) -> dict[str, str]:
1152 """Return the path map of the latest package of the request's QField project.
1154 Args:
1155 rx: API request.
1157 Returns:
1158 Paths on disk by package file name, empty if there is no package.
1159 """
1160 d = self.fs_latest_package_dir(rx.qfcProject)
1161 try:
1162 return gws.lib.jsonx.from_path(f'{d}/{packager.PATH_MAP_FILE}')
1163 except Exception:
1164 return {}
1167##
1170class PackageWorker(gws.base.job.worker.Object):
1171 """Job worker that creates a package."""
1173 @classmethod
1174 def run(cls, root: gws.Root, job: gws.Job):
1175 """Run a packaging job.
1177 Args:
1178 root: Root object.
1179 job: The job.
1180 """
1181 w = cls(root, job.user, job)
1182 w.work()
1184 def work(self):
1185 """Create the package and mark the job as complete."""
1186 self.update_job(state=gws.JobState.running)
1187 pa = WorkerPayload(gws.u.require(self.get_job()).payload)
1188 action = cast(Object, self.root.get(pa.actionUid))
1189 action.create_package_from_worker(self, pa)
1190 self.update_job(state=gws.JobState.complete)
1193##
1196_DATE_CREATED = '2025-10-10T14:00:00'
1199def _error_response(status: int, code: str, exc: Exception) -> gws.ContentResponse:
1200 """Log an error and return a JSON error response."""
1201 gws.log.warning(f'qfieldcloudApi: {status} {code} cause={exc!r}')
1202 return gws.ContentResponse(
1203 status=status,
1204 content=gws.lib.jsonx.to_string({'code': code}),
1205 mimeType=gws.lib.mime.JSON,
1206 )
1209def _format_project(qp: core.QfcProject, rx: Request) -> api.Project:
1210 """Convert a QField project to an API project."""
1211 return api.Project(
1212 id=qp.uid,
1213 name=qp.title,
1214 owner=rx.user.loginName,
1215 description='',
1216 private=True,
1217 is_public=False,
1218 created_at=_DATE_CREATED,
1219 updated_at=dtx.to_iso_string(),
1220 data_last_packaged_at=None,
1221 data_last_updated_at=dtx.to_iso_string(),
1222 can_repackage=True,
1223 needs_repackaging=True,
1224 status='ok',
1225 user_role='admin',
1226 user_role_origin='project_owner',
1227 shared_datasets_project_id=None,
1228 is_shared_datasets_project=False,
1229 is_featured=False,
1230 is_attachment_download_on_demand=False,
1231 )
1234def _format_files(path_map: dict[str, str]):
1235 """Convert a package path map to a list of API package files."""
1236 return [
1237 api.PackageFile(
1238 name=fname,
1239 size=osx.file_size(p),
1240 uploaded_at=_get_time_iso(p),
1241 is_attachment=False,
1242 md5sum=_get_md5sum(p),
1243 last_modified=_get_time_iso(p),
1244 sha256=_get_sha256(p),
1245 )
1246 for fname, p in path_map.items()
1247 ]
1250def _format_job(job: gws.Job, rx: Request) -> api.Job:
1251 """Convert a GWS job to an API job."""
1252 status_map = {
1253 gws.JobState.open: api.JobStatusEnum.pending,
1254 gws.JobState.running: api.JobStatusEnum.started,
1255 gws.JobState.complete: api.JobStatusEnum.finished,
1256 gws.JobState.error: api.JobStatusEnum.failed,
1257 }
1259 return api.Job(
1260 id=job.uid,
1261 type=job.payload.get('jobType', ''),
1262 created_at=dtx.to_iso_string(job.timeCreated),
1263 created_by=1,
1264 project_id=job.payload.get('qfcProjectUid', ''),
1265 status=status_map.get(job.state, api.JobStatusEnum.pending),
1266 updated_at=dtx.to_iso_string(job.timeUpdated),
1267 started_at=dtx.to_iso_string(job.timeUpdated) if job.state == gws.JobState.running else None,
1268 finished_at=dtx.to_iso_string(job.timeUpdated) if job.state == gws.JobState.complete else None,
1269 )
1272def _get_sha256(path: str) -> str:
1273 """Return the SHA-256 hash of a file."""
1274 with open(path, 'rb') as f:
1275 return hashlib.file_digest(f, 'sha256').hexdigest()
1278def _get_time_iso(path: str) -> str:
1279 """Return the modification time of a file as an ISO string."""
1280 t = osx.file_mtime(path)
1281 return dtx.to_iso_string(dtx.from_timestamp(t))
1284def _get_md5sum(path: str) -> str:
1285 """Return the MD5 hash of a file, as computed by QField."""
1286 with open(path, 'rb') as f:
1287 return _get_md5sum_file(f)
1290def _get_md5sum_file(fp, part_size: int = 8 * 1024 * 1024) -> str:
1291 """Return a plain MD5 for small files, or an S3-style multipart ETag for larger ones, as QField's ``fileEtag``."""
1292 fp.seek(0, 2)
1293 file_size = fp.tell()
1294 fp.seek(0)
1296 if file_size <= part_size:
1297 hash = hashlib.md5()
1298 hash.update(fp.read())
1299 return hash.hexdigest()
1301 md5_sums = b''
1302 read_size = 0
1304 while read_size < file_size:
1305 hash = hashlib.md5()
1306 hash.update(fp.read(part_size))
1307 md5_sums += hash.digest()
1308 read_size += part_size
1310 hash = hashlib.md5()
1311 hash.update(md5_sums)
1312 return f'{hash.hexdigest()}-{read_size // part_size}'