Coverage for postrfp / ref / handlers / editors.py: 97%

160 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-03 01:35 +0000

1""" 

2HTTP endpoints for managing Content objects 

3""" 

4 

5from typing import Optional 

6 

7import jsonschema_rs 

8from sqlalchemy.orm import Session, joinedload 

9 

10from postrfp.shared.pager import Pager 

11from postrfp.authorisation import perms 

12from postrfp.shared.decorators import http 

13from postrfp.model import User 

14from postrfp.model.ref import Content, ContentSpec, ContentSpecMap, ContentQElementPair 

15from postrfp.shared.serial.refmodels import ( 

16 ContentDocument, 

17 ContentPatch, 

18 ListResponse, 

19 TagSummary, 

20 SubjectSummary, 

21 ContentSpecMapDocument, 

22 ContentQElementPairDocument, 

23 SchemaPointersResponse, 

24) 

25from postrfp.ref.service.content_service import ( 

26 update_content, 

27 create_content, 

28 fetch_content_spec, 

29 patch_content_doc, 

30) 

31from postrfp.ref.service.reference_validator import ContentReferenceValidator 

32from postrfp.ref.permissions import check_content_authorization 

33from postrfp.shared.exceptions import AuthorizationFailure 

34from postrfp.model.exc import ValidationFailure 

35 

36 

37def build_saved_content(content: Content) -> ContentDocument: 

38 """ 

39 Build a ContentDocument response from a Content model instance. 

40 Converts Tag and Subject relationships to their summary representations. 

41 """ 

42 # Convert tags to TagSummary objects 

43 tags = [ 

44 TagSummary(id=tag.id, name=tag.name, description=tag.description) 

45 for tag in content.tags 

46 ] 

47 

48 # Convert subjects to SubjectSummary objects 

49 subjects = [ 

50 SubjectSummary( 

51 id=subject.id, 

52 name=subject.name, 

53 code=subject.code, 

54 description=subject.description, 

55 subject_type=subject.subject_type.value, 

56 ) 

57 for subject in content.subjects 

58 ] 

59 return ContentDocument.model_validate( 

60 content, context={"tags": tags, "subjects": subjects} 

61 ) 

62 

63 

64@http 

65def get_contents( 

66 session: Session, 

67 user: User, 

68 q_name: Optional[str] = None, 

69 q_spec_id: Optional[int] = None, 

70 pager: Optional[Pager] = None, 

71) -> ListResponse: 

72 """ 

73 List all content items accessible to the user's organization 

74 

75 Returns content items that the user's organization has permission to view, 

76 including authored content, public content, and content with explicit permissions. 

77 Results can be filtered by title and schema, and are paginated for performance. 

78 

79 **Query Parameters:** 

80 - `q_name`: Filter content by title (case-insensitive partial match) 

81 - `q_spec_id`: Filter by content schema/specification ID 

82 - Standard pagination parameters supported 

83 

84 **Returns:** Paginated list of content summaries accessible to the user 

85 

86 

87 **Content Summary includes:** 

88 - Basic identification (ID, title, dates) 

89 - Schema information 

90 - Author organization 

91 - Associated tags and subjects 

92 

93 **Examples:** 

94 - Get all content: no query parameters 

95 - Search by title: `?q_name=financial report` 

96 - Filter by schema: `?q_spec_id=5` 

97 """ 

98 if pager is None: 

99 pager = Pager(page=1, page_size=20) 

100 

101 # Get all content that might be accessible, then filter by authorization 

102 query = session.query(Content).order_by(Content.date_updated.desc()) 

103 

104 if q_name: 

105 query = query.filter(Content.title.ilike(f"%{q_name}%")) 

106 

107 if q_spec_id: 

108 query = query.filter(Content.schema_id == q_spec_id) 

109 

110 # Get all content first, then filter by authorization 

111 all_content = query.all() 

112 

113 # Filter content based on authorization 

114 authorized_content = [] 

115 for content in all_content: 

