Coverage for gws-app/gws/plugin/qfieldcloud/action_handler.py: 98%

299 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""QField Cloud API request handler.""" 

2 

3from typing import Optional, cast 

4 

5import re 

6import hashlib 

7 

8import gws 

9import gws.lib.mime 

10import gws.lib.jsonx 

11import gws.lib.sqlitex 

12import gws.lib.datetimex as dtx 

13import gws.lib.osx as osx 

14 

15from . import core, patcher, api, action_base, delta, auth 

16 

17DELTA_LOCK_TIMEOUT = 10 

18"""Seconds to wait for the delta lock of a QField project.""" 

19 

20 

21def route(pattern: str): 

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

23 

24 Args: 

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

26 

27 Returns: 

28 The decorator. 

29 """ 

30 

31 def decorator(fn): 

32 fn._route_pattern = pattern 

33 return fn 

34 

35 return decorator 

36 

37 

38class Handler: 

39 """Handler for a single QFieldCloud API request. 

40 

41 Finds the route for the request, authenticates the user and calls the route method. 

42 The handler holds the state of the request only, persistent data is accessed via ``action``. 

43 """ 

44 

45 action: action_base.BaseAction 

46 """The action that received the request.""" 

47 req: gws.WebRequester 

48 """The original web request.""" 

49 params: gws.Request 

50 """Command request.""" 

51 apiRoute: str 

52 """Request method and path.""" 

53 parts: dict 

54 """Variables from the path.""" 

55 qs: dict 

56 """Query string parameters.""" 

57 post: dict 

58 """POST payload.""" 

59 project: Optional[gws.Project] 

60 """GWS Project context.""" 

61 qfcProject: core.QfcProject 

62 """QField Cloud Project context.""" 

63 auth: auth.Result 

64 """Authentication result: user, session and token.""" 

65 

66 def __init__(self, action: action_base.BaseAction): 

67 """Create a handler. 

68 

69 Args: 

70 action: The action that received the request. 

71 """ 

72 self.action = action 

73 self.apiRoute = '' 

74 self.parts = {} 

75 self.qs = {} 

76 self.post = {} 

77 self.project = None 

78 

79 def handle(self, req: gws.WebRequester, params: gws.Request) -> gws.ContentResponse: 

80 """Find the route method for a request and call it. 

81 

82 The path can start with ``projectUid/<uid>`` to set the GWS project. 

83 If the action belongs to a project, that project is used instead. 

84 

85 Args: 

86 req: Web requester. 

87 params: Command request. 

88 

89 Returns: 

90 The response. 

91 

92 Raises: 

93 ``gws.NotFoundError``: If the path is empty, the GWS project is not found or no route matches. 

94 ``gws.ForbiddenError``: If the user cannot use the GWS project. 

95 """ 

96 self.req = req 

97 self.params = params 

98 

99 path = self.req.path().strip('/') 

100 if not path: 

101 raise gws.NotFoundError('API path not specified') 

102 

103 path_parts = path.split('/') 

104 uid = self.params.get('projectUid') 

105 

106 if path_parts[0] == 'projectUid': 

107 if len(path_parts) < 2: 

108 raise gws.NotFoundError('gws project UID not specified') 

109 uid = path_parts[1] 

110 path = '/'.join(path_parts[2:]) 

111 

112 project = cast(Optional[gws.Project], self.action.find_closest(gws.ext.object.project)) 

113 project_uid = project.uid if project else uid 

114 if project_uid: 

115 project = self.req.user.require_project(project_uid) 

116 

117 path = path.strip('/') 

118 route = f'{self.req.method} {path}' 

119 

120 for name in dir(self): 

121 fn = getattr(self, name) 

122 if callable(fn) and hasattr(fn, '_route_pattern'): 

123 m = re.match(f'^{fn._route_pattern}$', route) 

124 if m: 

125 self.project = project 

126 self.apiRoute = route 

