Coverage for claims\views.py: 93%

109 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-14 18:28 -0400

1import logging 

2from typing import Any, Dict 

3 

4from django.contrib.auth.mixins import LoginRequiredMixin 

5from django.db.models import Q, QuerySet 

6from django.http import HttpRequest, HttpResponse 

7from django.shortcuts import get_object_or_404, render 

8from django.utils import timezone 

9from django.views.generic import DetailView, ListView, View, FormView 

10from django.urls import reverse_lazy 

11from django.contrib import messages 

12from .forms import RegistrationForm 

13 

14from .models import Claim, Note 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19class ClaimListView(LoginRequiredMixin, ListView): 

20 """ 

21 Displays a list of all claims in the Claims Dashboard. 

22 

23 This view supports: 

24 - Searching by insurer name and patient name. 

25 - Filtering by claim status and flagged status. 

26 - Sorting on multiple fields. 

27 - HTMX-powered partial updates for filtering, sorting, and infinite scroll. 

28 """ 

29 

30 model = Claim 

31 template_name = "claims/claim_list.html" 

32 context_object_name = "claims" 

33 paginate_by = 50 

34 

35 def _apply_search_filter(self, queryset: QuerySet, search_query: str) -> QuerySet: 

36 """Applies a search filter to the queryset based on patient or insurer name.""" 

37 if search_query: 

38 return queryset.filter( 

39 Q(patient_name__icontains=search_query) 

40 | Q(insurer_name__icontains=search_query) 

41 ) 

42 return queryset 

43 

44 def _apply_status_filter(self, queryset: QuerySet, status_filter: str) -> QuerySet: 

45 """Applies a filter for a specific claim status.""" 

46 if status_filter: 

47 return queryset.filter(status=status_filter.upper()) 

48 return queryset 

49 

50 def _apply_flagged_filter( 

51 self, queryset: QuerySet, flagged_filter: str 

52 ) -> QuerySet: 

53 """Applies a filter to show only flagged claims.""" 

54 if flagged_filter == "true": 

55 return queryset.filter(is_flagged=True) 

56 return queryset 

57 

58 def _apply_sorting(self, queryset: QuerySet, sort_by: str) -> QuerySet: 

59 """Applies sorting to the queryset based on the provided field.""" 

60 allowed_sort_fields = [ 

61 "id", 

62 "patient_name", 

63 "billed_amount", 

64 "paid_amount", 

65 "status", 

66 "insurer_name", 

67 "discharge_date", 

68 ] 

69 # Fallback to a default sort order if the requested field is not allowed. 

70 sort_field = sort_by.lstrip("-") 

71 if sort_field not in allowed_sort_fields: 

72 sort_by = "-discharge_date" 

73 return queryset.order_by(sort_by) 

74 

75 def get_queryset(self) -> QuerySet[Claim]: 

76 """ 

77 Overrides the default queryset to implement search, filter, and sorting logic. 

78 """ 

79 queryset = super().get_queryset().select_related("details") 

80 

81 # Get search/filter parameters from the request URL. 

82 search_query = self.request.GET.get("search", "") 

83 status_filter = self.request.GET.get("status", "") 

84 flagged_filter = self.request.GET.get("flagged", "") 

85 sort_by = self.request.GET.get("sort", "id") # Default sort. 

86 

87 # Chain the filtering and sorting methods. 

88 queryset = self._apply_search_filter(queryset, search_query) 

89 queryset = self._apply_status_filter(queryset, status_filter) 

90 queryset = self._apply_flagged_filter(queryset, flagged_filter) 

91 queryset = self._apply_sorting(queryset, sort_by) 

92 

93 return queryset 

94 

95 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: 

96 """ 

97 Adds filter and sorting values to the context for use in the template. 

98 """ 

99 context = super().get_context_data(**kwargs) 

100 

101 # Pass the request object to the template for the custom template tags. 

102 context["request"] = self.request 

103 

104 # Provide current filter values to the template to repopulate controls. 

105 context["claim_statuses"] = Claim.ClaimStatus.choices 

106 context["current_search"] = self.request.GET.get("search", "") 

107 context["current_status"] = self.request.GET.get("status", "") 

108 context["current_flagged"] = self.request.GET.get("flagged", "") 

109 

110 # Provide current sort values to the template. 

111 sort_param = self.request.GET.get("sort", "-discharge_date") 

112 context["current_sort_param"] = sort_param 

113 context["current_sort_field"] = sort_param.lstrip("-") 

114 context["current_sort_dir"] = "desc" if sort_param.startswith("-") else "asc" 