116 authorization_result = check_content_authorization(content, user, "view") 

117 if authorization_result.granted: 

118 authorized_content.append(content) 

119 

120 # Apply pagination to the authorized results 

121 total_records = len(authorized_content) 

122 start_idx = pager.startfrom 

123 end_idx = min(pager.goto, total_records) 

124 records = authorized_content[start_idx:end_idx] 

125 

126 return ListResponse( 

127 items=[ContentDocument.model_validate(content) for content in records], 

128 pagination=pager.as_pagination(total_records, len(records)), 

129 ) 

130 

131 

132@http 

133def get_content(session: Session, user: User, content_id: int) -> ContentDocument: 

134 """ 

135 Get a single content item by ID 

136 

137 Retrieves complete information about a content item including all structured 

138 data, metadata, relationships, and associated tags and subjects. The user's 

139 organization must have appropriate permissions to view this content. 

140 

141 **Path Parameters:** 

142 - `content_id`: Unique identifier of the content item 

143 

144 **Raises:** 

145 - `NoResultFound`: If the content ID does not exist 

146 - `AuthorizationFailure`: If the user lacks permission to view this content 

147 """ 

148 content = session.get_one(Content, content_id) 

149 

150 # Enhanced permission check with detailed error reporting 

151 authorization_result = check_content_authorization(content, user, "view") 

152 if not authorization_result.granted: 

153 raise AuthorizationFailure( 

154 f"Cannot view Content {content_id}. {authorization_result.reason}. " 

155 "You do not have permission to perform the requested action." 

156 ) 

157 

158 return build_saved_content(content) 

159 

160 

161@http 

162def post_content( 

163 session: Session, user: User, content_doc: ContentDocument 

164) -> ContentDocument: 

165 """ 

166 Create a new content item 

167 

168 Creates a new structured content item validated against its schema. The content 

169 is authored by the user's organization and can be associated with subjects, 

170 tags, and custom permission settings. 

171 

172 **Returns:** Complete created content with assigned ID and metadata 

173 

174 **Schema Validation:** 

175 The `content_doc` structure must conform to the JSON schema defined by `schema_id`. 

176 Validation errors will be returned if the content doesn't match the schema. 

177 

178 

179 

180 **@permissions REF_CONTENT_SAVE** 

181 """ 

182 user.check_permission(perms.REF_CONTENT_SAVE) 

183 

184 # Additional authorization checks could be added here for: 

185 # - Schema access permissions 

186 # - Subject association permissions 

187 # - Organization content creation policies 

188 # For now, REF_CONTENT_SAVE permission is sufficient 

189 

190 # Use the service function to create the content 

191 new_content = create_content( 

192 session, content_doc, author_org_id=user.org_id, created_by_id=user.id 

193 ) 

194 

195 session.flush() 

196 

197 return build_saved_content(new_content) 

198 

199 

200@http 

201def put_content( 

202 session: Session, user: User, content_id: int, content_doc: ContentDocument 

203) -> ContentDocument: 

204 """ 

205 Update an existing content item 

206 

207 Modifies an existing content item with new data. The user's organization must 

208 have edit permissions for this content. All changes are tracked in the revision 

209 history for auditing purposes. 

210 

211 **Path Parameters:** 

212 - `content_id`: ID of the content item to update 

213 

214 

215 **Returns:** Updated content with new timestamps and metadata 

216 

217 

218 **Schema Validation:** 

219 Updated content must still conform to the original schema. Schema changes 

220 require updating the `schema_id` if needed. 

221 

222 **Revision Tracking:** 

223 Changes are automatically tracked in the revision history, including: 

224 - What fields changed 

225 - Who made the changes 

226 - When changes occurred 

227 - Optional change comments 

228 

229 **@permissions REF_CONTENT_SAVE** 

230 

231 **Raises:** 

232 - `NoResultFound`: If the content ID does not exist 

233 - `AuthorizationFailure`: If the user lacks edit permission 

234 """ 

235 user.check_permission(perms.REF_CONTENT_SAVE) 