127 self.parts = m.groupdict() 

128 self.qs = self.req.query_params() 

129 return self._handle_route(fn) 

130 

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

132 

133 _public_routes = ( 

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

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

136 'GET api/v1/server/info', 

137 'GET api/v1/status', 

138 ) 

139 

140 def _handle_route(self, fn) -> gws.ContentResponse: 

141 """Read the request payload, authenticate non-public routes, call the route method and convert its result to a response.""" 

142 if self.req.isApi: 

143 self.post = self.req.struct() 

144 elif self.req.isForm: 

145 self.post = dict(self.req.form()) 

146 elif self.req.isPost: 

147 self.post = dict(raw=self.req.data()) 

148 

149 gws.log.debug(f'API_REQUEST {self.apiRoute=} -> {fn.__name__} {self.parts=} {self.qs=}') 

150 

151 if self.apiRoute not in self._public_routes: 

152 self.auth = self.action.authMethod.authenticate_from_token(self.req) 

153 

154 res = fn() 

155 

156 if res is None: 

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 mimeType=gws.lib.mime.JSON, 

166 ) 

167 

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

169 

170 ## 

171 

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

173 def on_post_auth_logout(self): 

174 """Log out, deleting the session.""" 

175 am = self.action.root.app.authMgr 

176 am.sessionMgr.delete(self.auth.sess) 

177 gws.log.debug(f'logout: {self.auth.user.loginName=}') 

178 

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

180 def on_get_auth_providers(self) -> list[api.AuthProvider]: 

181 """Return the authentication providers. 

182 

183 Returns: 

184 A single username and password provider. 

185 """ 

186 return [ 

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

188 ] 

189 

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

191 def on_get_server_info(self) -> api.ServerInfo: 

192 """Return the server information. 

193 

194 Returns: 

195 Server information. 

196 """ 

197 return api.ServerInfo( 

198 version=self.action.root.app.version, 

199 auth_providers=self.on_get_auth_providers(), 

200 signup_url='', 

201 whitelabel={}, 

202 ) 

203 

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

205 def on_get_status(self) -> api.Status: 

206 """Return the server status. 

207 

208 Returns: 

209 Server status, always ``ok``. 

210 """ 

211 return api.Status( 

212 version=self.action.root.app.version, 

213 database='ok', 

214 storage='ok', 

215 status_page_url=None, 

216 incident_message=None, 

217 incident_timestamp_utc=None, 

218 maintenance_message=None, 

219 maintenance_start_timestamp_utc=None, 

220 maintenance_end_timestamp_utc=None, 

221 ) 

222 

223 @route('GET api/v1/users/(?P<username>[^/]+)/organizations') 

224 def on_get_user_organizations(self) -> list: 

225 """Return the organizations of a user. 

226 

227 Returns: 

228 An empty list, organizations are not supported. 

229 """ 

230 return [] 

231 

232 @route('GET api/v1/subscriptions/(?P<username>[^/]+)/current') 

233 def on_get_subscription(self) -> api.Subscription: 

234 """Return the subscription of a user. 

235 

236 Returns: 

237 A dummy active subscription. 

238 """ 

239 return api.Subscription( 

240 plan_display_name='', 

241 active_storage_total_bytes=0, 

242 storage_used_bytes=0, 

243 plan_storage_threshold_warning_bytes=0, 

244 plan_storage_threshold_critical_bytes=0, 

245 status='active_paid', 

246 ) 

247 

248 @route('POST api/v1/auth/token') 

249 def on_post_auth_token(self) -> api.AuthToken: 

250 """Log in with username and password and create a session. 

251 

252 Returns: 

253 Session token and user information. 

254 

255 Raises: 

256 ``gws.AuthenticationError``: If the credentials are invalid. 

257 """ 

258 self.auth = self.action.authMethod.authenticate_from_credentials( 

259 gws.Data( 

260 username=self.post.get('username', ''), 

261 password=self.post.get('password', ''), 

262 ), 

263 self.req, 

264 ) 