115 

116 return context 

117 

118 def get_template_names(self) -> list[str]: 

119 """ 

120 If the request is from HTMX, return the appropriate partial template. 

121 - For infinite scroll, return just the table rows. 

122 - For sorting/filtering, return the entire table body. 

123 """ 

124 if self.request.htmx: 

125 if self.request.GET.get("_partial") == "rows": 

126 return ["claims/partials/_claim_rows.html"] 

127 return ["claims/partials/_claims_table.html"] 

128 return [self.template_name] 

129 

130 

131class ClaimDetailView(LoginRequiredMixin, DetailView): 

132 """ 

133 Handles fetching and displaying the details for a single claim. This view 

134 is designed to be rendered within the main list view via an HTMX request, 

135 to avoid a full page reload. 

136 """ 

137 

138 model = Claim 

139 template_name = "claims/partials/_claim_detail.html" 

140 context_object_name = "claim" 

141 pk_url_kwarg = "claim_id" 

142 

143 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: 

144 """Adds the claim's notes to the context, ordered by most recent first.""" 

145 context = super().get_context_data(**kwargs) 

146 context["notes"] = self.object.notes.all().order_by("-created_at") 

147 return context 

148 

149 

150class ToggleFlagView(LoginRequiredMixin, View): 

151 """ 

152 Handles POST requests to toggle the 'is_flagged' status of a claim. 

153 This view returns an HTML partial of the updated flag button and triggers 

154 an HTMX event to refresh the claim detail view if it's open. 

155 """ 

156 

157 def post(self, request: HttpRequest, claim_id: int) -> HttpResponse: 

158 claim = get_object_or_404(Claim, id=claim_id) 

159 claim.is_flagged = not claim.is_flagged 

160 

161 if claim.is_flagged: 

162 claim.flagged_by = request.user 

163 claim.flagged_at = timezone.now() 

164 else: 

165 claim.flagged_by = None 

166 claim.flagged_at = None 

167 

168 claim.save(update_fields=["is_flagged", "flagged_by", "flagged_at"]) 

169 

170 logger.info( 

171 f"User '{request.user.username}' (ID: {request.user.id}) " 

172 f"set is_flagged to {claim.is_flagged} for Claim ID {claim_id}." 

173 ) 

174 

175 # Render the button's HTML partial in response to the POST request. 

176 response = render( 

177 request, "claims/partials/_flag_button.html", {"claim": claim} 

178 ) 

179 

180 # Add a special HTMX header to broadcast an event with the claim's ID. 

181 # This allows other components on the page to react to the change. 

182 response["HX-Trigger"] = f"refresh-claim-detail-{claim_id}" 

183 

184 return response 

185 

186 

187class AddNoteView(LoginRequiredMixin, View): 

188 """ 

189 Handles POST requests to add a new note to a claim. 

190 Returns an HTML partial of the updated notes section. 

191 """ 

192 

193 def post(self, request: HttpRequest, claim_id: int) -> HttpResponse: 

194 claim = get_object_or_404(Claim, id=claim_id) 

195 note_text = request.POST.get("note", "").strip() 

196 

197 if note_text: 

198 note = Note.objects.create(claim=claim, note=note_text, user=request.user) 

199 logger.info( 

200 f"User '{request.user.username}' (ID: {request.user.id}) " 

201 f"added Note ID {note.id} to Claim ID {claim_id}." 

202 ) 

203 

204 # Return the updated notes list, ordered by most recent first. 

205 notes = claim.notes.all().order_by("-created_at") 

206 return render(request, "claims/partials/_notes_section.html", {"notes": notes}) 

207 

208 

209class FlagButtonView(LoginRequiredMixin, View): 

210 """ 

211 Returns the current flag button partial for a claim without mutating state. 

212 Used to refresh the flag icon in the list row when the flag is toggled elsewhere. 

213 """ 

214 

215 def get(self, request: HttpRequest, claim_id: int) -> HttpResponse: 

216 claim = get_object_or_404(Claim, id=claim_id) 

217 return render(request, "claims/partials/_flag_button.html", {"claim": claim}) 

218 

219 

220class RegisterView(FormView): 

221 """Allow users to create an account and then log in.""" 

222 

223 template_name = "registration/register.html" 

224 form_class = RegistrationForm 

225 success_url = reverse_lazy("login") 

226 

227 def form_valid(self, form) -> HttpResponse: 

228 form.save() 

229 messages.success(self.request, "Account created! You can now log in.") 

230 return super().form_valid(form)