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

1"""QField Cloud API action.""" 

2 

3from typing import Optional, cast 

4 

5import re 

6import os 

7import hashlib 

8 

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 

18 

19from . import core, packager, patcher, api, caps 

20 

21 

22@gws.ext.config.action('qfieldcloud') 

23class Config(gws.ConfigWithAccess): 

24 """Endpoint for QField clients that emulates the QFieldCloud API.""" 

25 

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.""" 

30 

31 

32@gws.ext.props.action('qfieldcloud') 

33class Props(gws.base.action.Props): 

34 pass 

35 

36 

37class Request(gws.Data): 

38 """An API request, passed to the route handlers.""" 

39 

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.""" 

60 

61 

62class WorkerPayload(gws.Data): 

63 """Job payload for the package worker.""" 

64 

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.""" 

73 

74 

75def route(pattern: str): 

76 """Decorator that marks a method as an API route handler. 

77 

78 Args: 

79 pattern: Regular expression matched against ``"<METHOD> <path>"``. Named groups are passed to the handler in ``Request.parts``. 

80 

81 Returns: 

82 The decorator. 

83 """ 

84 def decorator(fn): 

85 fn._route_pattern = pattern 

86 return fn 

87 

88 return decorator 

89 

90 

91@gws.ext.object.action('qfieldcloud') 

92class Object(gws.base.action.Object): 

93 """QField Cloud API action. 

94 

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 """ 

99 

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.""" 

106 

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)) 

115 

116 def __getstate__(self): 

117 """Return the state for pickling, without the caps cache.""" 

118 return gws.u.omit(vars(self), 'capsCache') 

119 

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) 

133 

134 def dispatch_request(self, req: gws.WebRequester, p: gws.Request) -> gws.ContentResponse: 

135 """Find the route handler for a request and call it. 

136 

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. 

139 

140 Args: 

141 req: Web requester. 

142 p: Command request. 

143 

144 Returns: 

145 The response. 

146 

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') 

154 

155 path_parts = path.split('/') 

156 uid = p.get('projectUid') 

157 

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:]) 

163 

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) 

168 

169 path = path.strip('/') 

170 route = f'{req.method} {path}' 

171 

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) 

186 

187 raise gws.NotFoundError(f'API {route=} not found') 

188 

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 ) 

195 

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()) 

204 

205 gws.log.debug(f'API_REQUEST {rx.route=} -> {fn.__name__} {rx=}') 

206 

207 if rx.route not in self._public_routes: 

208 self.authorize_from_token(rx) 

209 

210 res = fn(rx) 

211 

212 if res is None: 

213 return gws.ContentResponse(content='') 

214 

215 if isinstance(res, gws.ContentResponse): 

216 return res 

217 

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 ) 

223 

224 raise gws.Error(f'API {rx.route=} invalid response type: {type(res)}') 

225 

226 ## 

227 

228 @route('POST api/v1/auth/logout') 

229 def on_post_auth_logout(self, rx: Request): 

230 """Log out, deleting the session. 

231 

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=}') 

238 

239 @route('GET api/v1/auth/providers') 

240 def on_get_auth_providers(self, rx: Request) -> list[api.AuthProvider]: 

241 """Return the authentication providers. 

242 

243 Args: 

244 rx: API request. 

245 

246 Returns: 

247 A single username and password provider. 

248 """ 

249 return [ 

250 api.AuthProvider(type='credentials', id='credentials', name='Username / Password'), 

251 ] 

252 

253 @route('GET api/v1/server/info') 

254 def on_get_server_info(self, rx: Request) -> api.ServerInfo: 

255 """Return the server information. 

256 

257 Args: 

258 rx: API request. 

259 

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 ) 

269 

270 @route('GET api/v1/status') 

271 def on_get_status(self, rx: Request) -> api.Status: 

272 """Return the server status. 

273 

274 Args: 

275 rx: API request. 

276 

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 ) 

291 

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. 

295 

296 Args: 

297 rx: API request. 

298 

299 Returns: 

300 An empty list, organizations are not supported. 

301 """ 

302 return [] 

303 

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. 

307 

308 Args: 

309 rx: API request. 

310 

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 ) 

322 

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. 

326 

327 Args: 

328 rx: API request. 

329 

330 Returns: 

331 Session token and user information. 

332 

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 ) 

355 

356 @route('GET api/v1/auth/user') 

357 def on_get_auth_user(self, rx: Request) -> api.CompleteUser: 

358 """Return the authenticated user. 

359 

360 Args: 

361 rx: API request. 

362 

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 ) 

375 

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. 

379 

380 Args: 

381 rx: API request. 

382 

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]] 

390 

391 @route('POST api/v1/projects') 

392 def on_post_projects(self, rx: Request): 

393 """Create a project. Not supported. 

394 

395 Args: 

396 rx: API request. 

397 

398 Raises: 

399 ``gws.BadRequestError``: Always. 

400 """ 

401 raise gws.BadRequestError('creating projects is not supported') 

402 

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. 

406 

407 Args: 

408 rx: API request. 

409 

410 Returns: 

411 Project. 

