diff --git a/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html b/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html index 48b5ac46ec..40ab28b4bf 100644 --- a/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html +++ b/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html @@ -49,6 +49,19 @@

{{ heading }}

{% endif %} + + {% if invoice_export %} + + {% heroicon_mini "arrow-down-tray" %} + + {% endif %} {% endif %} diff --git a/hypha/apply/projects/migrations/0104_invoiceexportmanager.py b/hypha/apply/projects/migrations/0104_invoiceexportmanager.py new file mode 100644 index 0000000000..d9280fe292 --- /dev/null +++ b/hypha/apply/projects/migrations/0104_invoiceexportmanager.py @@ -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", + }, + ), + ] diff --git a/hypha/apply/projects/models/__init__.py b/hypha/apply/projects/models/__init__.py index 181b7c93c9..c8e3b58a2d 100644 --- a/hypha/apply/projects/models/__init__.py +++ b/hypha/apply/projects/models/__init__.py @@ -1,4 +1,4 @@ -from .payment import Invoice, SupportingDocument +from .payment import Invoice, InvoiceExportManager, SupportingDocument from .project import ( Contract, ContractDocumentCategory, @@ -28,5 +28,6 @@ "DocumentCategory", "ContractDocumentCategory", "Invoice", + "InvoiceExportManager", "SupportingDocument", ] diff --git a/hypha/apply/projects/models/payment.py b/hypha/apply/projects/models/payment.py index 87f8106905..a88e2d75ee 100644 --- a/hypha/apply/projects/models/payment.py +++ b/hypha/apply/projects/models/payment.py @@ -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" @@ -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") diff --git a/hypha/apply/projects/tasks.py b/hypha/apply/projects/tasks.py new file mode 100644 index 0000000000..e3d723bb0b --- /dev/null +++ b/hypha/apply/projects/tasks.py @@ -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, + ) diff --git a/hypha/apply/projects/templates/application_projects/invoice_list.html b/hypha/apply/projects/templates/application_projects/invoice_list.html index 278916b43f..b42efdcd70 100644 --- a/hypha/apply/projects/templates/application_projects/invoice_list.html +++ b/hypha/apply/projects/templates/application_projects/invoice_list.html @@ -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 %} @@ -18,7 +18,7 @@
{% 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 %}

{% trans "No Invoices available" %}

