Coverage for gws-app/gws/plugin/qfieldcloud/action_base.py: 98%
137 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 action: projects, capabilities, packaging and directories."""
3from typing import Optional, cast
5import re
7import gws
8import gws.base.job
9import gws.base.action
10import gws.lib.jsonx
11import gws.lib.datetimex as dtx
12import gws.lib.osx as osx
14from . import core, packager, patcher, caps, auth
17class WorkerPayload(gws.Data):
18 """Job payload for the package worker."""
20 actionUid: str
21 """Uid of the action."""
22 jobType: str
23 """Job type, ``package``."""
24 qfcProjectUid: str
25 """QField project uid."""
26 projectUid: Optional[str]
27 """GWS project uid."""
30class BaseAction(gws.base.action.Object):
31 """Base of the QField Cloud API action.
33 Holds the configured QField projects and the authorization method, caches
34 the project capabilities, creates packages and manages the project directories.
35 Request handling is done by ``action_handler.Handler``.
36 """
38 qfcProjects: list[core.QfcProject]
39 """Configured QField projects."""
40 capsCache: dict[str, caps.Caps]
41 """Capabilities by QField project uid. Not serialized."""
42 authMethod: auth.Object
43 """Authorization method for QField clients."""
44 deltaLifeTime: int
45 """How long stored delta payloads are kept, in seconds."""
47 def configure(self):
48 self.deltaLifeTime = self.cfg('deltaLifeTime', default=3600 * 24 * 30)
49 self.authMethod = cast(
50 auth.Object,
51 self.create_child(gws.ext.object.authMethod, self.cfg('auth'), type='qfieldcloud'),
52 )
53 self.root.app.authMgr.add_method(self.authMethod)
54 self.qfcProjects = []
55 for p in self.cfg('projects') or []:
56 qp = self.create_child(core.QfcProject, p)
57 if qp:
58 self.qfcProjects.append(cast(core.QfcProject, qp))
60 def __getstate__(self):
61 """Return the state for pickling, without the caps cache."""
62 return gws.u.omit(vars(self), 'capsCache')
64 ##
66 def get_packager(self) -> packager.Object:
67 """Return a new packager. Override to use a custom packager.
69 Returns:
70 Packager.
71 """
72 return packager.Object()
74 def get_patcher(self) -> patcher.Object:
75 """Return a new patcher. Override to use a custom patcher.
77 Returns:
78 Patcher.
79 """
80 return patcher.Object()
82 ##
84 def get_caps(self, qfc_project: core.QfcProject) -> caps.Caps:
85 """Return the capabilities of a QField project, from the cache if they are still valid.
87 Args:
88 qfc_project: QField project.
90 Returns:
91 Capabilities.
92 """
93 if not hasattr(self, 'capsCache'):
94 self.capsCache = {}
95 cs = self.get_cached_caps(qfc_project)
96 if cs:
97 return cs
99 pa = caps.Parser(qfc_project)
100 pa.parse()
101 pa.create_models()
102 pa.assign_path_props()
104 self.capsCache[qfc_project.uid] = pa.caps
105 gws.log.debug(f'get_caps: {qfc_project.uid=}: created')
106 return pa.caps
108 def get_db_provider(self, qfc_project: core.QfcProject) -> gws.DatabaseProvider:
109 """Return the database provider of the models of a QField project.
111 Args:
112 qfc_project: QField project.
114 Returns:
115 Database provider.
117 Raises:
118 ``gws.Error``: If the project has no models, or its models use more than one database provider.
119 """
120 cs = self.get_caps(qfc_project)
122 db = None
123 uids = set()
124 for me in cs.modelMap.values():
125 db = me.model.db
126 uids.add(me.model.db.uid)
128 if len(uids) != 1 or db is None:
129 raise gws.Error(f'QField project {qfc_project.uid!r}: expected one database provider, found {sorted(uids)}')
131 return db
133 def get_cached_caps(self, qfc_project: core.QfcProject) -> Optional[caps.Caps]:
134 """Return cached capabilities, if the QGIS project source has not changed.
136 Args:
137 qfc_project: QField project.
139 Returns:
140 Capabilities, or ``None`` if not cached or outdated.
141 """
142 cs = self.capsCache.get(qfc_project.uid)
143 if not cs:
144 gws.log.debug(f'get_caps: {qfc_project.uid=}: not found')
145 return
147 qp = qfc_project.qgisProvider.qgis_project()
148 if qp.sourceHash != cs.sourceHash:
149 gws.log.debug(f'get_caps: {qfc_project.uid=}: hash changed: {cs.sourceHash=} != {qp.sourceHash=}')
150 self.capsCache.pop(qfc_project.uid, None)
151 return
153 gws.log.debug(f'get_caps: {qfc_project.uid=}: CACHED!')
154 return cs
156 ##
158 def get_qfc_projects(self, user: gws.User) -> list[core.QfcProject]:
159 """Return the QField projects a user can use.
161 Args:
162 user: User.
164 Returns:
165 QField projects.
166 """
167 return [p for p in self.qfcProjects if user.can_use(p)]
169 def get_qfc_project(self, qfc_project_uid: str, user: gws.User) -> Optional[core.QfcProject]:
170 """Return a QField project if the user can use it.
172 Args:
173 qfc_project_uid: QField project uid.
174 user: User.
176 Returns:
177 QField project, or ``None`` if not found.
178 """
179 for qp in self.get_qfc_projects(user):
180 if qp.uid == qfc_project_uid:
181 return qp
183 ##
185 def create_package_from_worker(self, worker: 'PackageWorker', pa: WorkerPayload):
186 """Create a package in a new package directory. Called by the package worker.
188 Packages older than an hour are removed first.
190 Args:
191 worker: Package worker.
192 pa: Job payload.
193 """
194 project = worker.user.require_project(pa.projectUid) if pa.projectUid else None
195 qfc_project = gws.u.require(self.get_qfc_project(pa.qfcProjectUid, worker.user))
197 self.cleanup_old_packages(qfc_project)
199 uid = dtx.to_basic_string(with_ms=True)
200 pkg_dir = self.new_package_dir(qfc_project, uid)
201 args = packager.Args(
202 uid=uid,
203 qfcProject=qfc_project,
204 caps=self.get_caps(qfc_project),
205 project=project,
206 user=worker.user,
207 packageDir=pkg_dir,
208 cacheDir=self.project_cache_dir(qfc_project),
209 withBaseMap=True,
210 withData=True,
211 withMedia=True,
212 withQgis=True,
213 )
214 self.get_packager().create_package(self.root, args)
216 def create_package_from_cli(self, qfc_project_uid: str, target_dir: str, project: Optional[gws.Project], user: gws.User):
217 """Create a package in a given directory.
219 Args:
220 qfc_project_uid: QField project uid.
221 target_dir: Directory to write the package into.
222 project: GWS project context.
223 user: User the package is created for.
225 Raises:
226 ``gws.NotFoundError``: If the QField project is not found.
227 """
228 qfc_project = self.get_qfc_project(qfc_project_uid, user)
229 if not qfc_project:
230 raise gws.NotFoundError(f'project {qfc_project_uid!r} not found')
232 args = packager.Args(
233 uid='cli',
234 qfcProject=qfc_project,
235 caps=self.get_caps(qfc_project),
236 project=project,
237 user=user,
238 packageDir=target_dir,
239 cacheDir=self.project_cache_dir(qfc_project),
240 withBaseMap=True,
241 withData=True,
242 withMedia=True,
243 withQgis=True,
244 )
245 self.get_packager().create_package(self.root, args)
247 def get_job(self, job_id: str, user: gws.User) -> Optional[gws.Job]:
248 """Return a job of a user.
250 Args:
251 job_id: Job uid.
252 user: User.
254 Returns:
255 The job, or ``None`` if not found.
256 """
257 return self.root.app.jobMgr.get_job(job_id, user=user)
259 ##
261 def project_base_dir(self, qfc_project: core.QfcProject) -> str:
262 """Return the base directory of a QField project, creating it if needed.
264 The directory is ``<VAR_DIR>/qfieldcloud/projects/<uid>``. Packages, caches
265 and stored deltas are kept below it.
267 Args:
268 qfc_project: QField project.
270 Returns:
271 Directory path.
272 """
273 return gws.u.ensure_dir(f'{gws.c.VAR_DIR}/qfieldcloud/projects/{qfc_project.uid}')
275 def latest_package_dir(self, qfc_project: core.QfcProject) -> Optional[str]:
276 """Return the directory of the latest complete package.
278 Args:
279 qfc_project: QField project.
281 Returns:
282 Directory path, or ``None`` if there is no complete package.
283 """
284 base_dir = self.project_base_dir(qfc_project)
285 for pkg in sorted(osx.find_directories(base_dir, deep=False), reverse=True):
286 m = re.search(r'package_(\d+)', pkg)
287 if m and gws.u.is_file(f'{pkg}/{packager.COMPLETE_FILE}'):
288 return pkg
290 def new_package_dir(self, qfc_project: core.QfcProject, uid: str) -> str:
291 """Create a directory for a new package.
293 Args:
294 qfc_project: QField project.
295 uid: Package uid.
297 Returns:
298 Directory path.
299 """
300 base_dir = self.project_base_dir(qfc_project)
301 pkg_dir = gws.u.ensure_dir(f'{base_dir}/package_{uid}')
302 return pkg_dir
304 def cleanup_old_packages(self, qfc_project: core.QfcProject, keep_seconds: int = 3600):
305 """Remove package directories older than ``keep_seconds``.
307 Args:
308 qfc_project: QField project.
309 keep_seconds: Maximum age in seconds.
310 """
311 base_dir = self.project_base_dir(qfc_project)
312 now = dtx.now().timestamp()
313 for pkg in osx.find_directories(base_dir, deep=False):
314 m = re.search(r'package_(\d+)', pkg)
315 if not m:
316 continue
317 t = osx.file_mtime(pkg)
318 if now - t > keep_seconds:
319 gws.log.info(f'cleanup_old_packages: removing old package: {pkg=}')
320 osx.rmdir(pkg)
322 def latest_package_path_map(self, qfc_project: core.QfcProject) -> dict[str, str]:
323 """Return the path map of the latest package of a QField project.
325 Args:
326 qfc_project: QField project.
328 Returns:
329 Paths on disk by package file name, empty if there is no package.
330 """
331 d = self.latest_package_dir(qfc_project)
332 try:
333 return gws.lib.jsonx.from_path(f'{d}/{packager.PATH_MAP_FILE}')
334 except Exception:
335 return {}
337 def project_cache_dir(self, qfc_project: core.QfcProject) -> str:
338 """Return the cache directory of a QField project, creating it if needed.
340 Args:
341 qfc_project: QField project.
343 Returns:
344 Directory path.
345 """
346 base_dir = self.project_base_dir(qfc_project)
347 return gws.u.ensure_dir(f'{base_dir}/cache')
349 def deltas_db_path(self, qfc_project: core.QfcProject) -> str:
350 """Return the path of the delta database of a QField project.
352 Args:
353 qfc_project: QField project.
355 Returns:
356 File path.
357 """
358 base_dir = self.project_base_dir(qfc_project)
359 return f'{base_dir}/deltas.sqlite'
362##
365class PackageWorker(gws.base.job.worker.Object):
366 """Job worker that creates a package."""
368 @classmethod
369 def run(cls, root: gws.Root, job: gws.Job):
370 """Run a packaging job.
372 Args:
373 root: Root object.
374 job: The job.
375 """
376 w = cls(root, job.user, job)
377 w.work()
379 def work(self):
380 """Create the package and mark the job as complete."""
381 self.update_job(state=gws.JobState.running)
382 pa = WorkerPayload(gws.u.require(self.get_job()).payload)
383 action = cast(BaseAction, self.root.get(pa.actionUid))
384 action.create_package_from_worker(self, pa)
385 self.update_job(state=gws.JobState.complete)