Coverage for postrfp/shared/fetch/nodesq.py: 86%
36 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
1from typing import (
2 Any,
3)
5from sqlalchemy import func, literal
6from sqlalchemy.orm import (
7 Query,
8 Session,
9)
10from postrfp.model import (
11 Section,
12 QuestionInstance,
13 QuestionDefinition,
14)
15from postrfp.model.questionnaire.b36 import from_b36
18def light_nodes(
19 session: Session, project_id: int, with_questions: bool = True
20) -> Query:
21 """
22 Returns a query of the project's questionnaire nodes.
23 The query returns rows representing the sections and questions
24 in the project. Each row has the following keys:
25 - id: The ID of the section or question
26 - title: The title of the section or question
27 - b36_number: The b36_number of the section or question
28 - type: The type of the node, either 'section' or 'question'
29 - parent_id: The ID of the parent section of the section or question
30 - depth: The depth of the section or question in the hierarchy
32 The query is ordered by the b36_number of the section or question.
33 If the with_questions flag is set to True, the query will include
34 the questions in the project.
36 This function avoids the expense of building ORM objects and is
37 useful for building a nested structure of the project's questionnaire.
38 """
40 q = session.query(
41 Section.id,
42 Section.title,
43 literal("section").label("type"),
44 Section.parent_id,
45 Section.b36_number.label("b36_number"),
46 Section.position,
47 (func.length(func.ifnull(Section.b36_number, "")) / 2).label("depth"),
48 ).filter(Section.project_id == project_id)
50 if with_questions:
51 aq = (
52 session.query(
53 QuestionInstance.id,
54 QuestionDefinition.title,
55 literal("question").label("type"),
56 QuestionInstance.section_id.label("parent_id"),
57 QuestionInstance.b36_number.label("b36_number"),
58 QuestionInstance.position,
59 (func.length(QuestionInstance.b36_number) / 2).label("depth"),
60 )
61 .join(QuestionDefinition)
62 .filter(QuestionInstance.project_id == project_id)
63 )
64 q = q.union(aq)
66 return q.order_by("b36_number")
69def light_tree(
70 session: Session, project_id: int, with_questions: bool = True
71) -> dict[str, Any]:
72 """
73 Returns a nested structure of the project's questionnaire.
74 """
76 node_lookup: dict[tuple[str, int], dict[str, Any]] = {}
78 for node in light_nodes(session, project_id, with_questions=with_questions):
79 if node.type == "section":
80 node_lookup[(node.type, node.id)] = {
81 "id": node.id,
82 "title": node.title,
83 "number": from_b36(node.b36_number),
84 "type": "section",
85 "parent_id": node.parent_id,
86 "depth": node.depth,
87 "subsections": [],
88 "questions": [],
89 }
90 else:
91 node_lookup[(node.type, node.id)] = {
92 "id": node.id,
93 "title": node.title,
94 "number": from_b36(node.b36_number),
95 "type": "question",
96 "parent_id": node.parent_id,
97 "depth": node.depth,
98 }
100 root_nodes: list[dict] = []
102 for node in node_lookup.values():
103 parent_id = node["parent_id"]
105 if parent_id is None:
106 root_nodes.append(node)
107 continue
109 # Parent is always a section
110 parent_key = ("section", parent_id)
111 try:
112 parent = node_lookup[parent_key]
113 except KeyError:
114 raise ValueError(f"Parent ID {parent_id} not found in node_lookup")
116 if node["type"] == "section":
117 parent["subsections"].append(node)
118 else:
119 parent["questions"].append(node)
121 if len(root_nodes) == 1:
122 return root_nodes[0]
123 elif len(root_nodes) == 0:
124 raise ValueError("No root found in the questionnaire")
125 else:
126 raise ValueError("Multiple roots found in the questionnaire")