Skip to content

Commit 4e15585

Browse files
authored
CSV export for invoices (#4894)
Fixes #4875 Add CSV export for invoices, implemented in the same way as submission CSV export. ## Test Steps - [ ] Test that CSV exports work
1 parent 979843c commit 4e15585

13 files changed

Lines changed: 500 additions & 15 deletions

File tree

hypha/apply/funds/templates/funds/includes/table_filter_and_search.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ <h2 class="section-header">{{ heading }}</h2>
4949
</label>
5050
</form>
5151
{% endif %}
52+
53+
{% if invoice_export %}
54+
<a
55+
class="btn btn-square"
56+
hx-get="{% url 'apply:projects:invoice-export-status' %}"
57+
hx-swap="outerHTML"
58+
hx-target="this"
59+
hx-push-url="false"
60+
hx-trigger="load"
61+
>
62+
{% heroicon_mini "arrow-down-tray" %}
63+
</a>
64+
{% endif %}
5265
</div>
5366
{% endif %}
5467

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Generated by Django 5.2.15 on 2026-06-13 15:29
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("application_projects", "0106_project_contract_number"),
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name="InvoiceExportManager",
17+
fields=[
18+
(
19+
"id",
20+
models.AutoField(
21+
auto_created=True,
22+
primary_key=True,
23+
serialize=False,
24+
verbose_name="ID",
25+
),
26+
),
27+
("export_data", models.TextField()),
28+
("created_time", models.DateTimeField(auto_now_add=True)),
29+
("completed_time", models.DateTimeField(null=True)),
30+
(
31+
"status",
32+
models.CharField(
33+
choices=[
34+
("error", "Failed"),
35+
("success", "Success"),
36+
("generating", "In Progress"),
37+
],
38+
default="generating",
39+
),
40+
),
41+
("total_export", models.IntegerField(null=True)),
42+
(
43+
"user",
44+
models.ForeignKey(
45+
on_delete=django.db.models.deletion.CASCADE,
46+
to=settings.AUTH_USER_MODEL,
47+
),
48+
),
49+
],
50+
options={
51+
"verbose_name": "invoice export manager",
52+
"verbose_name_plural": "invoice export managers",
53+
},
54+
),
55+
]

hypha/apply/projects/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .payment import Invoice, InvoiceTag, SupportingDocument
1+
from .payment import Invoice, InvoiceExportManager, InvoiceTag, SupportingDocument
22
from .project import (
33
Contract,
44
ContractDocumentCategory,
@@ -28,6 +28,7 @@
2828
"DocumentCategory",
2929
"ContractDocumentCategory",
3030
"Invoice",
31+
"InvoiceExportManager",
3132
"InvoiceTag",
3233
"SupportingDocument",
3334
]

hypha/apply/projects/models/payment.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,21 @@
77
from django.db.models import Q, Sum, Value
88
from django.db.models.functions import Coalesce
99
from django.urls import reverse
10+
from django.utils import timezone
1011
from django.utils.translation import gettext_lazy as _
1112
from viewflow.fsm import State
1213

1314
from hypha.apply.utils.storage import PrivateStorage
1415

16+
EXPORT_STATUS_ERROR = "error"
17+
EXPORT_STATUS_SUCCESS = "success"
18+
EXPORT_STATUS_GENERATING = "generating"
1519

16-
class InvoiceTag(models.Model):
17-
name = models.CharField(max_length=100, unique=True)
18-
19-
class Meta:
20-
verbose_name = _("invoice tag")
21-
verbose_name_plural = _("invoice tags")
22-
ordering = ["name"]
23-
24-
def __str__(self):
25-
return self.name
26-
20+
EXPORT_STATUS_CHOICES = [
21+
(EXPORT_STATUS_ERROR, _("Failed")),
22+
(EXPORT_STATUS_SUCCESS, _("Success")),
23+
(EXPORT_STATUS_GENERATING, _("In Progress")),
24+
]
2725

2826
SUBMITTED = "submitted"
2927
RESUBMITTED = "resubmitted"
@@ -77,6 +75,18 @@ def invoice_path(instance, filename):
7775
return f"projects/{instance.project_id}/payment_invoices/{filename}"
7876

7977

