diff --git a/scipost_django/finances/management/commands/recompute_pubfrac_cf_value.py b/scipost_django/finances/management/commands/recompute_pubfrac_cf_value.py new file mode 100644 index 0000000000000000000000000000000000000000..957ffd7de9158f56abb7385b834d8cd5ec932510 --- /dev/null +++ b/scipost_django/finances/management/commands/recompute_pubfrac_cf_value.py @@ -0,0 +1,21 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.core.management.base import BaseCommand + +from finances.models import PubFrac + + +class Command(BaseCommand): + help = "For all PubFrac objects, recompute the cf_value field" + + def handle(self, *args, **kwargs): + for pf in PubFrac.objects.all(): + pf.cf_value = int( + pf.fraction + * pf.publication.get_journal().cost_per_publication( + pf.publication.publication_date.year + ) + ) + pf.save() diff --git a/scipost_django/finances/migrations/0033_populate_pubfracs.py b/scipost_django/finances/migrations/0033_populate_pubfracs.py index 86aad1297c0f9b58a5658ce6e78dd6dbf34d99c2..d4a07844f03c1a91283da5339fa3ad8112aa0b64 100644 --- a/scipost_django/finances/migrations/0033_populate_pubfracs.py +++ b/scipost_django/finances/migrations/0033_populate_pubfracs.py @@ -12,7 +12,8 @@ def populate_pubfracs(apps, schema_editor): pubfrac = PubFrac( organization=opf.organization, publication=opf.publication, - fraction=opf.fraction) + fraction=opf.fraction + ) pubfrac.save() @@ -26,5 +27,5 @@ class Migration(migrations.Migration): migrations.RunPython( populate_pubfracs, reverse_code=migrations.RunPython.noop, - ) + ) ] diff --git a/scipost_django/finances/migrations/0039_alter_pubfraccompensation_pubfrac.py b/scipost_django/finances/migrations/0039_alter_pubfraccompensation_pubfrac.py new file mode 100644 index 0000000000000000000000000000000000000000..0fbd4b7faf590a64715a1cbf1d9eafbce4e7ea8d --- /dev/null +++ b/scipost_django/finances/migrations/0039_alter_pubfraccompensation_pubfrac.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.10 on 2024-03-15 15:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("finances", "0038_alter_pubfrac_options"), + ] + + operations = [ + migrations.AlterField( + model_name="pubfraccompensation", + name="pubfrac", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pubfrac_compensations", + to="finances.pubfrac", + ), + ), + ] diff --git a/scipost_django/finances/models/pubfrac.py b/scipost_django/finances/models/pubfrac.py index cd483acd7514f74b46235b07eb09833a1e2ace9f..b4c9015c048f8008af2038bfca8cbbf390f23708 100644 --- a/scipost_django/finances/models/pubfrac.py +++ b/scipost_django/finances/models/pubfrac.py @@ -42,16 +42,32 @@ class PubFrac(models.Model): verbose_name_plural = "PubFracs" def __str__(self): - return (f"{str(self.fraction)} (€{self.cf_value}) " - f"for {self.publication.doi_label} from {self.organization}") + return ( + f"{str(self.fraction)} (€{self.cf_value}) " + f"for {self.publication.doi_label} from {self.organization}" + ) + + @property + def compensated(self): + """Compensated part of this PubFrac.""" + return ( + self.pubfrac_compensations.aggregate(models.Sum("amount"))["amount__sum"] + if self.pubfrac_compensations.exists() + else 0 + ) + + @property + def arrears(self): + """Uncovered and uncompensated part of this PubFrac.""" + return self.cf_value - self.compensated @receiver(pre_save, sender=PubFrac) def calculate_cf_value(sender, instance: PubFrac, **kwargs): """Calculate the cf_value field before saving.""" instance.cf_value = int( - instance.fraction * instance.publication.get_journal( - ).cost_per_publication( + instance.fraction + * instance.publication.get_journal().cost_per_publication( instance.publication.publication_date.year ) ) diff --git a/scipost_django/finances/models/pubfrac_compensation.py b/scipost_django/finances/models/pubfrac_compensation.py index 41625f2d28d1683f08c63e3d4bac9dae2431562c..9539d973b17ea5463a6e54cb4e6c27044675c48e 100644 --- a/scipost_django/finances/models/pubfrac_compensation.py +++ b/scipost_django/finances/models/pubfrac_compensation.py @@ -18,7 +18,7 @@ class PubFracCompensation(models.Model): pubfrac = models.ForeignKey( "finances.PubFrac", - related_name="pubfracs", + related_name="pubfrac_compensations", on_delete=models.CASCADE, ) diff --git a/scipost_django/finances/models/subsidy.py b/scipost_django/finances/models/subsidy.py index 27e281dc61bfc6b610590990ebdaee6d0a97b325..44a81a4be96b9c3a3b0c4c02034959c2601bad3c 100644 --- a/scipost_django/finances/models/subsidy.py +++ b/scipost_django/finances/models/subsidy.py @@ -57,10 +57,14 @@ class Subsidy(models.Model): def __str__(self): if self.amount_publicly_shown: - return (f"{self.date_from}{f' - {self.date_until}' if self.date_until else ''}: " - f"€{self.amount} from {self.organization}") - return (f"{self.date_from}{f' - {self.date_until}' if self.date_until else ''}: " - f"from {self.organization}") + return ( + f"{self.date_from}{f' - {self.date_until}' if self.date_until else ''}: " + f"€{self.amount} from {self.organization}" + ) + return ( + f"{self.date_from}{f' - {self.date_until}' if self.date_until else ''}: " + f"from {self.organization}" + ) def get_absolute_url(self): return reverse("finances:subsidy_details", args=(self.id,)) @@ -116,3 +120,21 @@ class Subsidy(models.Model): Verify that there exist SubsidyPayment objects covering full amount. """ return self.amount == self.payments.aggregate(Sum("amount"))["amount__sum"] + + @property + def committed(self): + """ + Sum of the amounts of all PubFracCompensations related to this Subsidy. + """ + return ( + self.pubfrac_compensations.aggregate(Sum("amount"))["amount__sum"] + if self.pubfrac_compensations.exists() + else 0 + ) + + @property + def remainder(self): + """ + Part of the Subsidy amount which hasn't been used in a PubFracCompensation. + """ + return self.amount - self.committed diff --git a/scipost_django/finances/utils.py b/scipost_django/finances/utils.py index 8bd0f7a2602b7cb9267aebe7cc379c318e5116d3..676246545a369471a3887ce762cef973c8d46cf0 100644 --- a/scipost_django/finances/utils.py +++ b/scipost_django/finances/utils.py @@ -2,9 +2,58 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" +from .models import Subsidy, PubFrac, PubFracCompensation + + def id_to_slug(id): return max(0, int(id) + 821) def slug_to_id(slug): return max(0, int(slug) - 821) + + +def distribute_subsidy(subsidy: Subsidy, algorithm: str): + """ + Allocate subsidy amount to compensations of PubFrac + + 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 = [ + "PubFrac_ascribed_to_Org", + "full_PEX_if_author_affiliated_to_Org", + "PubFrac_author_affiliation_same_country_as_Org", + "full_PEX_author_affiliation_same_country_as_Org", + "full_PEX_if_pub_funding_ack_includes_Org", + "full_PEX_if_pub_matches_specialties", + ] + + 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: + pfc, created = PubFracCompensation.objects.get_or_create( + subsidy=subsidy, + pubfrac=pubfrac, + amount=pubfrac.cf_value, + ) + if created: + distributed += pubfrac.cf_value + else: + break diff --git a/scipost_django/journals/models/publication.py b/scipost_django/journals/models/publication.py index c608d59515db6eb2ecdc2d1c959b89ecc278aeb7..888d0ca1f1143e6c4b6c117a5bd80211f1cb1fbd 100644 --- a/scipost_django/journals/models/publication.py +++ b/scipost_django/journals/models/publication.py @@ -25,6 +25,7 @@ from ..managers import PublicationQuerySet from ..validators import doi_publication_validator from common.utils import get_current_domain +from finances.models import PubFracCompensation from scipost.constants import SCIPOST_APPROACHES from scipost.fields import ChoiceArrayField @@ -419,11 +420,27 @@ class Publication(models.Model): "funding_statement" in self.metadata and self.metadata["funding_statement"] ) + @property + def expenditure(self): + """The expenditure (as defined by the Journal) to produce this Publication.""" + return self.get_journal().cost_per_publication(self.publication_date.year) + @property def pubfracs_sum_to_1(self): """Checks that the support fractions sum up to one.""" return self.pubfracs.aggregate(Sum("fraction"))["fraction__sum"] == 1 + @property + def compensated_expenditures(self): + """Uncompensated part of expenditures for this Publication.""" + qs = PubFracCompensation.objects.filter(pubfrac__publication=self) + return qs.aggregate(Sum("amount"))["amount__sum"] + + @property + def uncompensated_expenditures(self): + """Compensated part of expenditures for this Publication.""" + return self.expenditure - self.compensated_expenditures + @property def citation(self): if self.cf_citation: diff --git a/scipost_django/journals/templates/journals/publication_detail.html b/scipost_django/journals/templates/journals/publication_detail.html index 29e584b1c17884e04f17b02bec7c8547ed8896fe..4ccc19f61a18fb24e7393010665173c624790df9 100644 --- a/scipost_django/journals/templates/journals/publication_detail.html +++ b/scipost_django/journals/templates/journals/publication_detail.html @@ -170,6 +170,70 @@ </div> {% endif %} + {% if 'edadmin' in user_roles %} + <h3 class="mt-4"> + PubFracs, Compensations and Arrears + </h3> + <table class="table mt-2"> + <thead class="table-light"> + <tr> + <th>Organization</th> + <th>PubFrac</th> + <th>Value</th> + <th>Compensation</th> + <th>Arrears</th> + </tr> + </thead> + <tbody> + {% for pubfrac in publication.pubfracs.all %} + <tr> + <td>{{ pubfrac.organization }}</td> + <td>{{ pubfrac.fraction }}</td> + <td>€{{ pubfrac.cf_value }}</td> + <td> + <ul> + {% for pc in pubfrac.pubfrac_compensations.all %} + <li> + €{{ pc.amount }}  + {% if pc.subsidy.organization != pubfrac.organization %} + from ally + <a href="{% url 'organizations:organization_detail' pk=pc.subsidy.organization.id %}">{{ pc.subsidy.organization }}</a> + {% endif %} + (<a href="{% url 'finances:subsidy_details' pk=pc.subsidy.id %}" target="_blank"> + see Subsidy details + </a>) + </li> + {% empty %} + <li>No compensation</li> + {% endfor %} + </ul> + </td> + {% with pubfrac.arrears as arrears %} + <td class="{% if arrears == 0 %}bg-success{% elif arrears < pubfrac.cf_value %}bg-warning{% else %}bg-danger{% endif %} bg-opacity-25"> + €{{ pubfrac.arrears }} + </td> + {% endwith %} + </tr> + {% endfor %} + <tr> + <th>Totals</th> + <td>1</td> + <td>{{ publication.expenditure }}</td> + {% if publication.uncompensated_expenditures == 0 %} + <td class="bg-success bg-opacity-25">{{ publication.compensated_expenditures }}</td> + <td class="bg-success bg-opacity-25">{{ publication.uncompensated_expenditures }}</td> + {% elif publication.uncompensated_expenditures < publication.expenditure %} + <td class="bg-warning bg-opacity-25">{{ publication.compensated_expenditures }}</td> + <td class="bg-warning bg-opacity-25">{{ publication.uncompensated_expenditures }}</td> + {% else %} + <td class="bg-danger bg-opacity-25">{{ publication.compensated_expenditures }}</td> + <td class="bg-danger bg-opacity-25">{{ publication.uncompensated_expenditures }}</td> + {% endif %} + </tr> + </tbody> + </table> + {% endif %} + {% if publication.status == 'draft' and perms.scipost.can_draft_publication %} <hr class="divider"> <div class="row"> diff --git a/scipost_django/templates/bi/arrow-left-right.html b/scipost_django/templates/bi/arrow-left-right.html new file mode 100644 index 0000000000000000000000000000000000000000..8bbc4ac721d19dbfae35059bf9c652108b1a0913 --- /dev/null +++ b/scipost_django/templates/bi/arrow-left-right.html @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left-right" viewBox="0 0 16 16"> + <path fill-rule="evenodd" d="M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5m14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5"/> +</svg>