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

1from typing import Optional, cast 

2 

3import re 

4import os 

5import hashlib 

6 

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 

16 

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

18 

19gws.ext.new.action('qfieldcloud') 

20 

21 

22class Config(gws.ConfigWithAccess): 

23 """QField Cloud action.""" 

24 

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

29 

30 

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

32 pass 

33 

34 

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

56 

57 

58class WorkerPayload(gws.Data): 

59 actionUid: str 

60 jobType: str 

61 qfcProjectUid: str 

62 projectUid: str 

63 

64 

65def route(pattern: str): 

66 def decorator(fn): 

67 fn._route_pattern = pattern 

68 return fn 

69 

70 return decorator 

71 

72 

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

74 """QField Cloud API action.""" 

75 

76 qfcProjects: list[core.QfcProject] 

77 capsCache: dict[str, caps.Caps] 

78 method: gws.AuthMethod 

79 

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

88 

89 def __getstate__(self): 

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

91 

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

97 

98 path_parts = path.split('/') 

99 project = cast(gws.Project, self.find_closest(gws.ext.object.project)) 

100 

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

112 

113 if not project: 

114 raise gws.NotFoundError('gws project not found') 

115 

116 path = path.strip('/') 

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

118 

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) 

133 

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

135 

136 _public_routes = [ 

137 'GET api/v1/auth/providers', 

138 'POST api/v1/auth/token', 

139 ] 

140 

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

148 

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

150 

151 if rx.route not in self._public_routes: 

152 self.authorize_from_token(rx) 

153 

154 res = fn(rx) 

155 

156 if not res: 

157 return gws.ContentResponse(content='') 

158 

159 if isinstance(res, gws.ContentResponse): 

160 return res 

161 

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 ) 

167 

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

169 

170 ## 

171 

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

177 

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 ] 

183 

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 ) 

205 

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 ) 

217 

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

224 

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) 

229 

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

237 

238 job = self.create_package_job(rx) 

239 return _format_job(job, rx) 

240 

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) 

248 

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) 

252 

253 # @TODO do we need versions? 

254 # @TODO do we need layers? 

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

256 

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 ) 

266 

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) 

270 

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

277 

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) 

283 

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) 

287 

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

300 

301 self.store_delta_payload(payload, rx) 

302 

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) 

316 

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) 

326 

327 self.set_delta_payload_applied(payload.id, rx) 

328 

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) 

332 

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

335 

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 

341 

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) 

345 

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

351 

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) 

362 

363 ## 

364 

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

366 return packager.Object() 

367 

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

369 return patcher.Object() 

370 

371 ## 

372 

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 

379 

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

385 

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

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

388 return pa.caps 

389 

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 

395 

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 

401 

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

403 return cs 

404 

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 

413 

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

433 

434 ## 

435 

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 

441 

442 def set_qfc_project_from_parts(self, rx: Request): 

443 uid = rx.parts.get('project_id', '') 

444 self.set_qfc_project(uid, rx) 

445 

446 ## 

447 

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

450 

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 

455 

456 ## 

457 

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) 

472 

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

476 

477 self.fs_cleanup_old_packages(qfc_project) 

478 

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) 

495 

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

500 

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) 

515 

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) 

518 

519 ## 

520 

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 ) 

543 

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 ) 

556 

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 

572 

573 ## 

574 

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

577 

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 

584 

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 

589 

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) 

601 

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

605 

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

609 

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' 

614 

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) 

623 

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

630 

631 

632## 

633 

634 

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

640 

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) 

647 

648 

649## 

650 

651 

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

653 

654 

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 ) 

677 

678 

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 ] 

692 

693 

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 } 

701 

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 ) 

713 

714 

715def _get_sha256(path: str) -> str: 

716 with open(path, 'rb') as f: 

717 return hashlib.file_digest(f, 'sha256').hexdigest() 

718 

719 

720def _get_time_iso(path: str) -> str: 

721 t = osx.file_mtime(path) 

722 return dtx.to_iso_string(dtx.from_timestamp(t)) 

723 

724 

725def _get_md5sum(path: str) -> str: 

726 with open(path, 'rb') as f: 

727 return _get_md5sum_file(f) 

728 

729 

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) 

737 

738 if file_size <= part_size: 

739 hash = hashlib.md5() 

740 hash.update(fp.read()) 

741 return hash.hexdigest() 

742 

743 md5_sums = b'' 

744 read_size = 0 

745 

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 

751 

752 hash = hashlib.md5() 

753 hash.update(md5_sums) 

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