78+
class InvoiceTag(models.Model):
79+
name = models.CharField(max_length=100, unique=True)
80+
81+
class Meta:
82+
verbose_name = _("invoice tag")
83+
verbose_name_plural = _("invoice tags")
84+
ordering = ["name"]
85+
86+
def __str__(self):
87+
return self.name
88+
89+
8090
class InvoiceQueryset(models.QuerySet):
8191
def in_progress(self):
8292
return self.exclude(status__in=[DECLINED, PAID])
@@ -333,3 +343,33 @@ def get_absolute_url(self):
333343
"file_pk": self.pk,
334344
},
335345
)
346+
347+
348+
class InvoiceExportManager(models.Model):
349+
user = models.ForeignKey(
350+
settings.AUTH_USER_MODEL,
351+
on_delete=models.CASCADE,
352+
)
353+
export_data = models.TextField()
354+
created_time = models.DateTimeField(auto_now_add=True)
355+
completed_time = models.DateTimeField(null=True)
356+
status = models.CharField(
357+
choices=EXPORT_STATUS_CHOICES, default=EXPORT_STATUS_GENERATING
358+
)
359+
total_export = models.IntegerField(null=True)
360+
361+
class Meta:
362+
verbose_name = _("invoice export manager")
363+
verbose_name_plural = _("invoice export managers")
364+
365+
def set_completed_and_save(self) -> None:
366+
self.status = EXPORT_STATUS_SUCCESS
367+
self.completed_time = timezone.now()
368+
self.save()
369+
370+
def set_failed_and_save(self) -> None:
371+
self.status = EXPORT_STATUS_ERROR
372+
self.save()
373+
374+
def get_absolute_url(self) -> str:
375+
return reverse("apply:projects:invoices")

hypha/apply/projects/tasks.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from typing import List
2+
3+
from celery import shared_task
4+
from django.conf import settings
5+
6+
from hypha.apply.projects.models.payment import InvoiceExportManager
7+
from hypha.apply.projects.utils import export_invoices_to_csv
8+
from hypha.apply.todo.options import (
9+
DOWNLOAD_INVOICES_EXPORT,
10+
FAILED_INVOICES_EXPORT,
11+
)
12+
from hypha.apply.todo.views import add_task_to_user
13+
from hypha.apply.users.models import User
14+
15+
16+
@shared_task
17+
def generate_invoice_csv(qs_ids: List[int], request_user_id: int) -> None:
18+
"""Celery task to generate a CSV file containing the given invoice IDs.
19+
20+
Updates the user's InvoiceExportManager object with status/final data, then
21+
adds a download task to the user's `My Tasks` when completed.
22+
"""
23+
try:
24+
from hypha.apply.projects.models.payment import Invoice
25+
26+
qs = (
27+
Invoice.objects.filter(id__in=qs_ids)
28+
.select_related("project", "project__user")
29+
.prefetch_related("tags")
30+
)
31+
request_user = User.objects.get(pk=request_user_id)
32+
33+
if current := InvoiceExportManager.objects.filter(user=request_user):
34+
current.delete()
35+
36+
export_manager = InvoiceExportManager.objects.create(
37+
user=request_user, total_export=len(qs_ids)
38+
)
39+
export_manager.export_data = export_invoices_to_csv(
40+
qs.iterator(chunk_size=2000)
41+
)
42+
export_manager.set_completed_and_save()
43+
44+
user_task = DOWNLOAD_INVOICES_EXPORT
45+
46+
except Exception as exc:
47+
export_manager.set_failed_and_save()
48+
user_task = FAILED_INVOICES_EXPORT
49+
50+
if settings.SENTRY_DSN:
51+
from sentry_sdk import capture_exception
52+
53+
capture_exception(exc)
54+
else:
55+
raise exc
56+
finally:
57+
if not settings.CELERY_TASK_ALWAYS_EAGER:
58+
add_task_to_user(
59+
code=user_task,
60+
user=request_user,
61+
related_obj=export_manager,
62+
)

hypha/apply/projects/templates/application_projects/invoice_list.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{% extends "base-apply.html" %}
22

33
{% load render_table from django_tables2 %}
4-
{% load i18n static %}
4+
{% load i18n static heroicons %}
55

66
{% block title %}{% trans "Invoices" %}{% endblock %}
77