diff --git a/hypha/apply/projects/templates/application_projects/partials/export-invoice-button.html b/hypha/apply/projects/templates/application_projects/partials/export-invoice-button.html new file mode 100644 index 0000000000..ae4e2b09cf --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/partials/export-invoice-button.html @@ -0,0 +1,81 @@ +{% load i18n heroicons %} + + +{% if not_async %} + {% comment %} For sync uses: no polling, just a download after pressing the button {% endcomment %} + + {% heroicon_mini "arrow-down-tray" aria_hidden=true %} + +{% else %} + {% if generating %} + {% comment %} Disabled button used to indicate generation of the CSV is in progress {% endcomment %} + + + + {% elif success %} + {% comment %} The final download link for the generated CSV {% endcomment %} + + + + + + {% heroicon_mini "arrow-down-tray" aria_hidden=true %} + + + {% else %} + {% comment %} Button that will begin the generation of the CSV, used to start a generation or retry a failed one {% endcomment %} + + {% endif %} +{% endif %} diff --git a/hypha/apply/projects/urls.py b/hypha/apply/projects/urls.py index 01c2b00e9d..e8e1290d24 100644 --- a/hypha/apply/projects/urls.py +++ b/hypha/apply/projects/urls.py @@ -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, @@ -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(), diff --git a/hypha/apply/projects/utils.py b/hypha/apply/projects/utils.py index a928a3793c..6b282a5616 100644 --- a/hypha/apply/projects/utils.py +++ b/hypha/apply/projects/utils.py @@ -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 @@ -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( diff --git a/hypha/apply/projects/views/__init__.py b/hypha/apply/projects/views/__init__.py index 84396463fe..8a7ba566f7 100644 --- a/hypha/apply/projects/views/__init__.py +++ b/hypha/apply/projects/views/__init__.py @@ -7,6 +7,8 @@ InvoiceListView, InvoicePrivateMedia, InvoiceView, + invoice_export_download, + invoice_export_status, ) from .project import ( AdminProjectDetailView, @@ -98,4 +100,6 @@ "EditInvoiceView", "DeleteInvoiceView", "InvoicePrivateMedia", + "invoice_export_status", + "invoice_export_download", ] diff --git a/hypha/apply/projects/views/payment.py b/hypha/apply/projects/views/payment.py index 8e4c521a3d..91f0d6aebe 100644 --- a/hypha/apply/projects/views/payment.py +++ b/hypha/apply/projects/views/payment.py @@ -1,5 +1,7 @@ import json +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import UserPassesTestMixin @@ -7,7 +9,7 @@ from django.core.exceptions import PermissionDenied from django.db import transaction from django.db.models import Q -from django.http import HttpResponse +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.template.loader import render_to_string from django.utils import timezone @@ -32,10 +34,12 @@ from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.activity.models import APPLICANT, COMMENT, Activity from hypha.apply.funds.models.co_applicants import CoApplicantProjectPermission +from hypha.apply.funds.utils import get_export_polling_time from hypha.apply.projects.templatetags.invoice_tools import ( display_invoice_status_for_user, ) from hypha.apply.todo.options import ( + DOWNLOAD_INVOICES_EXPORT, INVOICE_REQUIRED_CHANGES, INVOICE_WAITING_APPROVAL, PROJECT_WAITING_INVOICE, @@ -44,6 +48,7 @@ add_task_to_user, remove_tasks_for_user, remove_tasks_of_related_obj, + remove_tasks_of_related_obj_for_specific_code, ) from hypha.apply.users.decorators import staff_or_finance_required from hypha.apply.utils.pdfs import html_to_pdf, merge_pdf @@ -68,10 +73,14 @@ CHANGES_REQUESTED_BY_FINANCE, CHANGES_REQUESTED_BY_STAFF, DECLINED, + EXPORT_STATUS_ERROR, + EXPORT_STATUS_GENERATING, + EXPORT_STATUS_SUCCESS, INVOICE_TRANSITION_TO_RESUBMITTED, PAID, PAYMENT_FAILED, Invoice, + InvoiceExportManager, ) from ..models.project import Project from ..service_utils import batch_update_invoices_status, handle_tasks_on_invoice_update @@ -577,6 +586,57 @@ def post(self, *args, **kwargs): return HttpResponseClientRefresh() +@login_required +def invoice_export_status(request: HttpResponse) -> HttpResponse: + """Partial view returning the current state of the invoice CSV export button.""" + ctx = {} + status = None + + if not settings.CELERY_TASK_ALWAYS_EAGER: + if export_manager := InvoiceExportManager.objects.filter( + user=request.user + ).first(): + status = export_manager.status + if status == EXPORT_STATUS_GENERATING: + ctx["poll_time"] = get_export_polling_time(export_manager.total_export) + else: + ctx["not_async"] = True + + if status is None or status == EXPORT_STATUS_ERROR: + all_url = urlparse(request.headers.get("Hx-Current-Url")) + url_list = list(all_url) + url_list[4] = urlencode( + {**parse_qs(all_url.query), "format": "csv"}, + doseq=True, + ) + ctx["start_export_url"] = urlunparse(url_list) + + ctx["generating"] = status == EXPORT_STATUS_GENERATING + ctx["failed"] = status == EXPORT_STATUS_ERROR + ctx["success"] = status == EXPORT_STATUS_SUCCESS + + return render( + request, "application_projects/partials/export-invoice-button.html", ctx + ) + + +@login_required +def invoice_export_download(request: HttpResponse) -> HttpResponse: + export_manager = get_object_or_404(InvoiceExportManager, user=request.user) + if export_manager.status == EXPORT_STATUS_SUCCESS: + response = HttpResponse(export_manager.export_data, content_type="text/csv") + response["Content-Disposition"] = "attachment; filename=invoices.csv" + + remove_tasks_of_related_obj_for_specific_code( + code=DOWNLOAD_INVOICES_EXPORT, related_obj=export_manager + ) + export_manager.delete() + + return response + + raise Http404() + + @method_decorator(staff_or_finance_required, name="dispatch") class InvoiceListView(SingleTableMixin, FilterView, DelegateableListView): form_views = [ @@ -594,3 +654,34 @@ def get_table_class(self): if self.request.user.is_finance: return FinanceInvoiceTable return super().get_table_class() + + def get(self, request, *args, **kwargs): + if request.GET.get("format") == "csv": + from hypha.apply.projects.tasks import generate_invoice_csv + + filterset = self.filterset_class(request.GET, queryset=self.get_queryset()) + qs_ids = list(filterset.qs.values_list("id", flat=True)) + generate_invoice_csv.delay(qs_ids, request.user.id) + + if not settings.CELERY_TASK_ALWAYS_EAGER: + response = render( + request, + "application_projects/partials/export-invoice-button.html", + { + "generating": True, + "poll_time": get_export_polling_time(len(qs_ids)), + }, + ) + response["HX-Trigger"] = json.dumps( + {"showMessage": _("Started CSV generation.")} + ) + return response + else: + return invoice_export_download(request) + + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["can_export_invoices"] = True + return ctx diff --git a/hypha/apply/todo/migrations/0010_alter_task_code.py b/hypha/apply/todo/migrations/0010_alter_task_code.py new file mode 100644 index 0000000000..fcbb924383 --- /dev/null +++ b/hypha/apply/todo/migrations/0010_alter_task_code.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.15 on 2026-06-13 15:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("todo", "0009_alter_task_options"), + ] + + operations = [ + migrations.AlterField( + model_name="task", + name="code", + field=models.CharField( + choices=[ + ("comment_task", "Comment Task"), + ("submission_draft", "Submission Draft"), + ("determination_draft", "Determination draft"), + ("review_draft", "Review Draft"), + ("project_waiting_paf", "Project waiting project form"), + ("project_waiting_sow", "Project waiting scope of work"), + ("project_submit_pfs", "Project submit project form(s)"), + ("project_submit_sow", "Project submit scope of work"), + ("paf_required_changes", "Project form required changes"), + ("paf_waiting_assignee", "Project form waiting assignee"), + ("paf_waiting_approval", "Project form waiting approval"), + ("project_waiting_contract", "Project waiting contract"), + ( + "project_waiting_contract_document", + "Project waiting contract document", + ), + ( + "project_waiting_contract_review", + "Project waiting contract review", + ), + ("project_waiting_invoice", "Project waiting invoice"), + ("invoice_required_changes", "Invoice required changes"), + ("invoice_waiting_approval", "Invoice waiting approval"), + ("invoice_waiting_paid", "Invoice waiting paid"), + ("report_due", "Report due"), + ("download_submissions_export", "Download exported submissions"), + ( + "failed_submission_export", + "Failed to generate submissions export file", + ), + ("download_invoices_export", "Download exported invoices"), + ( + "failed_invoices_export", + "Failed to generate invoices export file", + ), + ], + max_length=50, + ), + ), + ] diff --git a/hypha/apply/todo/options.py b/hypha/apply/todo/options.py index 25629daa6e..4ce7877924 100644 --- a/hypha/apply/todo/options.py +++ b/hypha/apply/todo/options.py @@ -25,6 +25,8 @@ COMMENT_TASK = "comment_task" DOWNLOAD_SUBMISSIONS_EXPORT = "download_submissions_export" FAILED_SUBMISSIONS_EXPORT = "failed_submission_export" +DOWNLOAD_INVOICES_EXPORT = "download_invoices_export" +FAILED_INVOICES_EXPORT = "failed_invoices_export" TASKS_CODE_CHOICES = ( (COMMENT_TASK, _("Comment Task")), @@ -54,6 +56,11 @@ FAILED_SUBMISSIONS_EXPORT, _("Failed to generate submissions export file"), ), + (DOWNLOAD_INVOICES_EXPORT, _("Download exported invoices")), + ( + FAILED_INVOICES_EXPORT, + _("Failed to generate invoices export file"), + ), ) @@ -226,6 +233,21 @@ "url": "{link}", "type": _("export"), }, + # INVOICE EXPORT ACTIONS + DOWNLOAD_INVOICES_EXPORT: { + "text": _("Your generated invoice export file is ready for download"), + "icon": "arrow-down-tray", + "url": "{link}", + "type": _("export"), + }, + FAILED_INVOICES_EXPORT: { + "text": _( + "There was an issue generating your invoice export file, please try again." + ), + "icon": "exclamation-circle", + "url": "{link}", + "type": _("export"), + }, }