265 return api.AuthToken( 

266 token=self.auth.token, 

267 expires_at=dtx.to_iso_string(self.auth.expiresAt), 

268 username=self.auth.user.loginName, 

269 type=api.UserType.person, 

270 full_name=self.auth.user.displayName, 

271 avatar_url='', 

272 email='', 

273 first_name='', 

274 last_name='', 

275 ) 

276 

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

278 def on_get_auth_user(self) -> api.CompleteUser: 

279 """Return the authenticated user. 

280 

281 Returns: 

282 User information. 

283 """ 

284 return api.CompleteUser( 

285 username=self.auth.user.loginName, 

286 type=api.UserType.person, 

287 full_name=self.auth.user.displayName, 

288 avatar_url='', 

289 email='', 

290 first_name='', 

291 last_name='', 

292 ) 

293 

294 @route('GET api/v1/projects') 

295 def on_get_projects(self) -> list[api.Project]: 

296 """Return the QField projects the user can use. 

297 

298 Returns: 

299 Projects, paged by the ``limit`` and ``offset`` query parameters. 

300 """ 

301 limit = int(self.qs.get('limit', 100)) 

302 offset = int(self.qs.get('offset', 0)) 

303 qps = self.action.get_qfc_projects(self.auth.user) 

304 return [_format_project(qp, self.auth.user) for qp in qps[offset : offset + limit]] 

305 

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

307 def on_post_projects(self): 

308 """Create a project. Not supported. 

309 

310 Raises: 

311 ``gws.BadRequestError``: Always. 

312 """ 

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

314 

315 @route('GET api/v1/projects/(?P<project_id>[^/]+)') 

316 def on_get_projects_id(self) -> api.Project: 

317 """Return a QField project. 

318 

319 Returns: 

320 Project. 

321 

322 Raises: 

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

324 """ 

325 self.set_qfc_project_from_parts() 

326 return _format_project(self.qfcProject, self.auth.user) 

327 

328 @route('POST api/v1/jobs') 

329 def on_post_jobs(self) -> api.Job: 

330 """Create a job. Only ``package`` jobs are supported. 

331 

332 Returns: 

333 The scheduled job. 

334 

335 Raises: 

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

337 ``gws.BadRequestError``: If the job type is not supported. 

338 """ 

339 project_id = self.post.get('project_id', '') 

340 type = self.post.get('type', '') 

341 self.set_qfc_project(project_id) 

342 if type != api.TypeEnum.package: 

343 raise gws.BadRequestError(f'unsupported job type: {type!r}') 

344 

345 job = self.create_package_job() 

346 return _format_job(job) 

347 

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

349 def on_get_jobs(self): 

350 """List jobs. Not supported. 

351 

352 Raises: 

353 ``gws.BadRequestError``: Always. 

354 """ 

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

356 

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

358 def on_get_jobs_id(self) -> api.Job: 

359 """Return a job. 

360 

361 Returns: 

362 Job. 

363 

364 Raises: 

365 ``gws.NotFoundError``: If the job is not found. 

366 """ 

367 job_id = self.parts.get('job_id', '') 

368 job = self.action.get_job(job_id, self.auth.user) 

369 if not job: 

370 raise gws.NotFoundError(f'Job {job_id!r} not found') 

371 return _format_job(job) 

372 

373 @route('GET api/v1/packages/(?P<project_id>[^/]+)/(?P<package_version>[^/]+)') 

374 def on_get_package(self) -> api.Package: 

375 """Return the latest package of a QField project. 

376 

377 Returns: 

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

379 

380 Raises: 

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

382 """ 

383 self.set_qfc_project_from_parts() 

384 

385 # @TODO do we need versions? 

386 # @TODO do we need layers? 

387 # package_version = self.parts.get('package_version', '') 

388 

