Coverage for backend/django/Economics/studies/services/configuration_api.py: 76%

116 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +0000

1"""API workflow helpers for study configuration endpoints.""" 

2 

3from __future__ import annotations 

4 

5from django.core.exceptions import ObjectDoesNotExist 

6from pydantic import ValidationError as PydanticValidationError 

7from rest_framework.exceptions import NotFound, ValidationError 

8 

9from Economics.costing.operating.stream_properties import sync_project_default_operating_line_rates_for_study 

10from Economics.results.services.comparison.config import save_comparison_config 

11from Economics.results.services.comparison.contracts import ( 

12 ComparisonValidationError, 

13 EconomicsStudyComparisonRequest, 

14) 

15from Economics.results.services.comparison.payloads import saved_comparison_payload 

16from Economics.scheduling.composite.contracts import CompositeSchedulePlanRequest 

17from Economics.scheduling.composite.plan_services import ( 

18 CompositeScheduleValidationError, 

19 preview_schedule_plan, 

20 replace_schedule_plan, 

21 saved_schedule_plan_payload, 

22) 

23from Economics.scheduling.serializers import ScheduleScenarioOptionSerializer 

24from Economics.scheduling.services import compatible_schedule_scenarios 

25from Economics.settings_profiles.serializers import EconomicsAssumptionsSerializer, EconomicsBaselineSerializer 

26from Economics.settings_profiles.services.settings_profiles import ( 

27 assumptions_from_settings_profile, 

28 baseline_from_settings_profile, 

29 update_settings_profile_from_assumptions, 

30 update_settings_profile_from_baseline, 

31) 

32from Economics.shared.access import require_write_access 

33from Economics.studies.models import EconomicsStudy 

34from Economics.studies.services.result_staleness import mark_study_stale 

35 

36 

37def assumptions_data( 

38 *, 

39 study: EconomicsStudy, 

40 method: str, 

41 data, 

42 user, 

43 serializer_context: dict, 

44) -> dict: 

45 """Return or mutate legacy assumptions backed by the selected settings profile.""" 

46 

47 if method == "GET": 

48 assumptions = assumptions_from_settings_profile(study) 

49 if assumptions is not None: 49 ↛ 51line 49 didn't jump to line 51 because the condition on line 49 was always true

50 return EconomicsAssumptionsSerializer(assumptions, context=serializer_context).data 

51 try: 

52 assumptions = study.assumptions 

53 except ObjectDoesNotExist as exc: 

54 raise NotFound("Economics assumptions have not been configured for this study.") from exc 

55 return EconomicsAssumptionsSerializer(assumptions, context=serializer_context).data 

56 

57 require_write_access(user) 

58 try: 

59 instance = study.assumptions 

60 except ObjectDoesNotExist: 

61 instance = None 

62 serializer = EconomicsAssumptionsSerializer( 

63 instance, 

64 data={**data, "study": study.pk}, 

65 partial=method == "PATCH", 

66 context=serializer_context, 

67 ) 

68 serializer.is_valid(raise_exception=True) 

69 field_names = None if method != "PATCH" else set(serializer.validated_data) 

70 assumptions = serializer.save() 

71 update_settings_profile_from_assumptions(assumptions, field_names=field_names) 

72 sync_project_default_operating_line_rates_for_study(study, reason="economics_assumptions_api_saved") 

73 mark_study_stale(study, reason="economics_assumptions_api_saved") 

74 return EconomicsAssumptionsSerializer(assumptions, context=serializer_context).data 

75 

76 

77def baseline_data( 

78 *, 

79 study: EconomicsStudy, 

80 method: str, 

81 data, 

82 user, 

83 serializer_context: dict, 

84) -> dict: 

85 """Return or mutate legacy baseline values backed by the selected settings profile.""" 

86 

87 if method == "GET": 

88 baseline = baseline_from_settings_profile(study) 

89 if baseline is not None: 89 ↛ 91line 89 didn't jump to line 91 because the condition on line 89 was always true

90 return EconomicsBaselineSerializer(baseline, context=serializer_context).data 

91 try: 

92 baseline = study.baseline 

93 except ObjectDoesNotExist as exc: 

94 raise NotFound("Economics baseline has not been configured for this study.") from exc 

95 return EconomicsBaselineSerializer(baseline, context=serializer_context).data 

96 

97 require_write_access(user) 

98 try: 

99 instance = study.baseline 

100 except ObjectDoesNotExist: 

101 instance = None 

