Skip to content

Commit 23c8995

Browse files
Merge pull request dimagi#911 from dimagi/hy/inv-adm-rep
Invoice Admin Report
2 parents 9345477 + 0410cfc commit 23c8995

File tree

8 files changed

+458
-31
lines changed

8 files changed

+458
-31
lines changed

commcare_connect/opportunity/views.py

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@
181181
from commcare_connect.program.utils import is_program_manager
182182
from commcare_connect.users.models import User
183183
from commcare_connect.utils.analytics import GA_CUSTOM_DIMENSIONS, Event, GATrackingInfo, send_event_to_ga
184-
from commcare_connect.utils.celery import CELERY_TASK_SUCCESS, get_task_progress_message
184+
from commcare_connect.utils.celery import download_export_file, render_export_status
185185
from commcare_connect.utils.file import get_file_extension
186186
from commcare_connect.utils.flags import FlagLabels, Flags
187187
from commcare_connect.utils.tables import PAGE_SIZE_OPTIONS, get_duration_min, get_validated_page_size
@@ -503,40 +503,29 @@ def review_visit_export(request, org_slug, opp_id):
503503
@org_member_required
504504
@require_GET
505505
def export_status(request, org_slug, task_id):
506-
task = AsyncResult(task_id)
507-
task_meta = task._get_task_meta()
508-
opportunity_id = task_meta.get("args")[0]
509-
# Make sure opportunity exists for the given org_slug
510-
get_opportunity_or_404(org_slug=org_slug, pk=opportunity_id)
511-
status = task_meta.get("status")
512-
progress = {"complete": status == CELERY_TASK_SUCCESS, "message": get_task_progress_message(task)}
513-
if status == "FAILURE":
514-
progress["error"] = task_meta.get("result")
515-
return render(
506+
def ownership_check(request, task_meta):
507+
opportunity_id = task_meta.get("args")[0]
508+
get_opportunity_or_404(org_slug=org_slug, pk=opportunity_id)
509+
510+
return render_export_status(
516511
request,
517-
"components/upload_progress_bar.html",
518-
{
519-
"task_id": task_id,
520-
"current_time": now().microsecond,
521-
"progress": progress,
522-
},
512+
task_id=task_id,
513+
download_url=reverse("opportunity:download_export", args=(org_slug, task_id)),
514+
export_status_url=reverse("opportunity:export_status", args=(org_slug, task_id)),
515+
ownership_check=ownership_check,
523516
)
524517

525518

526519
@org_member_required
527520
@require_GET
528521
def download_export(request, org_slug, task_id):
529522
task_meta = AsyncResult(task_id)._get_task_meta()
530-
saved_filename = task_meta.get("result")
531523
opportunity_id = task_meta.get("args")[0]
532524
opportunity = get_opportunity_or_404(org_slug=org_slug, pk=opportunity_id)
533525
op_slug = slugify(opportunity.name)
534-
export_format = saved_filename.split(".")[-1]
535-
filename = f"{org_slug}_{op_slug}_export.{export_format}"
536-
537-
export_file = storages["default"].open(saved_filename)
538-
return FileResponse(
539-
export_file, as_attachment=True, filename=filename, content_type=TableExport.FORMATS[export_format]
526+
return download_export_file(
527+
task_id=task_id,
528+
filename_without_ext=f"{org_slug}_{op_slug}_export",
540529
)
541530

542531

commcare_connect/reports/tables.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
from django.urls import reverse
2+
from django.utils.html import format_html
3+
from django.utils.translation import gettext as _
14
from django_tables2 import columns, tables
25

6+
from commcare_connect.opportunity.models import PaymentInvoice
37
from commcare_connect.opportunity.tables import SumColumn
8+
from commcare_connect.utils.tables import DMYTColumn
49

510

611
class AdminReportTable(tables.Table):
@@ -34,3 +39,57 @@ def render_avg_time_to_payment(self, record, value):
3439

3540
def render_max_time_to_payment(self, record, value):
3641
return f"{value} days"
42+
43+
44+
class InvoiceReportTable(tables.Table):
45+
opportunity_id = columns.Column(
46+
accessor="opportunity__id",
47+
verbose_name=_("Opportunity ID"),
48+
)
49+
opportunity_name = columns.Column(
50+
accessor="opportunity__name",
51+
verbose_name=_("Opportunity Name"),
52+
)
53+
invoice_number = columns.Column(orderable=False, verbose_name=_("Invoice Number"))
54+
amount = columns.Column(verbose_name=_("Amount"))
55+
amount_usd = columns.Column(verbose_name=_("Amount (USD)"))
56+
invoice_type = columns.Column(verbose_name=_("Type"), accessor="service_delivery")
57+
status = columns.Column(verbose_name=_("Status"))
58+
date = DMYTColumn(verbose_name=_("Date of Payment"), accessor="date_paid")
59+
60+
class Meta:
61+
model = PaymentInvoice
62+
fields = (
63+
"opportunity_id",
64+
"opportunity_name",
65+
"invoice_number",
66+
"amount",
67+
"amount_usd",
68+
"invoice_type",
69+
"status",
70+
"date",
71+
)
72+
73+
def render_invoice_number(self, value, record):
74+
url = reverse(
75+
"opportunity:invoice_review",
76+
args=[record.org_slug, record.opportunity_id, record.id],
77+
)
78+
return format_html(
79+
'<a href="{}" class="underline text-brand-deep-purple">{}</a>',
80+
url,
81+
value,
82+
)
83+
84+
def value_invoice_number(self, value, record):
85+
return value
86+
87+
def render_amount(self, record):
88+
return f"{record.opportunity.currency_code} {record.amount}"
89+
90+
def render_invoice_type(self, record):
91+
return (
92+
PaymentInvoice.InvoiceType.service_delivery.label
93+
if record.service_delivery
94+
else PaymentInvoice.InvoiceType.custom.label
95+
)

commcare_connect/reports/tasks.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import logging
2+
import uuid
23
from collections import defaultdict
34
from itertools import chain
45

6+
from django.core.files.base import ContentFile
7+
from django.core.files.storage import default_storage
58
from django.db.models import Case, Count, DateTimeField, F, IntegerField, Max, Min, Q, Sum, Value, When
69
from django.db.models.lookups import GreaterThanOrEqual
10+
from django_tables2.export.export import TableExport
711

812
from commcare_connect.connect_id_client.main import fetch_user_analytics
913
from commcare_connect.opportunity.models import CompletedWorkStatus, OpportunityAccess
@@ -128,3 +132,20 @@ def sync_user_analytics_data():
128132
)
129133

130134
logger.info(f"Updated UserAnalyticsData for {len(result)} users.")
135+
136+
137+
@celery_app.task()
138+
def export_invoice_report_task(filters_data):
139+
from commcare_connect.reports.views import InvoiceReportFilter, InvoiceReportTable, InvoiceReportView
140+
141+
logger.info("Starting invoice report export task with filters: %s", filters_data)
142+
143+
qs = InvoiceReportView.get_invoice_queryset()
144+
filterset = InvoiceReportFilter(filters_data, queryset=qs)
145+
table = InvoiceReportTable(filterset.qs)
146+
147+
exporter = TableExport("csv", table)
148+
filename = f"invoice-report-{uuid.uuid4()}.csv"
149+
content = exporter.export()
150+
default_storage.save(filename, ContentFile(content.encode("utf-8")))
151+
return filename

commcare_connect/reports/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,8 @@
66

77
urlpatterns = [
88
path("delivery_stats", view=views.DeliveryStatsReportView.as_view(), name="delivery_stats_report"),
9+
path("invoice_report", view=views.InvoiceReportView.as_view(), name="invoice_report"),
10+
path("export_invoice_report", view=views.export_invoice_report, name="export_invoice_report"),
11+
path("export_status/<slug:task_id>", view=views.export_status, name="export_status"),
12+
path("download_export/<slug:task_id>", view=views.download_export, name="download_export"),
913
]

commcare_connect/reports/views.py

Lines changed: 179 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
from datetime import date, timedelta
2+
from urllib.parse import urlencode
23

34
import django_filters
45
import django_tables2 as tables
56
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
713
from django.urls import reverse
814
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
917
from django_filters.views import FilterView
18+
from django_tables2.views import SingleTableMixin
1019

11-
from commcare_connect.opportunity.models import CompletedWork, DeliveryType, Opportunity
20+
from commcare_connect.opportunity.models import CompletedWork, DeliveryType, InvoiceStatus, Opportunity, PaymentInvoice
1221
from commcare_connect.organization.models import Organization
13-
from commcare_connect.program.models import Program
22+
from commcare_connect.program.models import ManagedOpportunity, Program
1423
from commcare_connect.reports.decorators import KPIReportMixin
1524
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
1830

1931
COUNTRY_CURRENCY_CHOICES = [
2032
("ETB", "Ethiopia"),
@@ -135,3 +147,165 @@ def filter_values(self):
135147
@property
136148
def object_list(self):
137149
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}")

commcare_connect/templates/components/upload_progress_bar.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<div
22
class="flex items-center justify-center"
33
{% if not progress.complete %}
4-
hx-get="{% url 'opportunity:export_status' request.org.slug task_id %}"
4+
hx-get="{{ export_status_url }}"
55
hx-trigger="load delay:2s"
66
hx-target="this"
77
hx-swap="outerHTML"
@@ -34,7 +34,7 @@
3434
{% if not progress.message %}
3535
<div>
3636
<a class="button button-sm text-brand-deep-purple"
37-
href="{% url 'opportunity:download_export' request.org.slug task_id %}">
37+
href="{{ download_url }}">
3838
<i class="fa-solid fa-circle-down"></i> Download Export
3939
</a>
4040
</div>

0 commit comments

Comments
 (0)