389 path_map = self.action.latest_package_path_map(self.qfcProject) 

390 return api.Package( 

391 files=_format_files(path_map), 

392 layers=[], 

393 status=api.JobStatusEnum.finished, 

394 package_id=self.qfcProject.uid, 

395 packaged_at=dtx.to_iso_string(), 

396 data_last_updated_at=dtx.to_iso_string(), 

397 ) 

398 

399 @route('GET api/v1/packages/(?P<project_id>[^/]+)/(?P<package_version>[^/]+)/files/(?P<file_name>.+)') 

400 def on_get_package_file(self) -> gws.ContentResponse: 

401 """Return a file from the latest package. 

402 

403 Returns: 

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

405 

406 Raises: 

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

408 ``gws.NotFoundError``: If the file is not in the package. 

409 """ 

410 self.set_qfc_project_from_parts() 

411 

412 file_name = self.parts.get('file_name', '') 

413 path_map = self.action.latest_package_path_map(self.qfcProject) 

414 for fname, p in path_map.items(): 

415 if file_name == fname: 

416 # QField resumes interrupted downloads with "Range: bytes=N-" and appends the body to its partial file. 

417 # Ranges are not supported, so refuse with 416: QField then drops the partial file and downloads in full. 

418 if self.req.header('Range'): 

419 return gws.ContentResponse( 

420 status=416, 

421 content='', 

422 headers={'Content-Range': f'bytes */{osx.file_size(p)}'}, 

423 ) 

424 return gws.ContentResponse(contentPath=p) 

425 raise gws.NotFoundError(f'file {file_name!r} not found') 

426 

427 @route('GET api/v1/files/(?P<project_id>[^/]+)') 

428 def on_get_files(self) -> list[api.PackageFile]: 

429 """Return the files of the latest package. 

430 

431 Returns: 

432 Package files. 

433 

434 Raises: 

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

436 """ 

437 self.set_qfc_project_from_parts() 

438 path_map = self.action.latest_package_path_map(self.qfcProject) 

439 return _format_files(path_map) 

440 

441 @route('GET api/v1/files/thumbnails/(?P<project_id>[^/]+)') 

442 def on_get_thumbnail(self) -> gws.ContentResponse: 

443 """Return the thumbnail of a QField project. 

444 

445 Returns: 

446 Thumbnail image. 

447 

448 Raises: 

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

450 ``gws.NotFoundError``: If the project has no thumbnail. 

451 """ 

452 self.set_qfc_project_from_parts() 

453 path = self.qfcProject.thumbnail 

454 if not path or not gws.u.is_file(path): 

455 raise gws.NotFoundError(f'no thumbnail for {self.qfcProject.uid!r}') 

456 return gws.ContentResponse(contentPath=path) 

457 

458 @route('POST api/v1/deltas/(?P<project_id>[^/]+)') 

459 def on_post_deltas(self): 

460 """Store a delta payload and apply its changes. 

461 

462 The payload is uploaded as a multipart file. Deltas are identified by their content, 

463 so a delta pushed again unchanged, also under another payload id, is not applied again. 

464 The deltas that are new, or failed before, or have been pending for longer than 

465 ``delta.PENDING_TIMEOUT``, are applied all together or not at all. 

466 

467 Raises: 

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

469 ``gws.BadRequestError``: If the payload cannot be read. 

470 ``core.DeltaApplyError``: If a delta is still being applied, or the deltas cannot be applied. 

471 """ 

472 self.set_qfc_project_from_parts() 

473 

474 try: 

475 text = self.post['file'].stream.read().decode('utf-8') 

476 payload = delta.parse_payload(text) 

477 except Exception as exc: 

478 raise gws.BadRequestError(f'invalid delta file: {exc}') 

479 

480 try: 

481 with gws.u.server_lock(f'qfieldcloud_deltas_{self.qfcProject.uid}', DELTA_LOCK_TIMEOUT): 

