diff --git a/scipost_django/finances/admin.py b/scipost_django/finances/admin.py index f2fd82f5fe2beec896bcded4f466e759abec3289..1c5dc3b9b7ce41c2dd3deaba88532fdc3bb933db 100644 --- a/scipost_django/finances/admin.py +++ b/scipost_django/finances/admin.py @@ -2,10 +2,12 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" +from django import forms from django.contrib import admin from finances.models.account import Account from finances.models.balance import Balance +from finances.models.subsidy import SubsidyCollective from finances.models.transaction import FuturePeriodicTransaction from .models import ( @@ -81,6 +83,23 @@ def detach(modeladmin, request, queryset): invoice_proof.save() +class SubsidyInline(admin.TabularInline): + model = Subsidy + autocomplete_fields = [ + "organization", + "renewal_of", + "individual_budget", + "collective", + ] + extra = 0 + + def formfield_for_dbfield(self, db_field, request, **kwargs): + if db_field.name == "description": + kwargs["widget"] = forms.Textarea(attrs={"rows": 1, "cols": 20}) + return db_field.formfield(**kwargs) + return super().formfield_for_dbfield(db_field, request, **kwargs) + + @admin.register(SubsidyAttachment) class SubsidyAttachmentAdmin(admin.ModelAdmin): list_display = [ @@ -184,3 +203,32 @@ class AccountAdmin(admin.ModelAdmin): FuturePeriodicTransactionInline, BalanceInline, ] + + +@admin.register(SubsidyCollective) +class SubsidyCollectiveAdmin(admin.ModelAdmin): + list_display = [ + "str", + "coordinator", + "subsidy_count", + ] + autocomplete_fields = [ + "coordinator", + ] + search_fields = [ + "str", + "description", + "coordinator__name", + "coordinator__name_original", + "coordinator__acronym", + "subsidies__organization__name", + ] + inlines = [SubsidyInline] + + @admin.display(description="str") + def str(self, obj): + return str(obj) + + @admin.display(description="subsidy count") + def subsidy_count(self, obj): + return obj.subsidies.count() diff --git a/scipost_django/finances/forms.py b/scipost_django/finances/forms.py index aadfb6faa0a6b8cf8d1828541c3b50fb8ba91991..90fc301e9dc02ebedc6f76f8b8f10f21d5cb7850 100644 --- a/scipost_django/finances/forms.py +++ b/scipost_django/finances/forms.py @@ -25,6 +25,7 @@ from finances.constants import ( SUBSIDY_TYPES, ) +from finances.models.subsidy import SubsidyCollective from organizations.models import Organization from scipost.fields import UserModelChoiceField @@ -71,6 +72,7 @@ class SubsidyForm(forms.ModelForm): "date_until", "renewable", "renewal_of", + "collective", ] widgets = { "paid_on": forms.DateInput(attrs={"type": "date"}), @@ -78,6 +80,13 @@ class SubsidyForm(forms.ModelForm): "date_until": forms.DateInput(attrs={"type": "date"}), } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + subsidy_collective_create = reverse_lazy("finances:subsidy_collective_create") + self.fields["collective"].help_text = ( + f"If missing, <a href='{subsidy_collective_create}'>create a new one</a>." + ) + class SubsidySearchForm(forms.Form): organization_query = forms.CharField( @@ -812,3 +821,141 @@ class LogsFilterForm(forms.Form): ) return output + + +class SubsidyCollectiveForm(forms.ModelForm): + required_css_class = "required-asterisk" + + subsidies = forms.ModelMultipleChoiceField( + queryset=Subsidy.objects.all(), + widget=autocomplete.ModelSelect2Multiple( + url=reverse_lazy("finances:subsidy_autocomplete"), + attrs={ + "data-html": True, + "style": "width: 100%", + }, + ), + required=False, + ) + + class Meta: + model = SubsidyCollective + fields = ["name", "description", "coordinator"] + widgets = { + "coordinator": autocomplete.ModelSelect2( + url=reverse_lazy("organizations:organization-autocomplete"), + attrs={ + "data-html": True, + "style": "width: 100%", + }, + ), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance.pk: + self.fields["subsidies"].initial = self.instance.subsidies.all() + + self.helper = FormHelper() + self.helper.layout = Layout( + Field("name"), + Field("description"), + Field("coordinator"), + Field("subsidies"), + ButtonHolder(Submit("submit", "Submit", css_class="btn-sm")), + ) + + def save(self, commit: bool = True): + collective = super().save(commit) + + collective.subsidies.set(self.cleaned_data["subsidies"]) + return collective + + +class SubsidyCollectiveRenewForm(forms.Form): + subsidies = forms.ModelMultipleChoiceField( + queryset=Subsidy.objects.all(), + widget=forms.CheckboxSelectMultiple, + ) + + start_date = forms.DateField( + widget=forms.DateInput(attrs={"type": "date"}), + required=False, + ) + end_date = forms.DateField( + widget=forms.DateInput(attrs={"type": "date"}), + required=False, + ) + + def __init__(self, *args, **kwargs): + self.collective = kwargs.pop("collective") + super().__init__(*args, **kwargs) + self.fields["subsidies"].queryset = self.collective.subsidies.all() + self.fields["subsidies"].initial = self.fields["subsidies"].queryset + + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Field("subsidies", css_class="col-12"), + Div(FloatingField("start_date"), css_class="col-6"), + Div(FloatingField("end_date"), css_class="col-6"), + css_class="row mb-0", + ), + ButtonHolder(Submit("submit", "Renew", css_class="btn-sm")), + ) + + def clean(self): + valid = self.is_valid() + if not (data := self.cleaned_data): + raise forms.ValidationError("No data was submitted") + elif not valid: + raise forms.ValidationError("Invalid form data") + + start = data.get("start_date") + end = data.get("end_date") + + if start > end: + self.add_error("end_date", "End date must be after start date") + + return data + + def save(self): + start_date = self.cleaned_data["start_date"] + end_date = self.cleaned_data["end_date"] + + new_subsidies = [ + Subsidy( + organization=subsidy.organization, + subsidy_type=subsidy.subsidy_type, + description=subsidy.description, + amount=subsidy.amount, + amount_publicly_shown=subsidy.amount_publicly_shown, + status=subsidy.status, + paid_on=subsidy.paid_on, + renewable=subsidy.renewable, + # Renewal dates are optional + date_from=start_date or subsidy.date_from, + date_until=end_date or subsidy.date_until, + ) + for subsidy in self.cleaned_data["subsidies"] + ] + + # Create new subsidies + Subsidy.objects.bulk_create(new_subsidies) + + # Update `renewal_of` field to point to the original subsidy + for new, old in zip(new_subsidies, self.cleaned_data["subsidies"]): + new.renewal_of.add(old) + new.save() + + # Create new collective + new_collective = SubsidyCollective.objects.create( + name=f"{self.collective.name} - Renewal {start_date} - {end_date}", + description=f"{self.collective.description}\n Renewal {start_date} - {end_date}", + coordinator=self.collective.coordinator, + ) + new_collective.subsidies.set(new_subsidies) + new_collective.save() + + return new_collective diff --git a/scipost_django/finances/migrations/0047_subsidycollective.py b/scipost_django/finances/migrations/0047_subsidycollective.py new file mode 100644 index 0000000000000000000000000000000000000000..c862c26c621db6ab9bec583640ee6115ac439785 --- /dev/null +++ b/scipost_django/finances/migrations/0047_subsidycollective.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.15 on 2024-12-02 12:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("organizations", "0024_contactperson_info_source"), + ("finances", "0046_account_transaction_futureperiodictransaction_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SubsidyCollective", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(blank=True, max_length=256, null=True)), + ("description", models.TextField(blank=True, null=True)), + ( + "coordinator", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="organizations.organization", + ), + ), + ], + options={ + "default_related_name": "collectives", + "verbose_name_plural": "subsidy collectives", + "ordering": ["coordinator__name"], + }, + ), + migrations.AddField( + model_name="subsidy", + name="collective", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="subsidies", + to="finances.subsidycollective", + ), + ), + ] diff --git a/scipost_django/finances/models/subsidy.py b/scipost_django/finances/models/subsidy.py index 8467098b9d7b3870c531bb056588dbb1e09f35df..e1f248444e5c096ac072db10e0771b5d6f6d7c28 100644 --- a/scipost_django/finances/models/subsidy.py +++ b/scipost_django/finances/models/subsidy.py @@ -7,6 +7,7 @@ import datetime from django.db import models from django.db.models import Sum +from django.db.models.functions import ExtractYear from django.urls import reverse from django.utils.html import format_html @@ -18,6 +19,12 @@ from ..constants import ( ) from ..managers import SubsidyQuerySet, PubFracQuerySet +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from django.db.models.manager import RelatedManager + from organizations.models import Organization + class Subsidy(models.Model): """ @@ -54,6 +61,16 @@ class Subsidy(models.Model): renewal_of = models.ManyToManyField( "self", related_name="renewed_by", symmetrical=False, blank=True ) + collective = models.ForeignKey["SubsidyCollective"]( + "SubsidyCollective", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="subsidies", + ) + + if TYPE_CHECKING: + collectives: RelatedManager["SubsidyCollective"] objects = SubsidyQuerySet.as_manager() @@ -189,3 +206,57 @@ class Subsidy(models.Model): compensated[pubfrac.publication.doi_label]["fraction"] += pubfrac.fraction compensated[pubfrac.publication.doi_label]["value"] += pubfrac.cf_value return compensated + + +class SubsidyCollective(models.Model): + """ + A collective of Subsidies, which can be used to group together relevant subsidies. + Primarily used to group together all subsidies from a single collective agreement, + as coordinated by a "parent" Organization. + """ + + coordinator = models.ForeignKey["Organization"]( + "organizations.Organization", on_delete=models.CASCADE + ) + name = models.CharField(max_length=256, null=True, blank=True) + description = models.TextField(null=True, blank=True) + + if TYPE_CHECKING: + subsidies: RelatedManager[Subsidy] + + class Meta: + verbose_name_plural = "subsidy collectives" + default_related_name = "collectives" + ordering = ["coordinator__name"] + + @property + def year_str(self): + min_year = ( + self.subsidies.annotate(year=ExtractYear("date_from")) + .values_list("year", flat=True) + .order_by("year") + .first() + ) + max_year = ( + self.subsidies.annotate(year=ExtractYear("date_until")) + .values_list("year", flat=True) + .order_by("year") + .last() + ) + if min_year and max_year: + if min_year == max_year: + return str(min_year) + return f"{min_year}-{max_year}" + + def __str__(self): + if self.name: + return self.name + + str_rep = f"Collective by {self.coordinator}" + if self.year_str: + str_rep += f" for {self.year_str}" + + return str_rep + + def get_absolute_url(self): + return reverse("finances:subsidy_collective_details", args=(self.id,)) diff --git a/scipost_django/finances/templates/finances/_subsidy_collective_nav_links_list.html b/scipost_django/finances/templates/finances/_subsidy_collective_nav_links_list.html new file mode 100644 index 0000000000000000000000000000000000000000..aa3bda1904ef785c5538f6ccc378af69bab101ca --- /dev/null +++ b/scipost_django/finances/templates/finances/_subsidy_collective_nav_links_list.html @@ -0,0 +1,14 @@ +<h5> + <a href="{{ collective.get_absolute_url }}">{{ collective }}</a> +</h5> + + <ul class="list-unstyled"> + {% for sub in collective.subsidies.all %} + <li class="nav-item"> + <a href="{{ sub.get_absolute_url }}" class="nav-link {% if sub.id == subsidy.id %}active{% endif %}"> + {{ sub.organization }} + </a> + </li> + {% endfor %} + </ul> + diff --git a/scipost_django/finances/templates/finances/_subsidy_details.html b/scipost_django/finances/templates/finances/_subsidy_details.html index 9e512ca256cbb684ad6eb3e24fbaf877aec6b0bf..5e1d1ef67e321700259988c131b4edb4a0a00bad 100644 --- a/scipost_django/finances/templates/finances/_subsidy_details.html +++ b/scipost_django/finances/templates/finances/_subsidy_details.html @@ -5,7 +5,7 @@ {% get_obj_perms request.user for subsidy.organization as "user_org_perms" %} <div class="row"> - <div class="col-12"> + <div class="col"> {% if perms.scipost.can_manage_subsidies %} <ul class="list-inline"><li class="list-inline-item"><strong>Admin actions:</strong></li> <li class="list-inline-item"><a href="{% url 'finances:subsidy_update' pk=subsidy.id %}"><span class="text-warning">Update</span></a></li> @@ -65,6 +65,18 @@ {% endif %} </div> + + {% if subsidy.collective %} + <div class="col-12 col-sm-6 col-md-3"> + <h4>Subsidies of the same Collective</h4> + <nav class="nav nav-pills flex-column"> + + {% include "finances/_subsidy_collective_nav_links_list.html" with collective=subsidy.collective %} + + </nav> + </div> + {% endif %} + </div> {% if "finadmin" in user_roles %} diff --git a/scipost_django/finances/templates/finances/subsidy_collective_delete.html b/scipost_django/finances/templates/finances/subsidy_collective_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..f77deb37e20d26b5838ddd8c84804064ae2a28b4 --- /dev/null +++ b/scipost_django/finances/templates/finances/subsidy_collective_delete.html @@ -0,0 +1,48 @@ +{% extends 'finances/base.html' %} + +{% load bootstrap %} + +{% block breadcrumb_items %} + {{ block.super }} + <span class="breadcrumb-item"><a href="{% url 'finances:subsidies' %}">Subsidies</a></span> + <span class="breadcrumb-item">Collectives</span> + <span class="breadcrumb-item"><a href="{{ object.get_absolute_url }}">{{ object }}</a></span> + <span class="breadcrumb-item"><a href="#" class="active">Delete</a></span> +{% endblock %} + +{% block pagetitle %} + : Delete Subsidy Collective +{% endblock pagetitle %} + +{% block content %} + + <hgroup class="highlight p-3 mb-3"> + <h1>Delete Subsidy Collective</h1> + <p class="m-0 fs-4"><a href="{{ collective.get_absolute_url }}">{{ collective }}</a></p> + </hgroup> + + <div class="row"> + <div class="col-12"> + <h2><a href="{{ collective.get_absolute_url }}">{{ collective }}</a></h2> + <p>The collective is coordinated by <a href="{{ collective.coordinator.get_absolute_url }}">{{ collective.coordinator }}</a>.</p> + <p>{{ collective.description }}</p> + + <h2>Subsidies part of this Collective</h2> + <ul class="list-unstyled"> + {% for subsidy in collective.subsidies.all %} + <li><a href="{{ subsidy.get_absolute_url }}">{{ subsidy }}</a></li> + {% endfor %} + </ul> + </div> + + <div class="col-12"> + <form method="post"> + {% csrf_token %} + <div class="fs-5 mb-2">Are you sure you want to delete this Subsidy Collective?</div> + <p>Deleting this collective will <strong>not</strong> delete the subsidies associated with it.</p> + <input type="submit" class="btn btn-danger" value="Yes, delete it" /> + </form> + </div> + </div> + +{% endblock content %} diff --git a/scipost_django/finances/templates/finances/subsidy_collective_detail.html b/scipost_django/finances/templates/finances/subsidy_collective_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..e8d7b3bb8685a9d125812acf424e743671c9f5f7 --- /dev/null +++ b/scipost_django/finances/templates/finances/subsidy_collective_detail.html @@ -0,0 +1,89 @@ +{% extends 'finances/base.html' %} + +{% load bootstrap %} + +{% block meta_description %} + {{ block.super }} Subsidy Collective Detail +{% endblock meta_description %} + +{% block pagetitle %} + : Subsidy Collective details +{% endblock pagetitle %} + +{% block breadcrumb_items %} + {{ block.super }} + <span class="breadcrumb-item"><a href="{% url 'finances:subsidies' %}">Subsidies</a></span> + <span class="breadcrumb-item"><a href="{% url 'finances:subsidy_collectives' %}">Collectives</a></span> + <span class="breadcrumb-item"><a href="#" class="active">{{ collective }}</a></span> +{% endblock %} + +{% block content %} + + <div class="highlight p-3 d-flex flex-row justify-content-between align-items-center mb-3"> + <hgroup> + <h1>{{ collective }}</h1> + <p class="m-0 fs-4">(Coordinated by <a href="{{ collective.coordinator.get_absolute_url }}">{{ collective.coordinator }}</a>)</p> + </hgroup> + <div class="dropdown"> + <button class="btn btn-sm btn-light dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> + <span>Actions</span> + </button> + <ul class="dropdown-menu dropdown-menu-end"> + <li><a href="{% url 'finances:subsidy_collective_renew' collective_id=collective.id %}" class="dropdown-item">Renew</a></li> + <li><hr class="dropdown-divider" /></li> + <li><a href="{% url 'finances:subsidy_collective_update' collective_id=collective.id %}" class="dropdown-item">Edit</a></li> + <li><a href="{% url 'finances:subsidy_collective_delete' collective_id=collective.id %}" class="dropdown-item">Delete</a></li> + </ul> + </div> + </div> + + {% if collective.description %} + <div class="fs-5 fw-bold">Description</div> + <p>{{ collective.description }}</p> + {% endif %} + + <h2>Subsidies part of this Collective</h2> + <table class="table table-hover position-relative"> + <thead class="table-light position-sticky top-0"> + <tr> + <th>From Organization</th> + <th>Type</th> + + {% if perms.scipost.can_manage_subsidies %} + <th> + <span class="small" style="writing-mode: vertical-rl;">Payments + <br /> + Scheduled?</span> + </th> + {% endif %} + + <th>Amount</th> + <th>From</th> + <th>Until</th> + + {% if perms.scipost.can_manage_subsidies %} + + <th>Status</th> + <th> + <span class="small" style="writing-mode: vertical-lr;">Renewable?</span> + </th> + <th> + <span class="small" style="writing-mode: vertical-lr;">Renewed?</span> + </th> + <th> + Renewal + <br /> + action date + </th> + {% endif %} + + + </tr> + + <tbody id="subsidy-table-tbody"> + {% include "finances/_hx_subsidy_list.html" with page_obj=page_encapsulated_subsidies %} + </tbody> + </table> + + + {% endblock content %} diff --git a/scipost_django/finances/templates/finances/subsidy_collective_form.html b/scipost_django/finances/templates/finances/subsidy_collective_form.html new file mode 100644 index 0000000000000000000000000000000000000000..8d52ac37f54b2d5f1f95d3ab5407a82ea07bb3c7 --- /dev/null +++ b/scipost_django/finances/templates/finances/subsidy_collective_form.html @@ -0,0 +1,47 @@ +{% extends 'finances/base.html' %} + +{% load bootstrap %} + +{% block breadcrumb_items %} + {{ block.super }} + <span class="breadcrumb-item"><a href="{% url 'finances:subsidies' %}">Subsidies</a></span> + <span class="breadcrumb-item"><a href="{% url 'finances:subsidy_collectives' %}">Collectives</a></span> + {% if form.instance.id %} + <span class="breadcrumb-item"><a href="{{ form.instance.get_absolute_url }}">{{ form.instance }}</a></span> + <span class="breadcrumb-item"><a href="#" class="active">Update</a></span> + {% else %} + <span class="breadcrumb-item"><a href="#" class="active">Create</a></span> + {% endif %} + +{% endblock %} + +{% block pagetitle %} + : Subsidies +{% endblock pagetitle %} + + +{% block content %} +<hgroup class="highlight p-3 mb-3"> + <h1>{% if form.instance.id %}Update{% else %}Create Subsidy Collective{% endif %} Form</h1> + {% if form.instance.id %} + <p class="m-0 fs-4"><a href="{{ form.instance.get_absolute_url }}">{{ form.instance }}</a></p> + {% endif %} +</hgroup> + + <div class="row"> + <form action="" method="post"> + <div class="col-12"> + {% csrf_token %} + {{ form|bootstrap }} + + <input type="submit" value="Submit" class="btn btn-primary" /> + </div> + </form> + </div> +{% endblock content %} + + +{% block footer_script %} + {{ block.super }} + {{ form.media }} +{% endblock footer_script %} diff --git a/scipost_django/finances/templates/finances/subsidy_collective_list.html b/scipost_django/finances/templates/finances/subsidy_collective_list.html new file mode 100644 index 0000000000000000000000000000000000000000..31b03f1822603e246c938331edd50483c76833c2 --- /dev/null +++ b/scipost_django/finances/templates/finances/subsidy_collective_list.html @@ -0,0 +1,39 @@ +{% extends 'finances/base.html' %} +{% load crispy_forms_tags %} + +{% block meta_description %} + {{ block.super }} Subsidies List +{% endblock meta_description %} + +{% block pagetitle %} + : Subsidies +{% endblock pagetitle %} + +{% load static %} +{% load bootstrap %} + +{% block breadcrumb_items %}{{ block.super }} + <span class="breadcrumb-item"><a href="{% url 'finances:subsidies' %}">Subsidies</a></span> + <span class="breadcrumb-item"><a href="#" class="active">Collectives</a></span> +{% endblock %} + +{% block content %} + + <div class="highlight p-3 d-flex flex-row justify-content-between align-items-center mb-3"> + <h1>Subsidy Collectives</h1> + <a href="{% url 'finances:subsidy_collective_create' %}" class="btn btn-primary">Create</a> + </div> + + <div class="row"> + <div class="col"> + <ul class="list-unstyled"> + {% for collective in collectives %} + <li><a href="{{ collective.get_absolute_url }}">{{ collective }}</a></li> + {% empty %} + <li>No Subsidy Collectives</li> + {% endfor %} + </ul> + </div> + </div> + + {% endblock content %} diff --git a/scipost_django/finances/templates/finances/subsidy_collective_renew_form.html b/scipost_django/finances/templates/finances/subsidy_collective_renew_form.html new file mode 100644 index 0000000000000000000000000000000000000000..7133d2ae4b62aaed3afa11573e5011b42e0412c9 --- /dev/null +++ b/scipost_django/finances/templates/finances/subsidy_collective_renew_form.html @@ -0,0 +1,37 @@ +{% extends 'finances/base.html' %} + +{% load bootstrap %} +{% load crispy_forms_tags %} + +{% block meta_description %} + {{ block.super }} Subsidy Collective Renewal Form +{% endblock meta_description %} + +{% block pagetitle %} + : Subsidy Collective Renewal Form +{% endblock pagetitle %} + +{% block breadcrumb_items %} + {{ block.super }} + <span class="breadcrumb-item"><a href="{% url 'finances:subsidies' %}">Subsidies</a></span> + <span class="breadcrumb-item">Collectives</span> + <span class="breadcrumb-item"><a href="{{ collective.get_absolute_url }}">{{ collective }}</a></span> + <span class="breadcrumb-item"><a href="#" class="active">Renewal Form</a></span> +{% endblock %} + +{% block content %} + + <hgroup class="highlight p-3 mb-3"> + <h1>Renewal Form</h1> + <p class="m-0 fs-4"><a href="{{ collective.get_absolute_url }}">{{ collective }}</a></p> + </hgroup> + + <h2>Renew Subsidies of this Collective</h2> + <p> + Submitting this form will create new subsidies for all selected organizations, keeping the same details as the current subsidy. + If provided, the start and end dates will be overridden by the values in the form. + </p> + + {% crispy form %} + + {% endblock content %} diff --git a/scipost_django/finances/templates/finances/subsidy_list.html b/scipost_django/finances/templates/finances/subsidy_list.html index 7b6f155d57375ba54e0ba316605ad4c752e7f200..504237ca20cb32825467c927b52838a80d36fc7b 100644 --- a/scipost_django/finances/templates/finances/subsidy_list.html +++ b/scipost_django/finances/templates/finances/subsidy_list.html @@ -29,6 +29,9 @@ <li> <a href="{% url 'finances:subsidyattachment_create' %}">Add a SubsidyAttachment</a> </li> + <li> + <a href="{% url 'finances:subsidy_collective_create' %}">Create a Collective Subsidy</a> + </li> <li> <a href="{% url 'finances:subsidyattachment_orphaned_list' %}">Link orphaned SubsidyAttachments</a> </li> diff --git a/scipost_django/finances/urls.py b/scipost_django/finances/urls.py index d3543a1eaea793d065711fd452fc5a56e93e603b..beec4587b03f52f940d001c023fb50b89cf3752a 100644 --- a/scipost_django/finances/urls.py +++ b/scipost_django/finances/urls.py @@ -88,7 +88,44 @@ urlpatterns = [ ] ), ), - ] + path( + "collectives/<int:collective_id>/", + include( + [ + path( + "", + views.SubsidyCollectiveDetailView.as_view(), + name="subsidy_collective_details", + ), + path( + "delete/", + views.SubsidyCollectiveDeleteView.as_view(), + name="subsidy_collective_delete", + ), + path( + "update/", + views.SubsidyCollectiveUpdateView.as_view(), + name="subsidy_collective_update", + ), + path( + "renew/", + views.SubsidyCollectiveRenewFormView.as_view(), + name="subsidy_collective_renew", + ), + ] + ), + ), + path( + "collectives/create/", + views.SubsidyCollectiveCreateView.as_view(), + name="subsidy_collective_create", + ), + path( + "collectives/", + views.SubsidyCollectiveListView.as_view(), + name="subsidy_collectives", + ), + ], ), ), path("subsidies/", views.subsidy_list, name="subsidies"), diff --git a/scipost_django/finances/views.py b/scipost_django/finances/views.py index 8078a674734dfdc731cac0c4463652907ca73854..8983a7c839408e509f616c3989528ce3524e0777 100644 --- a/scipost_django/finances/views.py +++ b/scipost_django/finances/views.py @@ -5,19 +5,23 @@ __license__ = "AGPL v3" import datetime from itertools import accumulate, chain import mimetypes +from typing import Any from dal import autocomplete from django.contrib.contenttypes.models import ContentType +from django.core.handlers.asgi import HttpRequest from django.db import models from django.db.models import Q, Count, Exists, OuterRef, Subquery from django.db.models.functions import Coalesce from django.template.response import TemplateResponse from django.utils.html import format_html +from django.views.generic import FormView import matplotlib from common.views import HXDynselAutocomplete, HXDynselSelectOptionView from finances.constants import SUBSIDY_TYPE_SPONSORSHIPAGREEMENT, SUBSIDY_PROMISED from finances.models.account import Account +from finances.models.subsidy import SubsidyCollective from journals.models.publication import PublicationAuthorsTable matplotlib.use("Agg") @@ -40,6 +44,8 @@ from django.views.generic.list import ListView from .forms import ( SubsidyAttachmentInlineLinkForm, SubsidyAttachmentSearchForm, + SubsidyCollectiveForm, + SubsidyCollectiveRenewForm, SubsidyForm, SubsidySearchForm, SubsidyPaymentForm, @@ -882,3 +888,95 @@ def periodicreport_file(request, pk): filename = periodicreport._file.name response["Content-Disposition"] = f"filename={filename}" return response + + +####################### +# Subsidy Collectives # +####################### + + +class SubsidyCollectiveListView(PermissionsMixin, ListView): + model = SubsidyCollective + template_name = "finances/subsidy_collective_list.html" + permission_required = "scipost.can_manage_subsidies" + context_object_name = "collectives" + + +class SubsidyCollectiveDetailView(PermissionsMixin, DetailView): + model = SubsidyCollective + template_name = "finances/subsidy_collective_detail.html" + permission_required = "scipost.can_manage_subsidies" + pk_url_kwarg = "collective_id" + context_object_name = "collective" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_encapsulated_subsidies"] = Paginator( + self.object.subsidies.all(), + 1000, + ).get_page(self.request.GET.get("page")) + return context + + +class SubsidyCollectiveDeleteView(PermissionsMixin, DeleteView): + model = SubsidyCollective + template_name = "finances/subsidy_collective_delete.html" + success_url = reverse_lazy("finances:subsidy_collectives") + permission_required = "scipost.can_manage_subsidies" + pk_url_kwarg = "collective_id" + context_object_name = "collective" + + +class SubsidyCollectiveCreateView(PermissionsMixin, CreateView): + model = SubsidyCollective + form_class = SubsidyCollectiveForm + template_name = "finances/subsidy_collective_form.html" + permission_required = "scipost.can_manage_subsidies" + pk_url_kwarg = "collective_id" + context_object_name = "collective" + + def get_success_url(self): + return self.object.get_absolute_url() + + +class SubsidyCollectiveUpdateView(PermissionsMixin, UpdateView): + model = SubsidyCollective + form_class = SubsidyCollectiveForm + template_name = "finances/subsidy_collective_form.html" + permission_required = "scipost.can_manage_subsidies" + pk_url_kwarg = "collective_id" + context_object_name = "collective" + + def get_success_url(self): + return self.object.get_absolute_url() + + +class SubsidyCollectiveRenewFormView(FormView): + template_name = "finances/subsidy_collective_renew_form.html" + form_class = SubsidyCollectiveRenewForm + + def dispatch(self, request: HttpRequest, *args, **kwargs): + self.collective = get_object_or_404( + SubsidyCollective, pk=kwargs.get("collective_id", None) + ) + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self) -> dict[str, Any]: + kwargs = super().get_form_kwargs() + kwargs["collective"] = self.collective + return kwargs + + def get_initial(self): + return {"collective": self.collective} + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["collective"] = self.collective + return context + + def get_success_url(self): + return self.new_collective.get_absolute_url() + + def form_valid(self, form): + self.new_collective = form.save() + return super().form_valid(form)