|
1 | 1 | from datetime import date, timedelta |
| 2 | +from urllib.parse import urlencode |
2 | 3 |
|
3 | 4 | import django_filters |
4 | 5 | import django_tables2 as tables |
5 | 6 | from crispy_forms.helper import FormHelper |
6 | | -from crispy_forms.layout import Column, Layout, Row |
| 7 | +from crispy_forms.layout import Column, Field, Layout, Row |
| 8 | +from django import forms |
| 9 | +from django.contrib.auth.decorators import login_required, permission_required |
| 10 | +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin |
| 11 | +from django.db.models import F |
| 12 | +from django.http import HttpResponse |
7 | 13 | from django.urls import reverse |
8 | 14 | from django.utils.functional import cached_property |
| 15 | +from django.utils.translation import gettext as _ |
| 16 | +from django.views.decorators.http import require_GET, require_POST |
9 | 17 | from django_filters.views import FilterView |
| 18 | +from django_tables2.views import SingleTableMixin |
10 | 19 |
|
11 | | -from commcare_connect.opportunity.models import CompletedWork, DeliveryType, Opportunity |
| 20 | +from commcare_connect.opportunity.models import CompletedWork, DeliveryType, InvoiceStatus, Opportunity, PaymentInvoice |
12 | 21 | from commcare_connect.organization.models import Organization |
13 | | -from commcare_connect.program.models import Program |
| 22 | +from commcare_connect.program.models import ManagedOpportunity, Program |
14 | 23 | from commcare_connect.reports.decorators import KPIReportMixin |
15 | 24 | from commcare_connect.reports.helpers import get_table_data_for_year_month |
16 | | - |
17 | | -from .tables import AdminReportTable |
| 25 | +from commcare_connect.reports.tables import AdminReportTable, InvoiceReportTable |
| 26 | +from commcare_connect.reports.tasks import export_invoice_report_task |
| 27 | +from commcare_connect.utils.celery import download_export_file, render_export_status |
| 28 | +from commcare_connect.utils.permission_const import ALL_ORG_ACCESS |
| 29 | +from commcare_connect.utils.tables import DEFAULT_PAGE_SIZE, get_validated_page_size |
18 | 30 |
|
19 | 31 | COUNTRY_CURRENCY_CHOICES = [ |
20 | 32 | ("ETB", "Ethiopia"), |
@@ -135,3 +147,165 @@ def filter_values(self): |
135 | 147 | @property |
136 | 148 | def object_list(self): |
137 | 149 | return get_table_data_for_year_month(**self.filter_values) |
| 150 | + |
| 151 | + |
| 152 | +class InvoiceReportFilter(django_filters.FilterSet): |
| 153 | + opportunity_name = django_filters.ModelChoiceFilter( |
| 154 | + field_name="opportunity", |
| 155 | + queryset=ManagedOpportunity.objects.only("id", "name"), |
| 156 | + label=_("Opportunity"), |
| 157 | + widget=forms.Select( |
| 158 | + attrs={ |
| 159 | + "data-tomselect": "1", |
| 160 | + "placeholder": "Select Opportunity (Name - ID)", |
| 161 | + } |
| 162 | + ), |
| 163 | + ) |
| 164 | + |
| 165 | + status = django_filters.MultipleChoiceFilter( |
| 166 | + choices=InvoiceStatus.choices, |
| 167 | + label=_("Status"), |
| 168 | + widget=forms.SelectMultiple( |
| 169 | + attrs={ |
| 170 | + "data-tomselect": "1", |
| 171 | + "placeholder": "Select status", |
| 172 | + } |
| 173 | + ), |
| 174 | + ) |
| 175 | + |
| 176 | + from_date = django_filters.DateFilter( |
| 177 | + field_name="date_paid__date", |
| 178 | + lookup_expr="gte", |
| 179 | + label=_("From Payment Date"), |
| 180 | + widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), |
| 181 | + required=False, |
| 182 | + input_formats=["%Y-%m-%d"], |
| 183 | + ) |
| 184 | + |
| 185 | + to_date = django_filters.DateFilter( |
| 186 | + field_name="date_paid__date", |
| 187 | + lookup_expr="lte", |
| 188 | + label=_("To Payment Date"), |
| 189 | + widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), |
| 190 | + required=False, |
| 191 | + input_formats=["%Y-%m-%d"], |
| 192 | + ) |
| 193 | + |
| 194 | + def __init__(self, *args, **kwargs): |
| 195 | + super().__init__(*args, **kwargs) |
| 196 | + |
| 197 | + self.filters["opportunity_name"].field.label_from_instance = lambda obj: f"{obj.name} - {obj.id}" |
| 198 | + self.form.helper = FormHelper() |
| 199 | + self.form.helper.form_tag = False |
| 200 | + self.form.helper.disable_csrf = True |
| 201 | + self.form.helper.layout = Layout( |
| 202 | + Field("opportunity_name"), |
| 203 | + Field("status"), |
| 204 | + Row( |
| 205 | + Field("from_date"), |
| 206 | + Field("to_date"), |
| 207 | + css_class="grid grid-cols-2 gap-4", |
| 208 | + ), |
| 209 | + ) |
| 210 | + |
| 211 | + class Meta: |
| 212 | + model = PaymentInvoice |
| 213 | + fields = ["opportunity_name", "status", "from_date", "to_date"] |
| 214 | + |
| 215 | + |
| 216 | +class InvoiceReportView( |
| 217 | + LoginRequiredMixin, |
| 218 | + PermissionRequiredMixin, |
| 219 | + SingleTableMixin, |
| 220 | + FilterView, |
| 221 | +): |
| 222 | + model = PaymentInvoice |
| 223 | + table_class = InvoiceReportTable |
| 224 | + filterset_class = InvoiceReportFilter |
| 225 | + permission_required = ALL_ORG_ACCESS |
| 226 | + paginate_by = DEFAULT_PAGE_SIZE |
| 227 | + |
| 228 | + def get_paginate_by(self, table): |
| 229 | + return get_validated_page_size(self.request) |
| 230 | + |
| 231 | + def get_template_names(self): |
| 232 | + return ["reports/invoice_report.html"] |
| 233 | + |
| 234 | + def get_context_data(self, **kwargs): |
| 235 | + context = super().get_context_data(**kwargs) |
| 236 | + context["title"] = "Invoice Report" |
| 237 | + context["task_id"] = self.request.GET.get("task_id") |
| 238 | + |
| 239 | + if self.filterset: |
| 240 | + filter_fields = self.filterset.form.fields.keys() |
| 241 | + context["filters_applied_count"] = sum( |
| 242 | + 1 for key in filter_fields if self.filterset.data.get(key) not in ("", None) |
| 243 | + ) |
| 244 | + else: |
| 245 | + context["filters_applied_count"] = 0 |
| 246 | + |
| 247 | + return context |
| 248 | + |
| 249 | + @classmethod |
| 250 | + def get_invoice_queryset(cls): |
| 251 | + return ( |
| 252 | + PaymentInvoice.objects.select_related( |
| 253 | + "opportunity__managedopportunity__program__organization", |
| 254 | + "payment", |
| 255 | + ) |
| 256 | + .annotate( |
| 257 | + date_paid=F("payment__date_paid"), |
| 258 | + org_slug=F("opportunity__managedopportunity__program__organization__slug"), |
| 259 | + ) |
| 260 | + .order_by("-date") |
| 261 | + ) |
| 262 | + |
| 263 | + def get_queryset(self): |
| 264 | + return self.get_invoice_queryset() |
| 265 | + |
| 266 | + |
| 267 | +@require_POST |
| 268 | +@login_required |
| 269 | +@permission_required(ALL_ORG_ACCESS, raise_exception=True) |
| 270 | +def export_invoice_report(request): |
| 271 | + filterset = InvoiceReportFilter(request.POST, queryset=PaymentInvoice.objects.none()) |
| 272 | + if not filterset.is_valid(): |
| 273 | + return HttpResponse("Invalid filters", status=400) |
| 274 | + |
| 275 | + filters_data = filterset.form.cleaned_data |
| 276 | + if filters_data.get("opportunity_name"): |
| 277 | + filters_data["opportunity_name"] = filters_data["opportunity_name"].id |
| 278 | + if filters_data.get("from_date"): |
| 279 | + filters_data["from_date"] = filters_data["from_date"].isoformat() |
| 280 | + if filters_data.get("to_date"): |
| 281 | + filters_data["to_date"] = filters_data["to_date"].isoformat() |
| 282 | + |
| 283 | + task = export_invoice_report_task.delay(filters_data) |
| 284 | + |
| 285 | + # Build redirect URL preserving applied filters |
| 286 | + query_params = {k: v for k, v in filters_data.items() if v not in [None, "", []]} |
| 287 | + query_params["task_id"] = task.id |
| 288 | + redirect_url = f"{reverse('reports:invoice_report')}?{urlencode(query_params, doseq=True)}" |
| 289 | + response = HttpResponse(status=204) |
| 290 | + response["HX-Redirect"] = redirect_url |
| 291 | + return response |
| 292 | + |
| 293 | + |
| 294 | +@require_GET |
| 295 | +@login_required |
| 296 | +@permission_required(ALL_ORG_ACCESS, raise_exception=True) |
| 297 | +def export_status(request, task_id): |
| 298 | + return render_export_status( |
| 299 | + request, |
| 300 | + task_id=task_id, |
| 301 | + download_url=reverse("reports:download_export", args=(task_id,)), |
| 302 | + export_status_url=reverse("reports:export_status", args=(task_id,)), |
| 303 | + ownership_check=None, |
| 304 | + ) |
| 305 | + |
| 306 | + |
| 307 | +@require_GET |
| 308 | +@login_required |
| 309 | +@permission_required(ALL_ORG_ACCESS, raise_exception=True) |
| 310 | +def download_export(request, task_id): |
| 311 | + return download_export_file(task_id=task_id, filename_without_ext=f"invoice_export_{request.user.name}") |
0 commit comments