482 drs = self.store_pending_deltas(payload) 

483 except gws.LockBusyError as exc: 

484 raise core.DeltaApplyError(f'changes {payload.id!r}: server busy, try again later') from exc 

485 

486 if not drs: 

487 gws.log.info(f'deltas {payload.id!r}: already applied') 

488 return 

489 

490 try: 

491 args = patcher.Args( 

492 qfcProject=self.qfcProject, 

493 caps=self.action.get_caps(self.qfcProject), 

494 db=self.action.get_db_provider(self.qfcProject), 

495 project=self.project, 

496 user=self.auth.user, 

497 baseDir='', 

498 changes=delta.extract_changes(drs), 

499 ) 

500 self.action.get_patcher().apply_changes(self.action.root, args) 

501 except Exception as exc: 

502 gws.log.exception(f'deltas {payload.id!r}: failed to apply') 

503 delta.set_status_for_all(self.delta_db(), drs, api.DeltaStatusType.error, str(exc)) 

504 raise core.DeltaApplyError(f'changes {payload.id!r} could not be applied, see the server log') from exc 

505 

506 delta.set_status_for_all(self.delta_db(), drs, api.DeltaStatusType.applied) 

507 

508 def store_pending_deltas(self, payload: api.DeltasPayload) -> list[delta.Record]: 

509 """Store the deltas of a payload that need to be applied, with the status pending. 

510 

511 Must be called under the delta lock of the QField project, so that concurrent 

512 requests with the same deltas do not both apply them. 

513 

514 Args: 

515 payload: Delta payload. 

516 

517 Returns: 

518 The stored delta records, empty if all deltas are already applied. 

519 

520 Raises: 

521 ``core.DeltaApplyError``: If a delta is still being applied. 

522 """ 

523 user_name = self.auth.user.loginName 

524 delta.cleanup(self.delta_db(), self.action.deltaLifeTime) 

525 

526 uids = [] 

527 drs = [] 

528 

529 for d in payload.deltas: 

530 uid = gws.u.sha256([user_name, d]) 

531 uids.append(uid) 

532 dr = delta.get_one(self.delta_db(), uid, user_name) 

533 if dr: 

534 ds = delta.status(dr, payload.id) 

535 if ds.status == api.DeltaStatusType.applied: 

536 continue 

537 if ds.status == api.DeltaStatusType.pending: 

538 raise core.DeltaApplyError(f'changes {payload.id!r} are being applied, try again later') 

539 drs.append(delta.new_record(uid, d, user_name)) 

540 

541 delta.link_payload(self.delta_db(), payload.id, uids, user_name) 

542 delta.store_all(self.delta_db(), drs) 

543 return drs 

544 

545 @route('GET api/v1/deltas/(?P<project_id>[^/]+)') 

546 def on_get_deltas(self) -> gws.ContentResponse: 

547 """Return the deltas the user has sent for a QField project, newest first. 

548 

549 The list is paged by the ``limit`` and ``offset`` query parameters. If there are more deltas, 

550 the response has an ``X-Next-Page`` header. The ``ordering`` parameter is ignored. 

551 

552 Returns: 

553 Delta statuses as JSON. 

554 

555 Raises: 

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

557 ``gws.BadRequestError``: If ``limit`` or ``offset`` is not a number. 

558 """ 

559 self.set_qfc_project_from_parts() 

560 

561 try: 

562 limit = int(self.qs.get('limit', 50)) 

563 offset = int(self.qs.get('offset', 0)) 

564 except ValueError as exc: 

565 raise gws.BadRequestError(f'invalid paging parameters: {exc}') 

566 

567 rs = delta.get_history(self.delta_db(), self.auth.user.loginName, limit + 1, offset) 

568 dss = [delta.status(dr, payload_id) for dr, payload_id in rs[:limit]] 

569 

570 headers = {} 

571 if len(rs) > limit: 

