Coverage for postrfp / model / questionnaire / renumber.py: 100%
29 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"""
2Section and Question Numbering Module
4This module provides efficient functions for renumbering sections and questions
5in hierarchical trees. It uses a base36-encoded materialized path approach where
6each two characters in a node's b36_number represent a level in the hierarchy.
8The algorithm works as follows:
91. Fetch all relevant sections and questions as lightweight tuples
102. Build a parent-to-children lookup dictionary to represent the hierarchy
113. Traverse the hierarchy to generate new node numbers based on position
124. Apply all updates to the database in a single bulk operation
14Features:
15- Supports renumbering from any section in the hierarchy (not just the root)
16- Uses efficient queries with materialized path pattern matching
17- Performs bulk updates for better performance with large trees
19Example:
20 To renumber a section and all its descendants:
22 ```python
23 from postrfp.model.questionnaire.renumber import renumber_tree
24 from sqlalchemy.orm import Session
26 session = Session()
27 section = session.get(Section, section_id)
28 renumber_tree(session, section)
29 ```
30"""
32from sqlalchemy.orm import Session
33from sqlalchemy import update, select, func, Integer
35from postrfp.model import Section, QuestionInstance
36from postrfp.model.questionnaire.b36 import materialised_paths
39def fetch_section_tuples(session: Session, section):
40 """
41 Fetch the section and all its descendants as lightweight tuples,
42 using recursive query based on parent_id relationship instead of b36_number.
44 Args:
45 session: SQLAlchemy session
46 section: The section to start renumbering from
47 """
49 # Define the base table for sections
50 sections = Section.__table__
52 # Base case: the section itself
53 base_query = (
54 select(
55 sections.c.id.cast(Integer).label("id"),
56 sections.c.parent_id.cast(Integer).label("parent_id"),
57 sections.c.position.cast(Integer).label("position"),
58 )
59 .where(sections.c.id == section.id)
60 .where(sections.c.project_id == section.project_id)
61 )
63 # Create the recursive CTE
64 descendants_cte = base_query.cte(name="descendants", recursive=True)
66 # Recursive case: all children of sections already in the result
67 recursive_query = (
68 select(
69 sections.c.id.cast(Integer).label("id"),
70 sections.c.parent_id.cast(Integer).label("parent_id"),
71 sections.c.position.cast(Integer).label("position"),
72 )
73 .select_from(
74 sections.join(descendants_cte, sections.c.parent_id == descendants_cte.c.id)
75 )
76 .where(sections.c.project_id == section.project_id)
77 )
79 # Union the base case and recursive case
80 descendants_cte = descendants_cte.union_all(recursive_query)
82 # Final query to select from the CTE
83 final_query = select(
84 descendants_cte.c.id, descendants_cte.c.parent_id, descendants_cte.c.position
85 ).order_by(descendants_cte.c.parent_id, descendants_cte.c.position)
87 result = session.execute(final_query)
88 return result.all()
91def save_section_numbers(session, section_numbers):
92 """Apply the generated node numbers to the database using ORM updates to ensure proper type conversion"""
94 # Get all the sections that need updating
95 sections_to_update = (
96 session.query(Section).filter(Section.id.in_(section_numbers.keys())).all()
97 )
99 # Update each section through the ORM to ensure type conversion
100 for section in sections_to_update:
101 section.b36_number = section_numbers[section.id]
103 session.flush()
105 # Verify the values were saved correctly
106 for section in sections_to_update:
107 session.refresh(section)
110def update_qinstance_numbers(session: Session, section_ids):
111 """
112 Derive "b36_number" values for all QuestionInstances based on the
113 b36_number of parent section b36_number
114 """
115 # Create a subquery to get the section numbers
116 section_query = (
117 select(Section.id, Section.b36_number)
118 .where(Section.id.in_(section_ids))
119 .subquery("section_numbers")
120 )
121 stmt = (
122 update(QuestionInstance)
123 .where(QuestionInstance.section_id.in_(section_ids))
124 .values(
125 b36_number=func.concat(
126 section_query.c.b36_number,
127 func.lpad(func.conv(QuestionInstance.position, 10, 36), 2, "0"),
128 )
129 )
130 .where(QuestionInstance.section_id == section_query.c.id)
131 )
132 session.execute(stmt)
135def renumber_tree(session: Session, root_section: Section) -> None:
136 """
137 Efficiently renumber a section tree and all its questions.
138 Works with any section at any level of the hierarchy.
140 Args:
141 session: The SQLAlchemy session
142 section: The Section object to renumber from (can be at any level)
143 """
144 # Fetch all sections
145 sec_nodes = fetch_section_tuples(session, root_section)
146 # build a dict of {id: b36_number} (path) - this is the main algorithm
147 sec_paths = materialised_paths(sec_nodes, root_section)
148 # save the new b36_number values to Sections by id
149 save_section_numbers(session, sec_paths)
150 # Use the section b36_numbers to derive the question b36_numbers (in sql)
151 update_qinstance_numbers(session, sec_paths.keys())