102 serializer = EconomicsBaselineSerializer( 

103 instance, 

104 data={**data, "study": study.pk}, 

105 partial=method == "PATCH", 

106 context=serializer_context, 

107 ) 

108 serializer.is_valid(raise_exception=True) 

109 field_names = None if method != "PATCH" else set(serializer.validated_data) 

110 baseline = serializer.save() 

111 update_settings_profile_from_baseline(baseline, field_names=field_names) 

112 mark_study_stale(study, reason="economics_baseline_api_saved") 

113 return EconomicsBaselineSerializer(baseline, context=serializer_context).data 

114 

115 

116def comparison_data( 

117 *, 

118 study: EconomicsStudy, 

119 user, 

120 method: str, 

121 data, 

122) -> dict: 

123 """Return or replace a study's saved comparison configuration.""" 

124 

125 if method == "PUT": 

126 require_write_access(user) 

127 try: 

128 comparison_request = EconomicsStudyComparisonRequest.model_validate(data) 

129 except PydanticValidationError as exc: 

130 raise ValidationError(exc.errors()) from exc 

131 try: 

132 save_comparison_config( 

133 active_study=study, 

134 user=user, 

135 comparison_study_ids=comparison_request.comparison_study_ids, 

136 baseline_mode=comparison_request.baseline_mode, 

137 baseline_study_id=comparison_request.baseline_study_id, 

138 ) 

139 except ComparisonValidationError as exc: 

140 raise ValidationError({"comparison_study_ids": str(exc)}) from exc 

141 return saved_comparison_payload(active_study=study, user=user).model_dump(mode="json") 

142 

143 

144def schedule_options_data(study: EconomicsStudy) -> list[dict]: 

145 """Return selectable schedule scenarios for a study.""" 

146 

147 return ScheduleScenarioOptionSerializer(compatible_schedule_scenarios(study), many=True).data 

148 

149 

150def schedule_plan_data( 

151 *, 

152 study: EconomicsStudy, 

153 user, 

154 method: str, 

155 data, 

156) -> dict: 

157 """Return or replace a study's saved composite schedule plan.""" 

158 

159 if method == "PUT": 

160 require_write_access(user) 

161 try: 

162 schedule_request = CompositeSchedulePlanRequest.model_validate(data) 

163 except PydanticValidationError as exc: 

164 raise _schedule_plan_request_validation_error(exc) from exc 

165 try: 

166 payload = replace_schedule_plan(study=study, request=schedule_request) 

167 except CompositeScheduleValidationError as exc: 

168 raise ValidationError({"rules": str(exc)}) from exc 

169 mark_study_stale(study, reason="economics_schedule_selected") 

170 return payload.model_dump(mode="json") 

171 return saved_schedule_plan_payload(study).model_dump(mode="json") 

172 

173 

174def schedule_plan_preview_data(*, study: EconomicsStudy, data) -> dict: 

175 """Return a composite schedule preview without mutating the saved plan.""" 

176 

177 try: 

178 schedule_request = CompositeSchedulePlanRequest.model_validate(data) 

179 except PydanticValidationError as exc: 

180 raise _schedule_plan_request_validation_error(exc) from exc 

181 return preview_schedule_plan(study=study, request=schedule_request).model_dump(mode="json") 

182 

183 

184def _schedule_plan_request_validation_error(exc: PydanticValidationError) -> ValidationError: 

185 """Convert Pydantic internals into product-language schedule errors.""" 

186 

187 messages = [_schedule_plan_error_message(error) for error in exc.errors()] 

188 return ValidationError({"rules": list(dict.fromkeys(messages))}) 

189 

190 

191def _schedule_plan_error_message(error: dict) -> str: 

192 """Return the user-facing error for one invalid composite schedule field.""" 

193 

194 loc = tuple(error.get("loc") or ()) 

195 error_type = str(error.get("type") or "") 

196 if "label" in loc: 

197 return "Rule labels must be 128 characters or fewer." 

198 if loc == ("rules",) and error_type in {"too_short", "too_long"}: 198 ↛ 200line 198 didn't jump to line 200 because the condition on line 198 was always true

199 return "Add between 1 and 64 timetable rules." 

200 if "source_scenario" in loc: 

201 return "Select a source scenario for each timetable rule." 

202 if "days_mask" in loc: 

203 return "Choose at least one day for each timetable rule." 

204 if "start_time" in loc: 

205 return "Check each rule's start time." 

206 message = str(error.get("msg") or "") 

207 if message.startswith("Value error, "): 

208 message = message.removeprefix("Value error, ") 

209 return message or "Check the composite schedule rules."