236 

237 content = session.get_one(Content, content_id) 

238 

239 # auth_policy is NOT checked here; only edit permission is required 

240 

241 # Use the service function to update the content 

242 update_content(session, content, content_doc, user.id) 

243 

244 return build_saved_content(content) 

245 

246 

247@http 

248def patch_content( 

249 session: Session, 

250 user: User, 

251 content_id: int, 

252 patch_doc: ContentPatch, 

253 if_match: str, 

254) -> ContentDocument: 

255 """ 

256 Update the content_doc of an existing content item partially 

257 

258 Applies partial updates to an existing content item. Only the fields provided 

259 in the request will be modified; all other fields remain unchanged. The user's 

260 organization must have edit permissions for this content. 

261 

262 **Path Parameters:** 

263 - `content_id`: ID of the content item to update 

264 

265 **Returns:** Updated content with new timestamps and metadata 

266 

267 **Partial Update Behavior:** 

268 - Only fields present in `content_doc` are updated 

269 - Unspecified fields remain unchanged 

270 - Useful for minor edits without resubmitting the entire content structure 

271 

272 **Schema Validation:** 

273 Updated content must still conform to the original schema. Schema changes 

274 require updating the `schema_id` if needed. 

275 

276 **Revision Tracking:** 

277 Changes are automatically tracked in the revision history. 

278 

279 **@permissions REF_CONTENT_SAVE** 

280 

281 **Raises:** 

282 - `NoResultFound`: If the content ID does not exist 

283 - `AuthorizationFailure`: If the user lacks edit permission 

284 - `UpdateConflict`: If the provided ETag does not match the current content state 

285 """ 

286 user.check_permission(perms.REF_CONTENT_SAVE) 

287 

288 content = session.get_one(Content, content_id) 

289 

290 # auth_policy is NOT checked here; only edit permission is required 

291 

292 # Use the service function to update the content partially 

293 patch_content_doc( 

294 session, content, patch_doc.patches, if_match, user, patch_doc.comment 

295 ) 

296 

297 return build_saved_content(content) 

298 

299 

300@http 

301def get_content_validation(session: Session, user: User, content_id: int) -> list[str]: 

302 """ 

303 Check if a content item is valid against its schema 

304 

305 Validates the structured content against the JSON schema defined by its 

306 associated ContentSpec. Returns whether the content currently conforms 

307 to the schema. 

308 

309 **Path Parameters:** 

310 - `content_id`: ID of the content item to validate 

311 

312 **Returns:** Boolean indicating if the content is valid 

313 

314 **Use Cases:** 

315 - Verify content integrity before publishing or sharing 

316 - Ensure compliance with schema requirements 

317 - Trigger re-validation workflows 

318 

319 **Raises:** 

320 - `NoResultFound`: If the content ID does not exist 

321 - `AuthorizationFailure`: If the user lacks view permission 

322 """ 

323 content = session.get_one(Content, content_id) 

324 

325 # Check authorization to view this content 

326 authorization_result = check_content_authorization(content, user, "view") 

327 if not authorization_result.granted: 

328 raise AuthorizationFailure( 

329 f"Cannot validate Content {content_id}. {authorization_result.reason}. " 

330 "You do not have permission to perform the requested action." 

331 ) 

332 

333 try: 

334 content.jsonschema_validate(content.content_doc) 

335 return [] 

336 except jsonschema_rs.ValidationError as jve: 

337 return [jve.message] 

338 

339 

340@http 

341def delete_content(session: Session, user: User, content_id: int) -> None: 

