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
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 01:35 +0000
1"""
2HTTP endpoints for managing Content objects
3"""
5from typing import Optional
7import jsonschema_rs
8from sqlalchemy.orm import Session, joinedload
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
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 ]
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 )
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
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.
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
84 **Returns:** Paginated list of content summaries accessible to the user
87 **Content Summary includes:**
88 - Basic identification (ID, title, dates)
89 - Schema information
90 - Author organization
91 - Associated tags and subjects
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)
101 # Get all content that might be accessible, then filter by authorization
102 query = session.query(Content).order_by(Content.date_updated.desc())
104 if q_name:
105 query = query.filter(Content.title.ilike(f"%{q_name}%"))
107 if q_spec_id:
108 query = query.filter(Content.schema_id == q_spec_id)
110 # Get all content first, then filter by authorization
111 all_content = query.all()
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)
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]
126 return ListResponse(
127 items=[ContentDocument.model_validate(content) for content in records],
128 pagination=pager.as_pagination(total_records, len(records)),
129 )
132@http
133def get_content(session: Session, user: User, content_id: int) -> ContentDocument:
134 """
135 Get a single content item by ID
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.
141 **Path Parameters:**
142 - `content_id`: Unique identifier of the content item
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)
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 )
158 return build_saved_content(content)
161@http
162def post_content(
163 session: Session, user: User, content_doc: ContentDocument
164) -> ContentDocument:
165 """
166 Create a new content item
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.
172 **Returns:** Complete created content with assigned ID and metadata
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.
180 **@permissions REF_CONTENT_SAVE**
181 """
182 user.check_permission(perms.REF_CONTENT_SAVE)
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
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 )
195 session.flush()
197 return build_saved_content(new_content)
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
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.
211 **Path Parameters:**
212 - `content_id`: ID of the content item to update
215 **Returns:** Updated content with new timestamps and metadata
218 **Schema Validation:**
219 Updated content must still conform to the original schema. Schema changes
220 require updating the `schema_id` if needed.
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
229 **@permissions REF_CONTENT_SAVE**
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)
237 content = session.get_one(Content, content_id)
239 # auth_policy is NOT checked here; only edit permission is required
241 # Use the service function to update the content
242 update_content(session, content, content_doc, user.id)
244 return build_saved_content(content)
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
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.
262 **Path Parameters:**
263 - `content_id`: ID of the content item to update
265 **Returns:** Updated content with new timestamps and metadata
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
272 **Schema Validation:**
273 Updated content must still conform to the original schema. Schema changes
274 require updating the `schema_id` if needed.
276 **Revision Tracking:**
277 Changes are automatically tracked in the revision history.
279 **@permissions REF_CONTENT_SAVE**
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)
288 content = session.get_one(Content, content_id)
290 # auth_policy is NOT checked here; only edit permission is required
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 )
297 return build_saved_content(content)
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
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.
309 **Path Parameters:**
310 - `content_id`: ID of the content item to validate
312 **Returns:** Boolean indicating if the content is valid
314 **Use Cases:**
315 - Verify content integrity before publishing or sharing
316 - Ensure compliance with schema requirements
317 - Trigger re-validation workflows
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)
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 )
333 try:
334 content.jsonschema_validate(content.content_doc)
335 return []
336 except jsonschema_rs.ValidationError as jve:
337 return [jve.message]
340@http
341def delete_content(session: Session, user: User, content_id: int) -> None:
342 """
343 Delete a content item
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.
349 **Path Parameters:**
350 - `content_id`: ID of the content item to delete
352 **Returns:** None (204 No Content status)
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
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
369 **@permissions REF_CONTENT_SAVE**
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)
377 content = session.get_one(Content, content_id)
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 )
387 session.delete(content)
390# ============================================================================
391# ContentSpecMap Endpoints - Managing Question-to-Content Field Mappings
392# ============================================================================
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 ]
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 )
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.
428 Raises AuthorizationFailure if the ContentSpec belongs to a different organization.
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 )
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
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.
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
465 **Returns:** Paginated list of content maps accessible to the user
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
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
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
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)
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 )
501 if q_name:
502 query = query.filter(ContentSpecMap.name.ilike(f"%{q_name}%"))
504 if q_spec_id:
505 query = query.filter(ContentSpecMap.content_spec_id == q_spec_id)
507 total_records = query.count()
508 records = query.slice(pager.startfrom, pager.goto).all()
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 )
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
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.
527 **Path Parameters:**
528 - `content_map_id`: Unique identifier of the content map
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"]
535 **Use Cases:**
536 - Understand complete mapping structure
537 - Review question-to-field connections
538 - Debug content population issues
539 - Document integration workflows
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)
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 )
552 return build_content_spec_map_document(content_map)
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
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.
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
571 **Returns:** Created map with all pairs, assigned IDs, and complete structure
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`
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
586 **Ownership:**
587 - Map is associated with the ContentSpec's organization
588 - Only users from the ContentSpec's organization can create/edit maps
590 **@permissions REF_CONTENT_SAVE**
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 ```
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)
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")
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)
627 if not is_valid:
628 raise ValidationFailure(
629 "Invalid content references in mapping pairs", error_messages
630 )
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
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)
650 session.flush()
651 return build_content_spec_map_document(new_map)
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
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.
665 **Path Parameters:**
666 - `content_map_id`: ID of the content map to update
668 **Returns:** Updated map with new structure and all pairs
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
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
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
690 **@permissions REF_CONTENT_SAVE**
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
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)
713 content_map = session.get_one(ContentSpecMap, content_map_id)
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 )
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 )
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)
733 if not is_valid:
734 raise ValidationFailure(
735 "Invalid content references in mapping pairs", error_messages
736 )
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
743 # Delete all existing pairs
744 content_map.pairs.clear()
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)
755 session.flush()
756 return build_content_spec_map_document(content_map)
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
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.
768 **Path Parameters:**
769 - `content_map_id`: ID of the content map to delete
771 **Returns:** None (204 No Content status)
773 **What Gets Deleted:**
774 - The content map itself
775 - All question-to-field mapping pairs
776 - All mapping metadata and documentation
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)
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
790 **Permission Requirements:**
791 - User must have REF_CONTENT_SAVE permission
792 - Map must belong to a ContentSpec owned by user's organization
794 **Use Cases:**
795 - Remove obsolete or incorrect mappings
796 - Clean up unused mapping definitions
797 - Reorganize mapping structure (delete then recreate)
799 **@permissions REF_CONTENT_SAVE**
801 **Raises:**
802 - `NoResultFound`: If the content map ID does not exist
803 - `AuthorizationFailure`: If the map belongs to another organization's ContentSpec
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)
810 content_map = session.get_one(ContentSpecMap, content_map_id)
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 )
817 session.delete(content_map)
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
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.
834 **Path Parameters:**
835 - `spec_id`: The ID of the ContentSpec to get pointers for
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
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
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
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`
861 **Raises:**
862 - `NoResultFound`: If the content_spec_id does not exist
863 - `AuthorizationFailure`: If the ContentSpec belongs to another organization
865 **Performance Notes:**
866 - Pointers are cached by the validator for efficiency
867 - Safe to call frequently in UI workflows
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)
875 # Use the validator to extract all valid paths
876 validator = ContentReferenceValidator(content_spec)
877 pointers = sorted(validator.get_valid_paths())
879 return SchemaPointersResponse(
880 content_spec_id=content_spec.id,
881 content_spec_name=content_spec.name,
882 pointers=pointers,
883 )