diff --git a/scipost_django/finances/allocate.py b/scipost_django/finances/allocate.py new file mode 100644 index 0000000000000000000000000000000000000000..124ad376684dec5fc4ac1b993ef0077156a7f3fd --- /dev/null +++ b/scipost_django/finances/allocate.py @@ -0,0 +1,52 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from .models import Subsidy, PubFrac + + +""" +Allocate subsidy amount to compensations of PubFrac or coverage of expenditures. + +Algorithm choices: +* [any_aff] Any PubFrac with affiliation to org +* [any_ctry] Any PubFrac with an affiliation in given list of countries +* [any_orgs] Any PubFrac with an affiliation in given list of orgs + (helps handling interrelated orgs) +* [any_specs] Any PubFrac of publication in given list of specialties +* [all_fund] All PubFracs of publication acknowledging org in Funders +* [all ctry] All PubFracs of publications having at least one affiliation + in given list of countries +* [all_spec] All PubFracs of publication in given list of specialties +* [all_aff] All PubFracs of publication with at least one author with affiliation to org + +Our highest priority is for individual organizations to take responsibility +for their publishing, so the preferred algorithm is aff_org. + +The algorithms are implemented in the following order, +(decreasing level of specificity): +* [any_aff] +* [any_ctry] +* [all_fund] +* [all_ctry] +* [all_spec] +* [all_aff] +""" + + +def allocate_to_any_aff(subsidy: Subsidy): + """ + Allocate the Subsidy to PubFracs with affiliation to Subsidy-giver. + """ + max_year = subsidy.date_until.year if subsidy.date_until else subsidy.date_from.year + uncompensated_pubfracs = PubFrac.objects.filter( + organization=subsidy.organization, + publication__publication_date__year__gte=subsidy.date_from.year, + publication__publication_date__year__lte=max_year, + compensated_by__isnull=True, + ) + print(f"{uncompensated_pubfracs.count() = }") + for pubfrac in uncompensated_pubfracs.all(): + if pubfrac.cf_value <= subsidy.remainder: + pubfrac.compensated_by = subsidy + pubfrac.save() diff --git a/scipost_django/finances/forms.py b/scipost_django/finances/forms.py index 3cfa2f39514c762df446dae97c734e0d98984d25..0dbc90b9d112b9f40912475faf98bccce58b22c8 100644 --- a/scipost_django/finances/forms.py +++ b/scipost_django/finances/forms.py @@ -58,6 +58,7 @@ class SubsidyForm(forms.ModelForm): class Meta: model = Subsidy fields = [ + "algorithm", "organization", "subsidy_type", "description", diff --git a/scipost_django/finances/migrations/0042_subsidy_algorithm_subsidy_algorithm_data.py b/scipost_django/finances/migrations/0042_subsidy_algorithm_subsidy_algorithm_data.py new file mode 100644 index 0000000000000000000000000000000000000000..8625cb11c484501c07ab1d80d87006728aedf9c0 --- /dev/null +++ b/scipost_django/finances/migrations/0042_subsidy_algorithm_subsidy_algorithm_data.py @@ -0,0 +1,62 @@ +# Generated by Django 4.2.10 on 2024-03-16 12:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("finances", "0041_remove_publicationexpenditurecoverage_publication_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="subsidy", + name="algorithm", + field=models.CharField( + choices=[ + ("any_aff", "Any PubFrac with affiliation to org"), + ( + "any_ctry", + "Any PubFrac with an affiliation in given list of countries", + ), + ( + "any_orgs", + "Any PubFrac with an affiliation in given list of orgs", + ), + ( + "any_spec", + "Any PubFrac of publication in given list of specialties", + ), + ( + "all_aff", + "All PubFracs of publication with at least one author with affiliation to org", + ), + ( + "all_ctry", + "All PubFracs of publications having at least one affiliation in given list of countries", + ), + ( + "all_orgs", + "All PubFracs of publications having at least one affiliation in given list of orgs", + ), + ( + "all_spec", + "All PubFracs of publication in given list of specialties", + ), + ( + "all_fund", + "All PubFracs of publication acknowledging org in Funders", + ), + ("reserves", "Allocate to reserves fund"), + ], + default="reserves", + max_length=32, + ), + ), + migrations.AddField( + model_name="subsidy", + name="algorithm_data", + field=models.JSONField(default=dict), + ), + ] diff --git a/scipost_django/finances/models/subsidy.py b/scipost_django/finances/models/subsidy.py index e62a00cca4aabd819adf8e307204da6e3b2d03e5..c416a05d6f5a9bd76bf65771741d43bf9ce09d4d 100644 --- a/scipost_django/finances/models/subsidy.py +++ b/scipost_django/finances/models/subsidy.py @@ -28,11 +28,69 @@ class Subsidy(models.Model): * a donation The date_from field represents the date at which the Subsidy was formally agreed, - or (e.g. for Sponsorship Agreements) the date at which the agreement enters into force. - The date_until field is optional, and represents (where applicable) the date + or (e.g. for Sponsorship Agreements) the date at which the agreement enters into + force. The date_until field is optional, and represents (where applicable) the date after which the object of the Subsidy is officially terminated. """ + ALGORITHM_ANY_AFF = "any_aff" + ALGORITHM_ANY_CTRY = "any_ctry" + ALGORITHM_ANY_ORGS = "any_orgs" + ALGORITHM_ANY_SPEC = "any_spec" + ALGORITHM_ALL_AFF = "all_aff" + ALGORITHM_ALL_CTRY = "all_ctry" + ALGORITHM_ALL_ORGS = "all_orgs" + ALGORITHM_ALL_SPEC = "all_spec" + ALGORITHM_ALL_FUND = "all_fund" + ALGORITHM_RESERVES = "reserves" + ALGORITHM_CHOICES = ( + (ALGORITHM_ANY_AFF, "Any PubFrac with affiliation to org"), + ( + ALGORITHM_ANY_CTRY, + "Any PubFrac with an affiliation in given list of countries", + ), + (ALGORITHM_ANY_ORGS, "Any PubFrac with an affiliation in given list of orgs"), + ( + ALGORITHM_ANY_SPEC, + "Any PubFrac of publication in given list of specialties", + ), + ( + ALGORITHM_ALL_AFF, + ( + "All PubFracs of publication with at least one author " + "with affiliation to org" + ), + ), + ( + ALGORITHM_ALL_CTRY, + ( + "All PubFracs of publications having at least one affiliation " + "in given list of countries" + ), + ), + ( + ALGORITHM_ALL_ORGS, + ( + "All PubFracs of publications having at least " + "one affiliation in given list of orgs" + ), + ), + ( + ALGORITHM_ALL_SPEC, + "All PubFracs of publication in given list of specialties", + ), + ( + ALGORITHM_ALL_FUND, + "All PubFracs of publication acknowledging org in Funders", + ), + (ALGORITHM_RESERVES, "Allocate to reserves fund"), + ) + algorithm = models.CharField( + max_length=32, + choices=ALGORITHM_CHOICES, + default=ALGORITHM_RESERVES, + ) + algorithm_data = models.JSONField(default=dict) organization = models.ForeignKey["Organization"]( "organizations.Organization", on_delete=models.CASCADE ) @@ -121,6 +179,15 @@ class Subsidy(models.Model): """ return self.amount == self.payments.aggregate(Sum("amount"))["amount__sum"] + def allocate(self): + """ + Allocate the funds according to the algorithm specific by the instance. + """ + from finances.allocate import allocate_to_any_aff + + if self.algorithm == self.ALGORITHM_ANY_AFF: + allocate_to_any_aff(self) + @property def total_compensations(self): """ diff --git a/scipost_django/finances/templates/finances/_subsidy_details.html b/scipost_django/finances/templates/finances/_subsidy_details.html index 92d363fc9c1901329c603819eb8c3db623d20b26..04f7c3965639775c48cdc6f2868620408b206f0d 100644 --- a/scipost_django/finances/templates/finances/_subsidy_details.html +++ b/scipost_django/finances/templates/finances/_subsidy_details.html @@ -35,6 +35,10 @@ </tr> {% endif %} {% if perms.scipost.can_manage_subsidies %} + <tr> + <td>Allocation algorithm</td> + <td>{{ subsidy.get_algorithm_display }}</td> + </tr> <tr> <td>Renewable?</td><td>{% if subsidy.renewable == True %}Yes, renewal action date: <span class="bg-{{ subsidy.renewal_action_date_color_class }}">{{ subsidy.renewal_action_date }}</span>{% elif subsidy.renewable == None %}Undetermined [please update]{% else %}No{% endif %}</td> </tr> @@ -111,6 +115,11 @@ {% if subsidy.amount_publicly_shown %} <h3 class="highlight">Expenditures compensated by this Subsidy</h3> + + {% if 'edadmin' in user_roles %} + <a class="btn btn-primary" href="{% url 'finances:allocate_subsidy' subsidy_id=subsidy.id %}">Allocate this Subsidy</a> + {% endif %} + <div class="row"> <div class="col-lg-6"> <table class="table mt-2 caption-top"> @@ -150,7 +159,7 @@ <td>€{{ subsidy.amount }}</td> </tr> <tr> - <th>Compensations</th> + <th>PubFrac Compensations</th> <td>€{{ subsidy.total_compensations }}</td> </tr> <tr class="bg-secondary bg-opacity-10"> diff --git a/scipost_django/finances/urls.py b/scipost_django/finances/urls.py index 45964d99921d483efddac135f79108ee38fbcfd0..ae25f1d0e26d23b433f35b1a876dfabeffb5b052 100644 --- a/scipost_django/finances/urls.py +++ b/scipost_django/finances/urls.py @@ -41,6 +41,11 @@ urlpatterns = [ "<int:subsidy_id>/", include( [ + path( + "allocate", + views.allocate_subsidy, + name="allocate_subsidy", + ), path( "_hx_subsidy_finadmin_details", views._hx_subsidy_finadmin_details, diff --git a/scipost_django/finances/utils.py b/scipost_django/finances/utils.py index ca818cf3c2ad34bf802dffcefb633637f89a38e7..0a5c77d71990a702d70408c406e5d18866af55e0 100644 --- a/scipost_django/finances/utils.py +++ b/scipost_django/finances/utils.py @@ -11,46 +11,3 @@ def id_to_slug(id): def slug_to_id(slug): return max(0, int(slug) - 821) - - -def allocate_subsidy(subsidy: Subsidy, algorithm: str): - """ - Allocate subsidy amount to compensations of PubFrac or coverage of expenditures. - - Algorithm choices: - * any PubFrac ascribed to org from affiliations - * full PEX of publication having at least one author affiliated to org - * any PubFrac involving an affiliation with same country as org - * full PEX of publication having at least one author affiliation with same country as org - * full PEX of publication acknowledging org in Funders - * full PEX of publication in specialties specified by Subsidy - """ - - algorithms = [ - "compensate_PubFrac_related_to_Org", - "cover_full_PEX_if_PubFrac_related_to_Org", - "compensate_PubFrac_if_author_affiliation_same_country_as_Org", - "cover_full_PEX_if_author_affiliation_same_country_as_Org", - "cover_full_PEX_if_pub_funding_ack_includes_Org", - "cover_full_PEX_if_pub_matches_specialties", - "allocate_to_reserve_fund", - ] - - if algorithm is "PubFrac_ascribed_to_Org": - max_year = ( - subsidy.date_until.year if subsidy.date_until else subsidy.date_from.year - ) - pubfracs = PubFrac.objects.filter( - organization=subsidy.organization, - publication__publication_date__year__gte=subsidy.date_from.year, - publication__publication_date__year__lte=max_year, - ) - distributed = 0 - for pubfrac in pubfracs.all(): - print(f"{distributed = };\tadding {pubfrac = }") - if pubfrac.cf_value <= subsidy.remainder: - pubfrac.compensated_by = subsidy - pubfrac.save() - distributed += pubfrac.cf_value - else: - break diff --git a/scipost_django/finances/views.py b/scipost_django/finances/views.py index 0e21f066ddd87075702160b269c13882fdbcfe30..ceb0db23e18d5273c4427ed4d356e58cb82318f4 100644 --- a/scipost_django/finances/views.py +++ b/scipost_django/finances/views.py @@ -24,7 +24,7 @@ from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import PermissionDenied from django.core.paginator import Paginator -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from django.utils import timezone from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, render, redirect @@ -420,6 +420,13 @@ def _hx_subsidy_list(request): return render(request, "finances/_hx_subsidy_list.html", context) +@permission_required("scipost.can_manage_subsidies", raise_exception=True) +def allocate_subsidy(request, subsidy_id:int): + subsidy = get_object_or_404(Subsidy, pk=subsidy_id) + subsidy.allocate() + return redirect(reverse("finances:subsidy_details", kwargs={"pk": subsidy.id})) + + @permission_required("scipost.can_manage_subsidies", raise_exception=True) def _hx_subsidy_finadmin_details(request, subsidy_id: int): subsidy = get_object_or_404(Subsidy, pk=subsidy_id)