342 """ 

343 Delete a content item 

344 

345 Permanently removes a content item and all its associated data including 

346 relationships, permissions, and revision history. This action cannot be undone. 

347 The user's organization must have edit permissions for this content. 

348 

349 **Path Parameters:** 

350 - `content_id`: ID of the content item to delete 

351 

352 **Returns:** None (204 No Content status) 

353 

354 **What Gets Deleted:** 

355 - The content item itself 

356 - All tag associations 

357 - All subject associations 

358 - All content relationships (both incoming and outgoing) 

359 - Complete revision history 

360 - Any comments or annotations 

361 

362 

363 **Important Warnings:** 

364 - **This action is irreversible** - deleted content cannot be recovered 

365 - Other content items that reference this content will have broken relationships 

366 - Organizations that had access will lose that access immediately 

367 - Any workflows or external systems referencing this content may break 

368 

369 **@permissions REF_CONTENT_SAVE** 

370 

371 **Raises:** 

372 - `NoResultFound`: If the content ID does not exist 

373 - `AuthorizationFailure`: If the user lacks edit permission 

374 """ 

375 user.check_permission(perms.REF_CONTENT_SAVE) 

376 

377 content = session.get_one(Content, content_id) 

378 

379 # Check authorization to delete this content 

380 authorization_result = check_content_authorization(content, user, "edit") 

381 if not authorization_result.granted: 

382 raise AuthorizationFailure( 

383 f"Cannot delete Content {content_id}. {authorization_result.reason}. " 

384 "You do not have permission to perform the requested action." 

385 ) 

386 

387 session.delete(content) 

388 

389 

390# ============================================================================ 

391# ContentSpecMap Endpoints - Managing Question-to-Content Field Mappings 

392# ============================================================================ 

393 

394 

395def build_content_spec_map_document( 

396 content_map: ContentSpecMap, 

397) -> ContentSpecMapDocument: 

398 """ 

399 Build a ContentSpecMapDocument response from a ContentSpecMap model instance. 

400 Includes all nested ContentQElementPair objects. 

401 """ 

402 pairs = [ 

403 ContentQElementPairDocument( 

404 id=pair.id, 

405 question_element_id=pair.question_element_id, 

406 content_reference=pair.content_reference, 

407 content_map_id=pair.content_map_id, 

408 ) 

409 for pair in content_map.pairs 

410 ] 

411 

412 return ContentSpecMapDocument( 

413 id=content_map.id, 

414 name=content_map.name, 

415 description=content_map.description, 

416 content_spec_id=content_map.content_spec_id, 

417 content_spec_name=content_map.content_spec.name, 

418 pairs=pairs, 

419 ) 

420 

421 

422def _require_same_org( 

423 content_spec: ContentSpec, user: User, action: str, entity_id: Optional[int] = None 

424) -> None: 

425 """ 

426 Check that a ContentSpec belongs to the user's organization. 

427 

428 Raises AuthorizationFailure if the ContentSpec belongs to a different organization. 

429 

430 Args: 

431 content_spec: The ContentSpec to check 

432 user: The user making the request 

433 action: Description of the action being attempted (e.g., "access ContentSpecMap", "create ContentSpecMap") 

434 entity_id: Optional ID to include in error message (e.g., content_map_id) 

435 """ 

436 if content_spec.org_id != user.org_id: 

437 entity_ref = f" {entity_id}" if entity_id else "" 

438 raise AuthorizationFailure( 

439 f"Cannot {action}{entity_ref}. " 

440 "This ContentSpec belongs to another organization." 

441 ) 

442 

443 

444@http 

445def get_content_maps( 

446 session: Session, 

447 user: User, 

448 q_name: Optional[str] = None, 

449 q_spec_id: Optional[int] = None, 

450 pager: Optional[Pager] = None, 

451) -> ListResponse: 

