Coverage for postrfp/model/questionnaire/weightings.py: 100%

36 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-22 21:34 +0000

1from decimal import Decimal 

2from typing import Optional, TYPE_CHECKING 

3 

4from sqlalchemy import ForeignKey, Index, Integer, Unicode, text 

5from sqlalchemy.orm import DynamicMapped, Mapped, mapped_column, relationship 

6import sqlalchemy.types as types 

7from sqlalchemy.types import DECIMAL 

8 

9from postrfp.model.meta import Base 

10 

11if TYPE_CHECKING: 

12 from postrfp.model.questionnaire.nodes import QuestionInstance, Section 

13 from postrfp.model.project import Project 

14 

15 

16class WeightingSet(Base): 

17 __tablename__ = "weighting_sets" 

18 

19 public_attrs = ("id", "name") 

20 name: Mapped[Optional[str]] = mapped_column(Unicode(255), default=None) 

21 project_id: Mapped[int] = mapped_column( 

22 Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False 

23 ) 

24 

25 project: Mapped["Project"] = relationship( 

26 "Project", back_populates="weighting_sets", foreign_keys=[project_id] 

27 ) 

28 

29 default_for_projects: Mapped[list["Project"]] = relationship( 

30 "Project", 

31 back_populates="default_weighting_set", 

32 foreign_keys="Project.default_weighting_set_id", 

33 viewonly=True, 

34 ) 

35 

36 weightings: DynamicMapped["Weighting"] = relationship( 

37 "Weighting", 

38 back_populates="weighting_set", 

39 lazy="dynamic", 

40 cascade="all,delete", 

41 passive_deletes=True, 

42 ) 

43 

44 def __repr__(self): 

45 return f'Weighting Set ID: {self.id} "{self.name}"' 

46 

47 

48WEIGHTING_COL_TYPE: types.TypeEngine = DECIMAL(precision=15, scale=4) 

49 

50 

51class Weighting(Base): 

52 __tablename__ = "weightings" 

53 

54 weighting_set_id: Mapped[int] = mapped_column( 

55 ForeignKey("weighting_sets.id", ondelete="CASCADE"), 

56 nullable=False, 

57 ) 

58 question_instance_id: Mapped[Optional[int]] = mapped_column( 

59 ForeignKey("question_instances.id", ondelete="CASCADE") 

60 ) 

61 section_id: Mapped[Optional[int]] = mapped_column( 

62 ForeignKey("sections.id", ondelete="CASCADE") 

63 ) 

64 value: Mapped[Decimal] = mapped_column( 

65 WEIGHTING_COL_TYPE, server_default=text("'1.0000'"), nullable=False 

66 ) 

67 

68 question: Mapped[Optional["QuestionInstance"]] = relationship("QuestionInstance") 

69 section: Mapped[Optional["Section"]] = relationship("Section") 

70 weighting_set: Mapped["WeightingSet"] = relationship( 

71 WeightingSet, back_populates="weightings" 

72 ) 

73 

74 def __repr__(self): 

75 return f"<Weighting {self.id} - {self.value}>" 

76 

77 

78class TotalWeighting(Base): 

79 """ 

80 The total_weightings table is essentially a cache. It saves the absolute, normalised weight 

81 values for question instances and sections for a given weighting_set_id. Null weighting_set_id 

82 indicates the default weighting set. 

83 

84 Absolute weights are derived hierachically - the entire questionnaire needs to be loaded 

85 as a tree structure in order to figure out these weights. e.g. question weight depends 

86 on the weights of parent sections. It is therefore expense to derive on the fly - hence 

87 the requirement for this table. 

88 

89 The cache is recalculated when weightings are saved 

90 """ 

91 

92 __tablename__ = "total_weightings" 

93 __table_args__ = ( 

94 Index( 

95 "idx_total_weightings_proj_weightset_qid_secid", 

96 "project_id", 

97 "weighting_set_id", 

98 "question_instance_id", 

99 "section_id", 

100 unique=True, 

101 ), 

102 ) 

103 

104 project_id: Mapped[int] = mapped_column( 

105 ForeignKey( 

106 "projects.id", ondelete="CASCADE" 

107 ), # Removed name="total_weightings_wset" 

108 nullable=False, 

109 ) 

110 weighting_set_id: Mapped[Optional[int]] = mapped_column( 

111 Integer, nullable=True, default=None 

112 ) 

113 question_instance_id: Mapped[int] = mapped_column( 

114 Integer, nullable=False, server_default=text("'0'") 

115 ) 

116 section_id: Mapped[int] = mapped_column( 

117 Integer, nullable=False, server_default=text("'0'") 

118 ) 

119 # Used by scoring, reporting, and other systems throughout the application 

120 weight: Mapped[Optional[Decimal]] = mapped_column(WEIGHTING_COL_TYPE, nullable=True) 

121 

122 # Additional columns for weight analysis and debugging 

123 normalised_weight: Mapped[Optional[Decimal]] = mapped_column( 

124 WEIGHTING_COL_TYPE, nullable=True 

125 ) 

126 absolute_weight: Mapped[Optional[Decimal]] = mapped_column( 

127 WEIGHTING_COL_TYPE, nullable=True 

128 ) 

129 

130 project: Mapped["Project"] = relationship( 

131 "Project", 

132 back_populates="total_weightings", 

133 ) 

134 

135 def __repr__(self): 

136 return f"<TotalWeighting {self.id} - {self.weight}>"