Coverage for claims\views.py: 93%
109 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-14 18:28 -0400
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-14 18:28 -0400
1import logging
2from typing import Any, Dict
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
14from .models import Claim, Note
16logger = logging.getLogger(__name__)
19class ClaimListView(LoginRequiredMixin, ListView):
20 """
21 Displays a list of all claims in the Claims Dashboard.
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 """
30 model = Claim
31 template_name = "claims/claim_list.html"
32 context_object_name = "claims"
33 paginate_by = 50
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
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
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
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)
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")
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.
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)
93 return queryset
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)
101 # Pass the request object to the template for the custom template tags.
102 context["request"] = self.request
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", "")
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"
116 return context
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]
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 """
138 model = Claim
139 template_name = "claims/partials/_claim_detail.html"
140 context_object_name = "claim"
141 pk_url_kwarg = "claim_id"
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
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 """
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
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
168 claim.save(update_fields=["is_flagged", "flagged_by", "flagged_at"])
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 )
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 )
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}"
184 return response
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 """
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()
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 )
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})
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 """
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})
220class RegisterView(FormView):
221 """Allow users to create an account and then log in."""
223 template_name = "registration/register.html"
224 form_class = RegistrationForm
225 success_url = reverse_lazy("login")
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)