452 """ 

453 List all content maps accessible to the user's organization 

454 

455 Content maps define how questionnaire responses populate structured content. 

456 Each map connects question elements to specific fields in a content document 

457 using JSON Pointer expressions. Maps are filtered to show only those belonging 

458 to content schemas owned by the user's organization. 

459 

460 **Query Parameters:** 

461 - `q_name`: Filter maps by name (case-insensitive partial match) 

462 - `q_spec_id`: Filter by content schema/specification ID 

463 - Standard pagination parameters supported 

464 

465 **Returns:** Paginated list of content maps accessible to the user 

466 

467 **Access Control:** 

468 Maps are filtered to show only: 

469 - Maps associated with ContentSpecs owned by the user's organization 

470 - This ensures organizations only see mappings for their own content structures 

471 

472 **Map Information Includes:** 

473 - Map identification (ID, name, description) 

474 - Associated ContentSpec ID 

475 - List of all question-to-field mapping pairs 

476 - JSON Pointer expressions for each field mapping 

477 

478 **Use Cases:** 

479 - Browse available content population mappings 

480 - Find mappings for specific content types 

481 - Understand how questionnaires populate content 

482 - Manage organization's content integration workflows 

483 

484 **Examples:** 

485 - Get all maps: no query parameters 

486 - Search by name: `?q_name=vendor SLA` 

487 - Filter by schema: `?q_spec_id=5` 

488 """ 

489 if pager is None: 

490 pager = Pager(page=1, page_size=20) 

491 

492 # Filter maps by user's org through ContentSpec relationship 

493 query = ( 

494 session.query(ContentSpecMap) 

495 .join(ContentSpec, ContentSpecMap.content_spec_id == ContentSpec.id) 

496 .options(joinedload(ContentSpecMap.content_spec).load_only(ContentSpec.name)) 

497 .filter(ContentSpec.org_id == user.org_id) 

498 .order_by(ContentSpecMap.name) 

499 ) 

500 

501 if q_name: 

502 query = query.filter(ContentSpecMap.name.ilike(f"%{q_name}%")) 

503 

504 if q_spec_id: 

505 query = query.filter(ContentSpecMap.content_spec_id == q_spec_id) 

506 

507 total_records = query.count() 

508 records = query.slice(pager.startfrom, pager.goto).all() 

509 

510 return ListResponse( 

511 items=[build_content_spec_map_document(map_obj) for map_obj in records], 

512 pagination=pager.as_pagination(total_records, len(records)), 

513 ) 

514 

515 

516@http 

517def get_content_map( 

518 session: Session, user: User, content_map_id: int 

519) -> ContentSpecMapDocument: 

520 """ 

521 Get a single content map by ID 

522 

523 Retrieves complete information about a content map including all question-to-field 

524 mapping pairs. Each pair specifies which question element maps to which field in 

525 the content document via a JSON Pointer expression. 

526 

527 **Path Parameters:** 

528 - `content_map_id`: Unique identifier of the content map 

529 

530 **JSON Pointer Expression Examples:** 

531 - `$.sla.uptime` → Maps to content_doc["sla"]["uptime"] 

532 - `$.contact.email` → Maps to content_doc["contact"]["email"] 

533 - `$.features[0].name` → Maps to content_doc["features"][0]["name"] 

534 

535 **Use Cases:** 

536 - Understand complete mapping structure 

537 - Review question-to-field connections 

538 - Debug content population issues 

539 - Document integration workflows 

540 

541 **Raises:** 

542 - `NoResultFound`: If the content map ID does not exist 

543 - `AuthorizationFailure`: If the map belongs to another organization's ContentSpec 

544 """ 

545 content_map = session.get_one(ContentSpecMap, content_map_id) 

546 

547 # Check that the map belongs to a ContentSpec owned by user's org 

548 _require_same_org( 

549 content_map.content_spec, user, "access ContentSpecMap", content_map_id 

550 ) 

551 

552 return build_content_spec_map_document(content_map) 

553 

554 

555@http 

556def post_content_map( 

557 session: Session, user: User, map_doc: ContentSpecMapDocument 

558) -> ContentSpecMapDocument: 