@@ -18,7 +18,7 @@
1818
<div class="my-4">
1919
{% if table %}
2020
{% trans "invoices" as search_placeholder %}
21-
{% include "funds/includes/table_filter_and_search.html" with search_term=search_term use_search=True invoice_batch_actions=True search_placeholder=search_placeholder %}
21+
{% include "funds/includes/table_filter_and_search.html" with search_term=search_term use_search=True invoice_batch_actions=True search_placeholder=search_placeholder invoice_export=can_export_invoices %}
2222
{% render_table table %}
2323
{% else %}
2424
<p>{% trans "No Invoices available" %}</p>
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
{% load i18n heroicons %}
2+
3+
4+
{% if not_async %}
5+
{% comment %} For sync uses: no polling, just a download after pressing the button {% endcomment %}
6+
<a
7+
class="btn btn-square"
8+
aria-label="{% trans 'Invoices: Export as CSV' %}"
9+
href="{{ start_export_url }}"
10+
data-tippy-content="{% trans 'Export as CSV' %}"
11+
onclick="return confirm('{% blocktrans %}Are you sure you want to export the invoices as a CSV file? This file may contain sensitive information, so please handle it carefully.{% endblocktrans %}')"
12+
>
13+
{% heroicon_mini "arrow-down-tray" aria_hidden=true %}
14+
</a>
15+
{% else %}
16+
{% if generating %}
17+
{% comment %} Disabled button used to indicate generation of the CSV is in progress {% endcomment %}
18+
<span
19+
class="btn btn-square btn-outline"
20+
aria-label="{% trans 'Invoices: Generating downloadable CSV' %}"
21+
title="{% trans 'Generating downloadable CSV...' %}"
22+
data-tippy-content="{% trans 'Generating downloadable CSV...' %}"
23+
disabled
24+
hx-get="{% url 'apply:projects:invoice-export-status' %}"
25+
hx-swap="outerHTML"
26+
hx-target="this"
27+
hx-trigger="every {{ poll_time }}s"
28+
hx-push-url="false"
29+
hx-noprog
30+
>
31+
<span class="loading loading-spinner text-info"></span>
32+
</span>
33+
{% elif success %}
34+
{% comment %} The final download link for the generated CSV {% endcomment %}
35+
<a
36+
class="btn btn-square btn-primary btn-outline"
37+
aria-label="{% trans 'Invoices: Download generated CSV' %}"
38+
href="{% url 'apply:projects:invoice-export-download' %}"
39+
data-tippy-content="{% trans 'Download generated CSV' %}"
40+
hx-get="{% url 'apply:projects:invoice-export-status' %}"
41+
hx-swap="outerHTML"
42+
hx-target="this"
43+
hx-trigger="every 2s"
44+
hx-push-url="false"
45+
hx-noprog
46+
>
47+
<span class="flex absolute top-0 right-0 -mt-1 -mr-1 size-3">
48+
<span class="inline-flex absolute w-full h-full bg-green-400 rounded-full opacity-75 animate-ping"></span>
49+
<span class="inline-flex relative bg-green-500 rounded-full size-3"></span>
50+
</span>
51+
{% heroicon_mini "arrow-down-tray" aria_hidden=true %}
52+
</a>
53+
54+
{% else %}
55+
{% comment %} Button that will begin the generation of the CSV, used to start a generation or retry a failed one {% endcomment %}
56+
<button
57+
class="btn btn-square"
58+
aria-label="{% trans 'Invoices: Generate downloadable CSV' %}"
59+
{% if failed %}
60+
data-tippy-content="{% trans 'Generation failed, click to retry generating downloadable CSV' %}"
61+
{% else %}
62+
data-tippy-content="{% trans 'Generate downloadable CSV' %}"
63+
{% endif %}
64+
hx-get="{{ start_export_url }}"
65+
hx-swap="outerHTML"
66+
hx-target="this"
67+
hx-push-url="false"
68+
hx-confirm="{% trans 'Are you sure you want to export the invoices as a CSV file? This file may contain sensitive information, so please handle it carefully.' %}"
69+
>
70+
{% if not failed %}
71+
{% heroicon_mini "arrow-down-tray" aria_hidden=true %}
72+
{% else %}
73+
<span class="flex absolute top-0 right-0 -mt-1 -mr-1 size-3">
74+
<span class="inline-flex absolute w-full h-full bg-red-400 rounded-full opacity-75 animate-ping"></span>
75+
<span class="inline-flex relative bg-red-500 rounded-full size-3"></span>
76+
</span>
77+
{% heroicon_mini "exclamation-circle" aria_hidden=true %}
78+
{% endif %}
79+
</button>
80+
{% endif %}
81+
{% endif %}

hypha/apply/projects/urls.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
UploadContractDocumentView,
4242
UploadContractView,
4343
UploadDocumentView,
44+
invoice_export_download,
45+
invoice_export_status,
4446
partial_contracting_documents,
4547
partial_get_invoice_detail_actions,
4648
partial_get_invoice_status,
@@ -62,6 +64,16 @@
6264
path("all/", ProjectListView.as_view(), name="all"),
6365
path("reports/", include("hypha.apply.projects.reports.urls"), name="reports"),
6466
path("invoices/", InvoiceListView.as_view(), name="invoices"),
67+
path(
68+
"invoices/export-status/",
69+
invoice_export_status,
70+
name="invoice-export-status",
71+
),
72+
path(
73+
"invoices/export-download/",
74+
invoice_export_download,
75+
name="invoice-export-download",
76+
),
6577
path(
6678
"all/bulk_invoice_status_update/",
6779
BatchUpdateInvoiceStatusView.as_view(),

0 commit comments

Comments
 (0)