diff --git a/hypha/apply/projects/admin.py b/hypha/apply/projects/admin.py index 68b7440e44..ed07ab63f6 100644 --- a/hypha/apply/projects/admin.py +++ b/hypha/apply/projects/admin.py @@ -15,6 +15,7 @@ from .models import ( ContractDocumentCategory, DocumentCategory, + InvoiceTag, ProjectForm, ProjectReportForm, ProjectSettings, @@ -95,12 +96,20 @@ class ProjectSettingsAdmin(SettingModelAdmin): model = ProjectSettings +class InvoiceTagAdmin(ModelAdmin): + model = InvoiceTag + menu_label = _("Invoice Tags") + menu_icon = "tag" + list_display = ("name",) + + class ProjectAdminGroup(ModelAdminGroup): menu_label = _("Projects") menu_icon = str(AdminIcon.PROJECT) items = ( ContractDocumentCategoryAdmin, DocumentCategoryAdmin, + InvoiceTagAdmin, ProjectFormAdmin, ProjectReportFormAdmin, ProjectSOWFormAdmin, diff --git a/hypha/apply/projects/forms/__init__.py b/hypha/apply/projects/forms/__init__.py index 85642d0089..c83844cdcc 100644 --- a/hypha/apply/projects/forms/__init__.py +++ b/hypha/apply/projects/forms/__init__.py @@ -3,6 +3,7 @@ ChangeInvoiceStatusForm, CreateInvoiceForm, EditInvoiceForm, + InvoiceTagsForm, SelectDocumentForm, ) from .project import ( @@ -50,4 +51,5 @@ "CreateInvoiceForm", "ChangeInvoiceStatusForm", "EditInvoiceForm", + "InvoiceTagsForm", ] diff --git a/hypha/apply/projects/forms/payment.py b/hypha/apply/projects/forms/payment.py index 1c4d67c613..7fffef4637 100644 --- a/hypha/apply/projects/forms/payment.py +++ b/hypha/apply/projects/forms/payment.py @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _ from django_file_form.forms import FileFormMixin +from hypha.apply.funds.widgets import MultiCheckboxesWidget from hypha.apply.stream_forms.fields import MultiFileField, SingleFileField from ..models.payment import ( @@ -22,6 +23,7 @@ RESUBMITTED, SUBMITTED, Invoice, + InvoiceTag, SupportingDocument, invoice_status_user_choices, ) @@ -226,3 +228,16 @@ def clean_invoices(self): value = self.cleaned_data["invoices"] invoice_ids = [int(invoice) for invoice in value.split(",")] return Invoice.objects.filter(id__in=invoice_ids) + + +class InvoiceTagsForm(forms.ModelForm): + tags = forms.ModelMultipleChoiceField( + queryset=InvoiceTag.objects.all(), + widget=MultiCheckboxesWidget, + required=False, + label=_("Tags"), + ) + + class Meta: + model = Invoice + fields = ["tags"] diff --git a/hypha/apply/projects/migrations/0104_invoice_tags.py b/hypha/apply/projects/migrations/0104_invoice_tags.py new file mode 100644 index 0000000000..0cf54673fb --- /dev/null +++ b/hypha/apply/projects/migrations/0104_invoice_tags.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.15 on 2026-06-23 05:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("application_projects", "0103_alter_contract_options_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="InvoiceTag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, unique=True)), + ], + options={ + "verbose_name": "invoice tag", + "verbose_name_plural": "invoice tags", + "ordering": ["name"], + }, + ), + migrations.AddField( + model_name="invoice", + name="tags", + field=models.ManyToManyField( + blank=True, + related_name="invoices", + to="application_projects.invoicetag", + verbose_name="tags", + ), + ), + ] diff --git a/hypha/apply/projects/models/__init__.py b/hypha/apply/projects/models/__init__.py index 181b7c93c9..50170e64ac 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, InvoiceTag, SupportingDocument from .project import ( Contract, ContractDocumentCategory, @@ -28,5 +28,6 @@ "DocumentCategory", "ContractDocumentCategory", "Invoice", + "InvoiceTag", "SupportingDocument", ] diff --git a/hypha/apply/projects/models/payment.py b/hypha/apply/projects/models/payment.py index 87f8106905..02eefad257 100644 --- a/hypha/apply/projects/models/payment.py +++ b/hypha/apply/projects/models/payment.py @@ -12,6 +12,19 @@ from hypha.apply.utils.storage import PrivateStorage + +class InvoiceTag(models.Model): + name = models.CharField(max_length=100, unique=True) + + class Meta: + verbose_name = _("invoice tag") + verbose_name_plural = _("invoice tags") + ordering = ["name"] + + def __str__(self): + return self.name + + SUBMITTED = "submitted" RESUBMITTED = "resubmitted" CHANGES_REQUESTED_BY_STAFF = "changes_requested_staff" @@ -138,6 +151,12 @@ class Invoice(models.Model): ) status_field = State(default=SUBMITTED, states=INVOICE_STATUS_CHOICES) requested_at = models.DateTimeField(auto_now_add=True) + tags = models.ManyToManyField( + InvoiceTag, + blank=True, + related_name="invoices", + verbose_name=_("tags"), + ) objects = InvoiceQueryset.as_manager() wagtail_reference_index_ignore = True diff --git a/hypha/apply/projects/tables.py b/hypha/apply/projects/tables.py index 17bfe21ce5..5ce1ff6f86 100644 --- a/hypha/apply/projects/tables.py +++ b/hypha/apply/projects/tables.py @@ -55,6 +55,19 @@ class Meta: "tabindex": "0", # Accessibility } + def render_tags(self, record): + tags = record.tags.all() + if not tags: + return mark_safe("") + badges = "".join( + format_html( + "{}", + tag.name, + ) + for tag in tags + ) + return mark_safe(f"
{badges}
") + def render_requested_at(self, record): return format_html( "{}", @@ -101,6 +114,7 @@ def render_project(self, record): class FinanceInvoiceTable(BaseInvoiceTable): vendor_name = tables.Column(verbose_name=_("Vendor Name"), empty_values=()) + tags = tables.Column(verbose_name=_("Tags"), orderable=False, empty_values=()) selected = LabeledCheckboxColumn( accessor=A("pk"), attrs={ @@ -118,6 +132,7 @@ class Meta(BaseInvoiceTable.Meta): "status", "requested_at", "invoice_amount", + "tags", ] model = Invoice orderable = True @@ -135,6 +150,7 @@ def render_vendor_name(self, record): class AdminInvoiceListTable(BaseInvoiceTable): project = tables.Column(verbose_name=_("Project Name")) + tags = tables.Column(verbose_name=_("Tags"), orderable=False, empty_values=()) selected = LabeledCheckboxColumn( accessor=A("pk"), attrs={ @@ -154,6 +170,7 @@ class Meta(BaseInvoiceTable.Meta): "status", "requested_at", "project", + "tags", ] model = Invoice orderable = True diff --git a/hypha/apply/projects/templates/application_projects/invoice_detail.html b/hypha/apply/projects/templates/application_projects/invoice_detail.html index f790670b3b..51deb1ac30 100644 --- a/hypha/apply/projects/templates/application_projects/invoice_detail.html +++ b/hypha/apply/projects/templates/application_projects/invoice_detail.html @@ -49,6 +49,11 @@