559 """ 

560 Create a new content map with mapping pairs 

561 

562 Creates a new mapping between questionnaire elements and content fields. 

563 The map can include multiple pairs in a single request, defining how various 

564 question responses populate different fields in the content document. 

565 

566 **Pair Structure:** 

567 Each pair in the `pairs` array must include: 

568 - `question_element_id`: ID of the question element 

569 - `content_reference`: JSONPointer expression for the content field 

570 

571 **Returns:** Created map with all pairs, assigned IDs, and complete structure 

572 

573 **JSON Pointer Requirements:** 

574 - Must be valid JSON Pointer expressions starting with `$` 

575 - Should reference fields that exist in the associated ContentSpec 

576 - Can target nested objects, arrays, and complex structures 

577 - Examples: `$.name`, `$.address.city`, `$.items[0].quantity` 

578 

579 **Validation:** 

580 The system validates: 

581 - ContentSpec ID exists and belongs to user's organization 

582 - Question element IDs exist (if provided) 

583 - JSON Pointer expressions are well-formed 

584 - No duplicate question elements in the same map 

585 

586 **Ownership:** 

587 - Map is associated with the ContentSpec's organization 

588 - Only users from the ContentSpec's organization can create/edit maps 

589 

590 **@permissions REF_CONTENT_SAVE** 

591 

592 **Example:** 

593 ```json 

594 { 

595 "name": "Vendor SLA Mapping", 

596 "description": "Maps vendor questionnaire to SLA content", 

597 "content_spec_id": 5, 

598 "pairs": [ 

599 { 

600 "question_element_id": 123, 

601 "content_reference": "$.sla.uptime_guarantee" 

602 }, 

603 { 

604 "question_element_id": 124, 

605 "content_reference": "$.sla.response_time" 

606 } 

607 ] 

608 } 

609 ``` 

610 

611 **Raises:** 

612 - `NoResultFound`: If the ContentSpec ID does not exist 

613 - `AuthorizationFailure`: If the ContentSpec belongs to another organization 

614 - `ValidationFailure`: If content_references are invalid against the schema 

615 """ 

616 user.check_permission(perms.REF_CONTENT_SAVE) 

617 

618 # Verify ContentSpec exists and belongs to user's org 

619 content_spec = session.get_one(ContentSpec, map_doc.content_spec_id) 

620 _require_same_org(content_spec, user, "create ContentSpecMap for ContentSpec") 

621 

622 # Validate all content_references against the schema 

623 if map_doc.pairs: 

624 validator = ContentReferenceValidator(content_spec) 

625 is_valid, error_messages = validator.validate_pairs(map_doc.pairs) 

626 

627 if not is_valid: 

628 raise ValidationFailure( 

629 "Invalid content references in mapping pairs", error_messages 

630 ) 

631 

632 # Create the content map 

633 new_map = ContentSpecMap( 

634 name=map_doc.name, 

635 description=map_doc.description, 

636 content_spec_id=map_doc.content_spec_id, 

637 ) 

638 session.add(new_map) 

639 session.flush() # Get the map ID for the pairs 

640 

641 # Create the pairs 

642 for pair_doc in map_doc.pairs: 

643 pair = ContentQElementPair( 

644 content_map_id=new_map.id, 

645 question_element_id=pair_doc.question_element_id, 

646 content_reference=pair_doc.content_reference, 

647 ) 

648 session.add(pair) 

649 

650 session.flush() 

651 return build_content_spec_map_document(new_map) 

652 

653 

654@http 

655def put_content_map( 

656 session: Session, user: User, content_map_id: int, map_doc: ContentSpecMapDocument 

657) -> ContentSpecMapDocument: 

