Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ <h2 class="section-header">{{ heading }}</h2>
</label>
</form>
{% endif %}

{% if invoice_export %}
<a
class="btn btn-square"
hx-get="{% url 'apply:projects:invoice-export-status' %}"
hx-swap="outerHTML"
hx-target="this"
hx-push-url="false"
hx-trigger="load"
>
{% heroicon_mini "arrow-down-tray" %}
</a>
{% endif %}
</div>
{% endif %}

Expand Down
55 changes: 55 additions & 0 deletions hypha/apply/projects/migrations/0104_invoiceexportmanager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Generated by Django 5.2.15 on 2026-06-13 15:29

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("application_projects", "0103_alter_contract_options_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="InvoiceExportManager",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("export_data", models.TextField()),
("created_time", models.DateTimeField(auto_now_add=True)),
("completed_time", models.DateTimeField(null=True)),
(
"status",
models.CharField(
choices=[
("error", "Failed"),
("success", "Success"),
("generating", "In Progress"),
],
default="generating",
),
),
("total_export", models.IntegerField(null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "invoice export manager",
"verbose_name_plural": "invoice export managers",
},
),
]
3 changes: 2 additions & 1 deletion hypha/apply/projects/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .payment import Invoice, SupportingDocument
from .payment import Invoice, InvoiceExportManager, SupportingDocument
from .project import (
Contract,
ContractDocumentCategory,
Expand Down Expand Up @@ -28,5 +28,6 @@
"DocumentCategory",
"ContractDocumentCategory",
"Invoice",
"InvoiceExportManager",
"SupportingDocument",
]
41 changes: 41 additions & 0 deletions hypha/apply/projects/models/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@
from django.db.models import Q, Sum, Value
from django.db.models.functions import Coalesce
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from viewflow.fsm import State

from hypha.apply.utils.storage import PrivateStorage

EXPORT_STATUS_ERROR = "error"
EXPORT_STATUS_SUCCESS = "success"
EXPORT_STATUS_GENERATING = "generating"

EXPORT_STATUS_CHOICES = [
(EXPORT_STATUS_ERROR, _("Failed")),
(EXPORT_STATUS_SUCCESS, _("Success")),
(EXPORT_STATUS_GENERATING, _("In Progress")),
]

SUBMITTED = "submitted"
RESUBMITTED = "resubmitted"
CHANGES_REQUESTED_BY_STAFF = "changes_requested_staff"
Expand Down Expand Up @@ -314,3 +325,33 @@ def get_absolute_url(self):
"file_pk": self.pk,
},
)


class InvoiceExportManager(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
)
export_data = models.TextField()
created_time = models.DateTimeField(auto_now_add=True)
completed_time = models.DateTimeField(null=True)
status = models.CharField(
choices=EXPORT_STATUS_CHOICES, default=EXPORT_STATUS_GENERATING
)
total_export = models.IntegerField(null=True)

class Meta:
verbose_name = _("invoice export manager")
verbose_name_plural = _("invoice export managers")

def set_completed_and_save(self) -> None:
self.status = EXPORT_STATUS_SUCCESS
self.completed_time = timezone.now()
self.save()

def set_failed_and_save(self) -> None:
self.status = EXPORT_STATUS_ERROR
self.save()

def get_absolute_url(self) -> str:
return reverse("apply:projects:invoices")
58 changes: 58 additions & 0 deletions hypha/apply/projects/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from typing import List

from celery import shared_task
from django.conf import settings

from hypha.apply.projects.models.payment import InvoiceExportManager
from hypha.apply.projects.utils import export_invoices_to_csv
from hypha.apply.todo.options import (
DOWNLOAD_INVOICES_EXPORT,
FAILED_INVOICES_EXPORT,
)
from hypha.apply.todo.views import add_task_to_user
from hypha.apply.users.models import User


@shared_task
def generate_invoice_csv(qs_ids: List[int], request_user_id: int) -> None:
"""Celery task to generate a CSV file containing the given invoice IDs.

Updates the user's InvoiceExportManager object with status/final data, then
adds a download task to the user's `My Tasks` when completed.
"""
try:
from hypha.apply.projects.models.payment import Invoice

qs = Invoice.objects.filter(id__in=qs_ids).select_related(
"project", "project__user"
)
request_user = User.objects.get(pk=request_user_id)

if current := InvoiceExportManager.objects.filter(user=request_user):
current.delete()

export_manager = InvoiceExportManager.objects.create(
user=request_user, total_export=len(qs_ids)
)
export_manager.export_data = export_invoices_to_csv(qs.iterator())
export_manager.set_completed_and_save()

user_task = DOWNLOAD_INVOICES_EXPORT

except Exception as exc:
export_manager.set_failed_and_save()
user_task = FAILED_INVOICES_EXPORT

if settings.SENTRY_DSN:
from sentry_sdk import capture_exception

capture_exception(exc)
else:
raise exc
finally:
if not settings.CELERY_TASK_ALWAYS_EAGER:
add_task_to_user(
code=user_task,
user=request_user,
related_obj=export_manager,
)
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{% extends "base-apply.html" %}

{% load render_table from django_tables2 %}
{% load i18n static %}
{% load i18n static heroicons %}

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

Expand All @@ -18,7 +18,7 @@
<div class="my-4">
{% if table %}
{% trans "invoices" as search_placeholder %}
{% include "funds/includes/table_filter_and_search.html" with search_term=search_term use_search=True invoice_batch_actions=True search_placeholder=search_placeholder %}
{% 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 %}
{% render_table table %}
{% else %}
<p>{% trans "No Invoices available" %}</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
{% load i18n heroicons %}


