Coverage for postrfp/shared/fetch/weightq.py: 100%
51 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
1import logging
2from typing import (
3 Optional,
4 List,
5)
6from typing_extensions import TypedDict
8from postrfp.model import (
9 Project,
10 Section,
11 QuestionInstance,
12)
13from postrfp.model.questionnaire.weightings import (
14 TotalWeighting,
15 Weighting,
16)
18log = logging.getLogger(__name__)
21# TypedDict definitions for type safety
22class QuestionWeight(TypedDict):
23 question_instance_id: int
24 weight: float
27class SectionWeight(TypedDict):
28 section_id: int
29 weight: float
32class WeightingsData(TypedDict):
33 questions: List[QuestionWeight]
34 sections: List[SectionWeight]
37def _ws_weights(
38 project: Project, weightset_id: int, parent_section_id: Optional[int] = None
39) -> WeightingsData:
40 """Get Weights for Questions and Sections for the given weightset_id"""
42 questions: List[QuestionWeight] = []
43 sections: List[SectionWeight] = []
45 # Get all sections in scope (with explicit weights via LEFT JOIN)
46 section_query = project.sections
47 if parent_section_id is not None:
48 section_query = section_query.filter(Section.parent_id == parent_section_id)
50 # LEFT JOIN to get explicit weights, defaulting to 1.0 for missing records
51 # NOTE: Explicit ordering is important for deterministic API responses.
52 # Without an ORDER BY, different DB backends / query plans can yield a
53 # non-deterministic sequence of rows (especially when outer joins and
54 # missing rows are involved). This was causing fragile snapshot tests
55 # where e.g. section_id 1 occasionally appeared at the end of the list.
56 section_weights = (
57 section_query.outerjoin(
58 Weighting,
59 (Weighting.section_id == Section.id)
60 & (Weighting.weighting_set_id == weightset_id),
61 )
62 .with_entities(Section.id, Weighting.value)
63 .order_by(Section.id)
64 )
66 for section_id, weight_value in section_weights:
67 weight = float(weight_value) if weight_value is not None else 1.0
68 sections.append({"section_id": section_id, "weight": weight})
70 # Get all questions in scope (with explicit weights via LEFT JOIN)
71 question_query = project.questions
72 if parent_section_id is not None:
73 question_query = question_query.filter(
74 QuestionInstance.section_id == parent_section_id
75 )
77 # LEFT JOIN to get explicit weights, defaulting to 1.0 for missing records
78 question_weights = (
79 question_query.outerjoin(
80 Weighting,
81 (Weighting.question_instance_id == QuestionInstance.id)
82 & (Weighting.weighting_set_id == weightset_id),
83 )
84 .with_entities(QuestionInstance.id, Weighting.value)
85 .order_by(QuestionInstance.id)
86 )
88 question_weights = question_weights.order_by(QuestionInstance.id)
90 for question_id, weight_value in question_weights:
91 weight = float(weight_value) if weight_value is not None else 1.0
92 questions.append({"question_instance_id": question_id, "weight": weight})
94 return {"questions": questions, "sections": sections}
97def _default_weights(
98 project: Project, parent_section_id: Optional[int] = None
99) -> WeightingsData:
100 """Default weights for all sections and questions in the project"""
101 return _ws_weights(
102 project,
103 project.get_or_create_default_weighting_set_id(),
104 parent_section_id=parent_section_id,
105 )
108def weightings_dict(
109 project: Project,
110 weightset_id: Optional[int] = None,
111 parent_section_id: Optional[int] = None,
112) -> WeightingsData:
113 """
114 Get a dictionary of weightings for all sections and
115 questions in the given project,
116 {
117 questions: [{question_instance_id: question_weight}]
118 sections: [{section_id: section_weight}]
119 },
120 for the given project and weightset_id
121 """
122 if weightset_id is None:
123 return _default_weights(project, parent_section_id=parent_section_id)
124 else:
125 return _ws_weights(project, weightset_id, parent_section_id=parent_section_id)
128def total_weightings_dict(project: Project, weightset_id: int) -> WeightingsData:
129 """
130 Get a dictionary of *total* weightings for each section
131 and question in the given project {
132 weightset: {id, name}
133 questions: {question_instance_id: total_question_weight}
134 sections: {section_id: total_section_weight}
135 },
136 for the given project and weightset_id
137 """
138 q = (
139 project.total_weightings.filter(TotalWeighting.weighting_set_id == weightset_id)
140 .with_entities(
141 TotalWeighting.question_instance_id,
142 TotalWeighting.section_id,
143 # Used by scoring and reporting systems throughout the application
144 TotalWeighting.weight,
145 )
146 .order_by(TotalWeighting.question_instance_id, TotalWeighting.section_id)
147 )
149 res: WeightingsData = {"questions": [], "sections": []}
150 sections: List[SectionWeight] = res["sections"]
151 questions: List[QuestionWeight] = res["questions"]
153 for tw in q:
154 weight_value = tw.weight
156 if tw.section_id: # zero is the default
157 sections.append({"section_id": tw.section_id, "weight": weight_value})
158 else:
159 questions.append(
160 {
161 "question_instance_id": tw.question_instance_id,
162 "weight": weight_value,
163 }
164 )
166 return res