572 headers['X-Next-Page'] = str(offset + limit) 

573 

574 return gws.ContentResponse( 

575 content=gws.lib.jsonx.to_string(dss), 

576 mimeType=gws.lib.mime.JSON, 

577 headers=headers, 

578 ) 

579 

580 @route('GET api/v1/deltas/(?P<project_id>[^/]+)/(?P<payload_id>.+)') 

581 def on_get_deltas_in_payload(self) -> list[core.DeltaStatus]: 

582 """Return the status of the deltas of a payload. 

583 

584 Returns: 

585 Delta statuses. 

586 

587 Raises: 

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

589 ``gws.NotFoundError``: If the payload is not found. 

590 """ 

591 self.set_qfc_project_from_parts() 

592 

593 payload_id = self.parts.get('payload_id', '') 

594 drs = delta.get_for_payload(self.delta_db(), payload_id, self.auth.user.loginName) 

595 dss = [delta.status(dr, payload_id) for dr in drs] 

596 if not dss: 

597 raise gws.NotFoundError(f'delta {payload_id=} not found') 

598 return dss 

599 

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

601 def on_post_file(self) -> gws.ContentResponse: 

602 """Apply a file upload. 

603 

604 Returns: 

605 An empty response with status 201. 

606 

607 Raises: 

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

609 ``gws.NotFoundError``: If no feature refers to the file. 

610 ``gws.BadRequestError``: If the upload cannot be read. 

611 """ 

612 self.set_qfc_project_from_parts() 

613 

614 path = self.parts.get('path', '') 

615 try: 

616 fc = self.post['file'].stream.read() 

617 except Exception as exc: 

618 raise gws.BadRequestError(f'invalid file upload: {exc}') 

619 

620 args = patcher.Args( 

621 qfcProject=self.qfcProject, 

622 caps=self.action.get_caps(self.qfcProject), 

623 project=self.project, 

624 user=self.auth.user, 

625 baseDir='', 

626 filePath=path, 

627 fileContent=fc, 

628 ) 

629 if not self.action.get_patcher().apply_upload(self.action.root, args): 

630 raise gws.NotFoundError(f'no feature found for {path!r}') 

631 return gws.ContentResponse(status=201, content='') 

632 

633 ## 

634 

635 ## 

636 

637 def set_qfc_project(self, uid: str): 

638 """Set the QField project of the request. 

639 

640 Args: 

641 uid: QField project uid. 

642 

643 Raises: 

644 ``gws.NotFoundError``: If the project is not found or the user cannot use it. 

645 """ 

646 qp = self.action.get_qfc_project(uid, self.auth.user) 

647 if not qp: 

648 raise gws.NotFoundError(f'project {uid!r} not found') 

649 self.qfcProject = qp 

650 

651 def set_qfc_project_from_parts(self): 

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

653 

654 Raises: 

655 ``gws.NotFoundError``: If the project is not found or the user cannot use it. 

656 """ 

657 uid = self.parts.get('project_id', '') 

658 self.set_qfc_project(uid) 

659 

660 ## 

661 

662 def create_package_job(self) -> gws.Job: 

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

664 

665 Returns: 

666 The scheduled job. 

667 """ 

668 mgr = self.action.root.app.jobMgr 

669 p = action_base.WorkerPayload( 

670 actionUid=self.action.uid, 

671 jobType='package', 

672 qfcProjectUid=self.qfcProject.uid, 

673 projectUid=self.project.uid if self.project else None, 

674 ) 

675 job = mgr.create_job( 

676 action_base.PackageWorker, 

677 self.auth.user, 

678 payload=gws.u.to_dict(p), 

679 ) 

680 return mgr.schedule_job(job) 

681 

682 def delta_db(self) -> gws.lib.sqlitex.Object: 

683 """Return the delta database of the request's QField project. 

684 

685 Returns: 

686 Database wrapper. 

687 """ 

688 