{% if not_async %}
{% comment %} For sync uses: no polling, just a download after pressing the button {% endcomment %}
<a
class="btn btn-square"
aria-label="{% trans 'Invoices: Export as CSV' %}"
href="{{ start_export_url }}"
data-tippy-content="{% trans 'Export as CSV' %}"
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 %}')"
>
{% heroicon_mini "arrow-down-tray" aria_hidden=true %}
</a>
{% else %}
{% if generating %}
{% comment %} Disabled button used to indicate generation of the CSV is in progress {% endcomment %}
<span
class="btn btn-square btn-outline"
aria-label="{% trans 'Invoices: Generating downloadable CSV' %}"
title="{% trans 'Generating downloadable CSV...' %}"
data-tippy-content="{% trans 'Generating downloadable CSV...' %}"
disabled
hx-get="{% url 'apply:projects:invoice-export-status' %}"
hx-swap="outerHTML"
hx-target="this"
hx-trigger="every {{ poll_time }}s"
hx-push-url="false"
hx-noprog
>
<span class="loading loading-spinner text-info"></span>
</span>
{% elif success %}
{% comment %} The final download link for the generated CSV {% endcomment %}
<a
class="btn btn-square btn-primary btn-outline"
aria-label="{% trans 'Invoices: Download generated CSV' %}"
href="{% url 'apply:projects:invoice-export-download' %}"
data-tippy-content="{% trans 'Download generated CSV' %}"
hx-get="{% url 'apply:projects:invoice-export-status' %}"
hx-swap="outerHTML"
hx-target="this"
hx-trigger="every 2s"
hx-push-url="false"
hx-noprog
>
<span class="flex absolute top-0 right-0 -mt-1 -mr-1 size-3">
<span class="inline-flex absolute w-full h-full bg-green-400 rounded-full opacity-75 animate-ping"></span>
<span class="inline-flex relative bg-green-500 rounded-full size-3"></span>
</span>
{% heroicon_mini "arrow-down-tray" aria_hidden=true %}
</a>

{% else %}
{% comment %} Button that will begin the generation of the CSV, used to start a generation or retry a failed one {% endcomment %}
<button
class="btn btn-square"
aria-label="{% trans 'Invoices: Generate downloadable CSV' %}"
{% if failed %}
data-tippy-content="{% trans 'Generation failed, click to retry generating downloadable CSV' %}"
{% else %}
data-tippy-content="{% trans 'Generate downloadable CSV' %}"
{% endif %}
hx-get="{{ start_export_url }}"
hx-swap="outerHTML"
hx-target="this"
hx-push-url="false"
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.' %}"
>
{% if not failed %}
{% heroicon_mini "arrow-down-tray" aria_hidden=true %}
{% else %}
<span class="flex absolute top-0 right-0 -mt-1 -mr-1 size-3">
<span class="inline-flex absolute w-full h-full bg-red-400 rounded-full opacity-75 animate-ping"></span>
<span class="inline-flex relative bg-red-500 rounded-full size-3"></span>
</span>
{% heroicon_mini "exclamation-circle" aria_hidden=true %}
{% endif %}
</button>
{% endif %}
{% endif %}
12 changes: 12 additions & 0 deletions hypha/apply/projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
UploadContractDocumentView,
UploadContractView,
UploadDocumentView,
invoice_export_download,
invoice_export_status,
partial_contracting_documents,
partial_get_invoice_detail_actions,
partial_get_invoice_status,
Expand All @@ -56,6 +58,16 @@
path("all/", ProjectListView.as_view(), name="all"),
path("reports/", include("hypha.apply.projects.reports.urls"), name="reports"),
path("invoices/", InvoiceListView.as_view(), name="invoices"),
path(
"invoices/export-status/",
invoice_export_status,
name="invoice-export-status",
),
path(
"invoices/export-download/",
invoice_export_download,
name="invoice-export-download",
),
path(
"all/bulk_invoice_status_update/",
BatchUpdateInvoiceStatusView.as_view(),
Expand Down
46 changes: 46 additions & 0 deletions hypha/apply/projects/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import csv
import io

from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django_file_form.uploaded_file import PlaceholderUploadedFile
Expand Down Expand Up @@ -128,6 +131,49 @@ def get_invoice_table_status(invoice_status, is_applicant=False):
return INT_PAYMENT_FAILED


def export_invoices_to_csv(invoices_iter) -> str:
"""Generate a CSV string from an invoice queryset iterator.

Columns match those shown in the invoice list table. Add more fields here
as needed.
"""
output = io.StringIO()
fieldnames = [
"Invoice #",
"Invoice Date",
"Status",
"Submitted",
"Project Title",
"Project #",
"Vendor Name",
"Invoice Amount",
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()

for invoice in invoices_iter:
writer.writerow(
{
"Invoice #": invoice.invoice_number or "",
"Invoice Date": invoice.invoice_date.isoformat()
if invoice.invoice_date
else "",
"Status": invoice.get_status_display(),
"Submitted": invoice.requested_at.strftime("%Y-%m-%d %H:%M")
if invoice.requested_at
else "",
"Project Title": invoice.project.title,
"Project #": invoice.project.application_id,
"Vendor Name": str(invoice.project.user),
"Invoice Amount": str(invoice.invoice_amount)
if invoice.invoice_amount is not None
else "",
}
)

return output.getvalue()


def get_placeholder_file(initial_file):
if not isinstance(initial_file, list):
return PlaceholderUploadedFile(
Expand Down
Loading