412 

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) 

418 

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. 

422 

423 Args: 

424 rx: API request. 

425 

426 Returns: 

427 The scheduled job. 

428 

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}') 

438 

439 job = self.create_package_job(rx) 

440 return _format_job(job, rx) 

441 

442 @route('GET api/v1/jobs') 

443 def on_get_jobs(self, rx: Request): 

444 """List jobs. Not supported. 

445 

446 Args: 

447 rx: API request. 

448 

449 Raises: 

450 ``gws.BadRequestError``: Always. 

451 """ 

452 raise gws.BadRequestError('listing jobs is not supported') 

453 

454 @route('GET api/v1/jobs/(?P<job_id>[^/]+)') 

455 def on_get_jobs_id(self, rx: Request) -> api.Job: 

456 """Return a job. 

457 

458 Args: 

459 rx: API request. 

460 

461 Returns: 

462 Job. 

463 

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) 

472 

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. 

476 

477 Args: 

478 rx: API request. 

479 

480 Returns: 

481 Package with its files. The package version is ignored. 

482 

483 Raises: 

484 ``gws.NotFoundError``: If the QField project is not found. 

485 """ 

486 self.set_qfc_project_from_parts(rx) 

487 

488 # @TODO do we need versions? 

489 # @TODO do we need layers? 

490 # package_version = rx.parts.get('package_version', '') 

491 

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 ) 

501 

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. 

505 

506 Args: 

507 rx: API request. 

508 

509 Returns: 

510 File content. A request with a ``Range`` header gets status 416, so that QField downloads the full file. 

511 

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) 

517 

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') 

532 

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. 

536 

537 Args: 

538 rx: API request. 

539 

540 Returns: 

541 Package files. 

542 

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) 

549 

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. 

553 

554 Args: 

555 rx: API request. 

556 

557 Returns: 

558 Thumbnail image. 

559 

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) 

569 

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. 

573 

574 The payload is uploaded as a multipart file. After the patcher returns, all deltas of the payload are marked as applied. 

575 

576 Args: 

577 rx: API request. 

578 

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) 

584 

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}') 

597 

598 self.store_delta_payload(payload, rx) 

599 

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) 

613 

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) 

623 

624 self.set_delta_payload_applied(payload.id, rx) 

625 

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. 

629 

630 Args: 

631 rx: API request. 

632 

633 Returns: 

634 Stored deltas. 

635 

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) 

641 

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() 

644 

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 

650 

651 @route('POST api/v1/files/(?P<project_id>[^/]+)/(?P<path>.+)') 

652 def on_post_file(self, rx: Request): 

653 """Apply a file upload. 

654 

655 Args: 

656 rx: API request. 

657 

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) 

663 

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}') 

669 

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) 

680 

681 ## 

682 

683 def get_packager(self) -> packager.Object: 

684 """Return a new packager. Override to use a custom packager. 

685 

686 Returns: 

687 Packager. 

688 """ 

689 return packager.Object() 

690 

691 def get_patcher(self) -> patcher.Object: 

692 """Return a new patcher. Override to use a custom patcher. 

693 

694 Returns: 

695 Patcher. 

696 """ 

697 return patcher.Object() 

698 

699 ## 

700 

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. 

703 

704 New capabilities are also written to ``caps.pickle`` in the project cache directory. 

705 

706 Args: 

707 qfc_project: QField project. 

708 

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 

717 

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() 

723 

724 self.capsCache[qfc_project.uid] = pa.caps 

725 gws.log.debug(f'get_caps: {qfc_project.uid=}: created') 

726 return pa.caps 

727 

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. 

730 

731 Args: 

732 qfc_project: QField project. 

733 

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 

741 

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 

747 

748 gws.log.debug(f'get_caps: {qfc_project.uid=}: CACHED!') 

749 return cs 

750 

751 def authorize_from_credentials(self, credentials: gws.Data, rx: Request): 

752 """Authenticate a user and create a session. 

753 

754 Sets ``sess``, ``user`` and ``token`` in the request. 

755 

756 Args: 

757 credentials: Data with ``username`` and ``password``. 

758 rx: API request. 

759 

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 

770 

771 def authorize_from_token(self, rx: Request): 

772 """Authorize a request by the ``Authorization: Token ...`` header. 

773 

774 Sets ``sess``, ``user`` and ``token`` in the request and touches the session. 

775 

776 Args: 

777 rx: API request. 

778 

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=}') 

801 

802 ## 

803 

804 def set_qfc_project(self, uid: str, rx: Request): 

805 """Set the QField project of a request. 

806 

807 Args: 

808 uid: QField project uid. 

809 rx: API request. 

810 

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 

818 

819 def set_qfc_project_from_parts(self, rx: Request): 

820 """Set the QField project of a request from the ``project_id`` path variable. 

821 

822 Args: 

823 rx: API request. 

824 

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) 

830 

831 ## 

832 

833 def get_qfc_projects(self, user: gws.User) -> list[core.QfcProject]: 

834 """Return the QField projects a user can use. 

835 

836 Args: 

837 user: User. 

838 

839 Returns: 