689 return gws.lib.sqlitex.Object( 

690 self.action.deltas_db_path(self.qfcProject), 

691 init_ddl=delta.STORAGE_DDL, 

692 ) 

693 

694 

695## 

696 

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

698 

699 

700def _format_project(qp: core.QfcProject, user: gws.User) -> api.Project: 

701 """Convert a QField project to an API project.""" 

702 return api.Project( 

703 id=qp.uid, 

704 name=qp.title, 

705 owner=user.loginName, 

706 description='', 

707 private=True, 

708 is_public=False, 

709 created_at=_DATE_CREATED, 

710 updated_at=dtx.to_iso_string(), 

711 data_last_packaged_at=None, 

712 data_last_updated_at=dtx.to_iso_string(), 

713 can_repackage=True, 

714 needs_repackaging=True, 

715 status='ok', 

716 user_role='admin', 

717 user_role_origin='project_owner', 

718 shared_datasets_project_id=None, 

719 is_shared_datasets_project=False, 

720 is_featured=False, 

721 is_attachment_download_on_demand=False, 

722 ) 

723 

724 

725def _format_files(path_map: dict[str, str]): 

726 """Convert a package path map to a list of API package files.""" 

727 return [ 

728 api.PackageFile( 

729 name=fname, 

730 size=osx.file_size(p), 

731 uploaded_at=_get_time_iso(p), 

732 is_attachment=False, 

733 md5sum=_get_md5sum(p), 

734 last_modified=_get_time_iso(p), 

735 sha256=_get_sha256(p), 

736 ) 

737 for fname, p in path_map.items() 

738 ] 

739 

740 

741def _format_job(job: gws.Job) -> api.Job: 

742 """Convert a GWS job to an API job.""" 

743 status_map = { 

744 gws.JobState.open: api.JobStatusEnum.pending, 

745 gws.JobState.running: api.JobStatusEnum.started, 

746 gws.JobState.complete: api.JobStatusEnum.finished, 

747 gws.JobState.error: api.JobStatusEnum.failed, 

748 } 

749 

750 return api.Job( 

751 id=job.uid, 

752 type=job.payload.get('jobType', ''), 

753 created_at=dtx.to_iso_string(job.timeCreated), 

754 created_by=1, 

755 project_id=job.payload.get('qfcProjectUid', ''), 

756 status=status_map.get(job.state, api.JobStatusEnum.pending), 

757 updated_at=dtx.to_iso_string(job.timeUpdated), 

758 started_at=dtx.to_iso_string(job.timeUpdated) if job.state == gws.JobState.running else None, 

759 finished_at=dtx.to_iso_string(job.timeUpdated) if job.state == gws.JobState.complete else None, 

760 ) 

761 

762 

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

764 """Return the SHA-256 hash of a file.""" 

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

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

767 

768 

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

770 """Return the modification time of a file as an ISO string.""" 

771 t = osx.file_mtime(path) 

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

773 

774 

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

776 """Return the MD5 hash of a file, as computed by QField.""" 

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

778 return _get_md5sum_file(f) 

779 

780 

781def _get_md5sum_file(fp, part_size: int = 8 * 1024 * 1024) -> str: 

782 """Return a plain MD5 for small files, or an S3-style multipart ETag for larger ones, as QField's ``fileEtag``.""" 

783 fp.seek(0, 2) 

784 file_size = fp.tell() 

785 fp.seek(0) 

786 

787 if file_size <= part_size: 

788 hash = hashlib.md5() 

789 hash.update(fp.read()) 

790 return hash.hexdigest() 

791 

792 md5_sums = b'' 

793 read_size = 0 

794 

795 while read_size < file_size: 

796 hash = hashlib.md5() 

797 hash.update(fp.read(part_size)) 

798 md5_sums += hash.digest() 

799 read_size += part_size 

800 

801 hash = hashlib.md5() 

802 hash.update(md5_sums) 

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