{% trans "Fund" %}: {{ object.project.submission.page }}

+

diff --git a/hypha/apply/projects/templates/application_projects/modals/invoice_tag.html b/hypha/apply/projects/templates/application_projects/modals/invoice_tag.html new file mode 100644 index 0000000000..c897b08465 --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/modals/invoice_tag.html @@ -0,0 +1,21 @@ +{% load i18n %} +{% trans "Update tags" %} + +
+
+ {% csrf_token %} + {% include "forms/includes/field.html" with field=form.tags %} + +
+ + +
+
+
diff --git a/hypha/apply/projects/templates/application_projects/partials/invoice_detail_actions.html b/hypha/apply/projects/templates/application_projects/partials/invoice_detail_actions.html index 37eb2a1027..579702619f 100644 --- a/hypha/apply/projects/templates/application_projects/partials/invoice_detail_actions.html +++ b/hypha/apply/projects/templates/application_projects/partials/invoice_detail_actions.html @@ -47,6 +47,18 @@ {% endif %} + + {% if user.is_apply_staff or user.is_finance %} + + {% endif %} + {% can_delete object user as user_can_delete_request %} {% if user_can_delete_request %} diff --git a/hypha/apply/projects/templates/application_projects/partials/invoice_tags.html b/hypha/apply/projects/templates/application_projects/partials/invoice_tags.html new file mode 100644 index 0000000000..1eb1064452 --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/partials/invoice_tags.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% with tags=object.tags.all %} + {% if tags %} + {% trans "Tags" %}: {{ tags|join:", " }} + {% endif %} +{% endwith %} diff --git a/hypha/apply/projects/urls.py b/hypha/apply/projects/urls.py index c5cdc419cd..2c545d8adf 100644 --- a/hypha/apply/projects/urls.py +++ b/hypha/apply/projects/urls.py @@ -31,6 +31,7 @@ SendForApprovalView, SkipPAFApprovalProcessView, SubmitContractDocumentsView, + TagInvoiceView, UpdateAssignApproversView, UpdateLeadView, UpdatePAFApproversView, @@ -41,6 +42,7 @@ partial_get_invoice_detail_actions, partial_get_invoice_status, partial_get_invoice_status_table, + partial_get_invoice_tags, partial_project_information, partial_project_lead, partial_project_title, @@ -252,6 +254,16 @@ partial_get_invoice_detail_actions, name="partial-invoice-detail-actions", ), + path( + "tags/", + TagInvoiceView.as_view(), + name="invoice-tags", + ), + path( + "partial/tags/", + partial_get_invoice_tags, + name="partial-invoice-tags", + ), path( "documents/invoice/", InvoicePrivateMedia.as_view(), diff --git a/hypha/apply/projects/views/__init__.py b/hypha/apply/projects/views/__init__.py index 84396463fe..b18bb2df5e 100644 --- a/hypha/apply/projects/views/__init__.py +++ b/hypha/apply/projects/views/__init__.py @@ -7,6 +7,7 @@ InvoiceListView, InvoicePrivateMedia, InvoiceView, + TagInvoiceView, ) from .project import ( AdminProjectDetailView, @@ -45,6 +46,7 @@ partial_get_invoice_detail_actions, partial_get_invoice_status, partial_get_invoice_status_table, + partial_get_invoice_tags, partial_project_information, partial_project_lead, partial_project_title, @@ -59,6 +61,7 @@ "partial_get_invoice_status_table", "partial_get_invoice_status", "partial_get_invoice_detail_actions", + "partial_get_invoice_tags", "partial_contracting_documents", "BatchUpdateInvoiceStatusView", "ChangeInvoiceStatusView", @@ -98,4 +101,5 @@ "EditInvoiceView", "DeleteInvoiceView", "InvoicePrivateMedia", + "TagInvoiceView", ] diff --git a/hypha/apply/projects/views/payment.py b/hypha/apply/projects/views/payment.py index 8e4c521a3d..393ee66b54 100644 --- a/hypha/apply/projects/views/payment.py +++ b/hypha/apply/projects/views/payment.py @@ -61,6 +61,7 @@ ChangeInvoiceStatusForm, CreateInvoiceForm, EditInvoiceForm, + InvoiceTagsForm, ) from ..models.payment import ( APPROVED_BY_FINANCE, @@ -588,9 +589,54 @@ class InvoiceListView(SingleTableMixin, FilterView, DelegateableListView): template_name = "application_projects/invoice_list.html" def get_queryset(self): - return super().get_queryset().select_related("project", "project__user") + return ( + super() + .get_queryset() + .select_related("project", "project__user") + .prefetch_related("tags") + ) def get_table_class(self): if self.request.user.is_finance: return FinanceInvoiceTable return super().get_table_class() + + +@method_decorator(staff_or_finance_required, name="dispatch") +class TagInvoiceView(InvoiceAccessMixin, View): + form_class = InvoiceTagsForm + template_name = "application_projects/modals/invoice_tag.html" + + def dispatch(self, request, *args, **kwargs): + self.object = get_object_or_404(Invoice, id=kwargs.get("invoice_pk")) + return super().dispatch(request, *args, **kwargs) + + def get(self, *args, **kwargs): + form = self.form_class(instance=self.object) + return render( + self.request, + self.template_name, + {"form": form, "object": self.object}, + ) + + def post(self, *args, **kwargs): + form = self.form_class(self.request.POST, instance=self.object) + if form.is_valid(): + form.save() + return HttpResponse( + status=204, + headers={ + "HX-Trigger": json.dumps( + { + "invoicesUpdated": None, + "showMessage": _("Invoice tags updated."), + } + ) + }, + ) + return render( + self.request, + self.template_name, + {"form": form, "object": self.object}, + status=400, + ) diff --git a/hypha/apply/projects/views/project_partials.py b/hypha/apply/projects/views/project_partials.py index 3f9c00433f..6c63fcf366 100644 --- a/hypha/apply/projects/views/project_partials.py +++ b/hypha/apply/projects/views/project_partials.py @@ -157,3 +157,13 @@ def partial_get_invoice_detail_actions(request: HttpRequest, pk: int, invoice_pk "application_projects/partials/invoice_detail_actions.html", context={"object": invoice, "user": user}, ) + + +@login_required +def partial_get_invoice_tags(request: HttpRequest, pk: int, invoice_pk: int): + invoice = get_object_or_404(Invoice, pk=invoice_pk) + return render( + request, + "application_projects/partials/invoice_tags.html", + context={"object": invoice}, + ) diff --git a/hypha/templates/base.html b/hypha/templates/base.html index 6b4392b606..3a7ecc75ed 100644 --- a/hypha/templates/base.html +++ b/hypha/templates/base.html @@ -142,6 +142,7 @@ shouldSort: false, allowHTML: true, removeItemButton: true, + placeholderValue: selectElement.dataset.placeholder || "", }); selectElement.hasChoicesInstance = true; }; @@ -149,7 +150,7 @@ const choicesElements = document.querySelectorAll(".choices__input--cloned") choicesElements.forEach((choiceElement) => { - const inputPlaceholder = choiceElement.getAttribute("placeholder"); + const inputPlaceholder = choiceElement.getAttribute("placeholder") ?? ""; // Get the computed min-width of the input element otherwise it reset to 1 const minWidth = window.getComputedStyle(choiceElement).getPropertyValue("min-width"); choiceElement.addEventListener("focus", () => {