840 QField projects. 

841 """ 

842 return [p for p in self.qfcProjects if user.can_use(p)] 

843 

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. 

846 

847 Args: 

848 qfc_project_uid: QField project uid. 

849 user: User. 

850 

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 

857 

858 ## 

859 

860 def create_package_job(self, rx: Request) -> gws.Job: 

861 """Create and schedule a packaging job for the QField project of a request. 

862 

863 Args: 

864 rx: API request. 

865 

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) 

882 

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. 

885 

886 Packages older than an hour are removed first. 

887 

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)) 

894 

895 self.fs_cleanup_old_packages(qfc_project) 

896 

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) 

913 

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. 

916 

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. 

922 

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') 

929 

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) 

944 

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. 

947 

948 Args: 

949 job_id: Job uid. 

950 project: GWS project context (not used). 

951 user: User. 

952 

953 Returns: 

954 The job, or ``None`` if not found. 

955 """ 

956 return self.root.app.jobMgr.get_job(job_id, user=user) 

957 

958 ## 

959 

960 def store_delta_payload(self, payload: api.DeltasPayload, rx: Request): 

961 """Store the deltas of a payload with the pending status. 

962 

963 Stored payloads older than an hour are removed first. 

964 

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 ) 

990 

991 def set_delta_payload_applied(self, payload_id: str, rx: Request): 

992 """Mark all stored deltas of a payload as applied. 

993 

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 ) 

1009 

1010 def get_delta_payload(self, payload_id: str, rx: Request) -> Optional[list[api.StoredDelta]]: 

1011 """Load the stored deltas of a payload. 

1012 

1013 Args: 

1014 payload_id: Payload id. 

1015 rx: API request. 

1016 

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 

1034 

1035 ## 

1036 

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. 

1039 

1040 The directory is ``<VAR_DIR>/qfieldcloud/projects/<uid>``. Packages, caches 

1041 and stored deltas are kept below it. 

1042 

1043 Args: 

1044 qfc_project: QField project. 

1045 

1046 Returns: 

1047 Directory path. 

1048 """ 

1049 return gws.u.ensure_dir(f'{gws.c.VAR_DIR}/qfieldcloud/projects/{qfc_project.uid}') 

1050 

1051 def fs_latest_package_dir(self, qfc_project: core.QfcProject) -> Optional[str]: 

1052 """Return the directory of the latest complete package. 

1053 

1054 Args: 

1055 qfc_project: QField project. 

1056 

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 

1065 

1066 def fs_new_package_dir(self, qfc_project: core.QfcProject, uid: str) -> str: 

1067 """Create a directory for a new package. 

1068 

1069 Args: 

1070 qfc_project: QField project. 

1071 uid: Package uid. 

1072 

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 

1079 

1080 def fs_cleanup_old_packages(self, qfc_project: core.QfcProject, keep_seconds: int = 3600): 

1081 """Remove package directories older than ``keep_seconds``. 

1082 

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) 

1097 

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. 

1100 

1101 Args: 

1102 qfc_project: QField project. 

1103 

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') 

1109 

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. 

1112 

1113 Args: 

1114 qfc_project: QField project. 

1115 

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') 

1121 

1122 def fs_delta_payload_path(self, qfc_project: core.QfcProject, payload_id: str) -> str: 

1123 """Return the path of a stored delta payload. 

1124 

1125 Args: 

1126 qfc_project: QField project. 

1127 payload_id: Payload id. 

1128 

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' 

1135 

1136 def fs_cleanup_old_deltas(self, qfc_project: core.QfcProject, keep_seconds: int = 3600): 

1137 """Remove stored delta payloads older than ``keep_seconds``. 

1138 

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) 

1150 

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. 

1153 

1154 Args: 

1155 rx: API request. 

1156 

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 {} 

1165 

1166 

1167## 

1168 

1169 

1170class PackageWorker(gws.base.job.worker.Object): 

1171 """Job worker that creates a package.""" 

1172 

1173 @classmethod 

1174 def run(cls, root: gws.Root, job: gws.Job): 

1175 """Run a packaging job. 

1176 

1177 Args: 

1178 root: Root object. 

1179 job: The job. 

1180 """ 

1181 w = cls(root, job.user, job) 

1182 w.work() 

1183 

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) 

1191 

1192 

1193## 

1194 

1195 

1196_DATE_CREATED = '2025-10-10T14:00:00' 

1197 

1198 

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 ) 

1207 

1208 

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 ) 

1232 

1233 

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 ] 

1248 

1249 

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 } 

1258 

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 ) 

1270 

1271 

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() 

1276 

1277 

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)) 

1282 

1283 

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) 

1288 

1289 

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) 

1295 

1296 if file_size <= part_size: 

1297 hash = hashlib.md5() 

1298 hash.update(fp.read()) 

1299 return hash.hexdigest() 

1300 

1301 md5_sums = b'' 

1302 read_size = 0 

1303 

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 

1309 

1310 hash = hashlib.md5() 

1311 hash.update(md5_sums) 

1312 return f'{hash.hexdigest()}-{read_size // part_size}'