658 """ 

659 Update an existing content map and replace all mapping pairs 

660 

661 Modifies an existing content map with new data. This operation replaces ALL 

662 existing mapping pairs with the new set provided in the request. Any pairs 

663 not included in the update will be deleted. 

664 

665 **Path Parameters:** 

666 - `content_map_id`: ID of the content map to update 

667 

668 **Returns:** Updated map with new structure and all pairs 

669 

670 **Important - Pair Replacement Behavior:** 

671 - ALL existing pairs are deleted 

672 - ALL pairs in the request are created as new 

673 - To keep a pair, include it in the update request 

674 - To remove a pair, omit it from the pairs array 

675 - To add a pair, include it in the pairs array 

676 

677 **Use Cases:** 

678 - Rename or re-describe the mapping 

679 - Add new question-to-field mappings 

680 - Remove obsolete mappings 

681 - Reorganize mapping structure 

682 - Move map to a different ContentSpec 

683 

684 **Best Practices:** 

685 - Include all pairs you want to keep in the update 

686 - Review existing pairs before updating (use GET first) 

687 - Test mapping changes with sample questionnaire data 

688 - Document reasons for structural changes 

689 

690 **@permissions REF_CONTENT_SAVE** 

691 

692 **Raises:** 

693 - `NoResultFound`: If the content map ID does not exist 

694 - `AuthorizationFailure`: If the map or target ContentSpec belongs to another organization 

695 - `ValidationFailure`: If content_references are invalid against the schema 

696 

697 **Example - Adding a new pair while keeping existing ones:** 

698 ```json 

699 { 

700 "name": "Vendor SLA Mapping", 

701 "description": "Updated mapping with additional field", 

702 "content_spec_id": 5, 

703 "pairs": [ 

704 {"question_element_id": 123, "content_reference": "$.sla.uptime_guarantee"}, 

705 {"question_element_id": 124, "content_reference": "$.sla.response_time"}, 

706 {"question_element_id": 125, "content_reference": "$.sla.support_hours"} 

707 ] 

708 } 

709 ``` 

710 """ 

711 user.check_permission(perms.REF_CONTENT_SAVE) 

712 

713 content_map = session.get_one(ContentSpecMap, content_map_id) 

714 

715 # Check that the current map belongs to user's org 

716 _require_same_org( 

717 content_map.content_spec, user, "update ContentSpecMap", content_map_id 

718 ) 

719 

720 # If changing content_spec_id, verify the new one also belongs to user's org 

721 target_content_spec = content_map.content_spec 

722 if map_doc.content_spec_id != content_map.content_spec_id: 

723 target_content_spec = fetch_content_spec(session, map_doc.content_spec_id) 

724 _require_same_org( 

725 target_content_spec, user, "move ContentSpecMap to ContentSpec" 

726 ) 

727 

728 # Validate all content_references against the (potentially new) schema 

729 if map_doc.pairs: 

730 validator = ContentReferenceValidator(target_content_spec) 

731 is_valid, error_messages = validator.validate_pairs(map_doc.pairs) 

732 

733 if not is_valid: 

734 raise ValidationFailure( 

735 "Invalid content references in mapping pairs", error_messages 

736 ) 

737 

738 # Update basic fields 

739 content_map.name = map_doc.name 

740 content_map.description = map_doc.description 

741 content_map.content_spec_id = map_doc.content_spec_id 

742 

743 # Delete all existing pairs 

744 content_map.pairs.clear() 

745 

746 # Create new pairs 

747 for pair_doc in map_doc.pairs: 

748 pair = ContentQElementPair( 

749 content_map_id=content_map.id, 

750 question_element_id=pair_doc.question_element_id, 

751 content_reference=pair_doc.content_reference, 

752 ) 

753 content_map.pairs.append(pair) 

754 

755 session.flush() 

756 return build_content_spec_map_document(content_map) 

757 

758 

759@http 

760def delete_content_map(session: Session, user: User, content_map_id: int) -> None: 

761 """ 

762 Delete a content map and all its mapping pairs 

763 

764 Permanently removes a content map and all associated question-to-field mappings. 

765 This action cannot be undone. The deletion only affects the mapping definition; 

766 it does not affect existing content or questionnaires. 

767 

768 **Path Parameters:** 

769 - `content_map_id`: ID of the content map to delete 

770 

771 **Returns:** None (204 No Content status) 

772 

773 **What Gets Deleted:** 

774 - The content map itself 

775 - All question-to-field mapping pairs 

776 - All mapping metadata and documentation 

777 

778 **What Is NOT Affected:** 

779 - Content items (existing content remains unchanged) 

780 - ContentSpec (the schema remains intact) 

781 - Question elements (questions remain in questionnaires) 

782 - Questionnaire responses (answers are preserved) 

783 

784 **Impact:** 

785 - Future questionnaire responses cannot use this mapping to populate content 

786 - Existing content created via this mapping remains valid 

787 - Manual content population is still possible 

788 - Other mappings for the same ContentSpec continue to work 

789 

790 **Permission Requirements:** 

791 - User must have REF_CONTENT_SAVE permission 

792 - Map must belong to a ContentSpec owned by user's organization 

793 

794 **Use Cases:** 

795 - Remove obsolete or incorrect mappings 

796 - Clean up unused mapping definitions 

797 - Reorganize mapping structure (delete then recreate) 

798 

799 **@permissions REF_CONTENT_SAVE** 

800 

801 **Raises:** 

802 - `NoResultFound`: If the content map ID does not exist 

803 - `AuthorizationFailure`: If the map belongs to another organization's ContentSpec 

804 

805 **Note:** This is a safe operation that only removes the mapping instructions. 

806 No actual content or questionnaire data is deleted. 

807 """ 

808 user.check_permission(perms.REF_CONTENT_SAVE) 

809 

810 content_map = session.get_one(ContentSpecMap, content_map_id) 

811 

812 # Check that the map belongs to user's org 

813 _require_same_org( 

814 content_map.content_spec, user, "delete ContentSpecMap", content_map_id 

815 ) 

816 

817 session.delete(content_map) 

818 

819 

820@http 

821def get_spec_pointers( 

822 session: Session, 

823 user: User, 

824 spec_id: int, 

825) -> SchemaPointersResponse: 

826 """ 

827 Get all valid JSON Pointer paths for a ContentSpec's schema 

828 

829 Returns a comprehensive list of all valid JSON Pointer expressions that can be 

830 used when creating ContentQElementPair mappings. This endpoint is designed to 

831 help UI developers provide auto-complete, validation, or selection interfaces 

832 when users are configuring question-to-field mappings. 

833 

834 **Path Parameters:** 

835 - `spec_id`: The ID of the ContentSpec to get pointers for 

836 

837 

838 **Access Control:** 

839 - Requires read access to the ContentSpec (must belong to user's organization) 

840 - This ensures organizations only access pointer information for their own schemas 

841 

842 **Pointer Extraction:** 

843 - Recursively extracts all valid paths from the JSON Schema 

844 - Handles nested objects, arrays, $ref definitions, and combinators 

845 - Array indices are normalized (e.g., `/items/0` represents any array element) 

846 - Paths are sorted alphabetically for consistent presentation 

847 

848 **Use Cases:** 

849 - Populate dropdowns or auto-complete fields in mapping UIs 

850 - Validate user input when creating/editing mappings 

851 - Display available fields when configuring content population 

852 - Document available fields for API consumers 

853 

854 **Examples:** 

855 For a schema with company and financial data, returns paths like: 

856 - `/company/name` 

857 - `/company/legal_status/jurisdiction` 

858 - `/financial/auditing/external_auditor` 

859 - `/metadata/assessment_date` 

860 

861 **Raises:** 

862 - `NoResultFound`: If the content_spec_id does not exist 

863 - `AuthorizationFailure`: If the ContentSpec belongs to another organization 

864 

865 **Performance Notes:** 

866 - Pointers are cached by the validator for efficiency 

867 - Safe to call frequently in UI workflows 

868 

869 **Access:** Requires authentication; no explicit permission needed for read operations 

870 """ 

871 # Fetch the content spec and verify ownership 

872 content_spec = session.get_one(ContentSpec, spec_id) 

873 _require_same_org(content_spec, user, "access ContentSpec", spec_id) 

874 

875 # Use the validator to extract all valid paths 

876 validator = ContentReferenceValidator(content_spec) 

877 pointers = sorted(validator.get_valid_paths()) 

878 

879 return SchemaPointersResponse( 

880 content_spec_id=content_spec.id, 

881 content_spec_name=content_spec.name, 

882 pointers=pointers, 

883 )