diff --git a/scipost_django/api/urls.py b/scipost_django/api/urls.py index 96f31b84cb154a7ceb73b2c7ce035842f243b6bd..98c46e8b50377c9fe8859cd285d74933d7ee16c7 100644 --- a/scipost_django/api/urls.py +++ b/scipost_django/api/urls.py @@ -14,7 +14,7 @@ from colleges.api.viewsets import FellowshipPublicAPIViewSet from journals.api.viewsets import ( PublicationPublicAPIViewSet, PublicationPublicSearchAPIViewSet, - PubFractionPublicAPIViewSet, + PubFracPublicAPIViewSet, ) # submissions @@ -61,7 +61,7 @@ router.register("colleges/fellowships", FellowshipPublicAPIViewSet) # journals router.register("publications", PublicationPublicAPIViewSet) -router.register("pubfractions", PubFractionPublicAPIViewSet) +router.register("pubfracs", PubFracPublicAPIViewSet) # submissions router.register("submissions", SubmissionPublicAPIViewSet) diff --git a/scipost_django/apimail/migrations/0033_alter_storedmessage_read_by.py b/scipost_django/apimail/migrations/0033_alter_storedmessage_read_by.py new file mode 100644 index 0000000000000000000000000000000000000000..7e615f09fbcece8f513e9f40f918a74bf81a1acb --- /dev/null +++ b/scipost_django/apimail/migrations/0033_alter_storedmessage_read_by.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.10 on 2024-03-14 17:59 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("apimail", "0032_auto_20210716_0926"), + ] + + operations = [ + migrations.AlterField( + model_name="storedmessage", + name="read_by", + field=models.ManyToManyField( + blank=True, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/scipost_django/finances/admin.py b/scipost_django/finances/admin.py index f59c8229e56e39afe98105079face53427bc6254..d374c58441ded84b8cd08a9c3a7891f1fd37bbf5 100644 --- a/scipost_django/finances/admin.py +++ b/scipost_django/finances/admin.py @@ -8,6 +8,8 @@ from .models import ( Subsidy, SubsidyPayment, SubsidyAttachment, + PubFrac, + PubFracCompensation, WorkLog, PeriodicReportType, PeriodicReport, @@ -61,6 +63,53 @@ class SubsidyAttachmentAdmin(admin.ModelAdmin): ] +@admin.register(PubFrac) +class PubFracAdmin(admin.ModelAdmin): + list_display = [ + "organization", + "doi_label_display", + "fraction", + "cf_value", + ] + autocomplete_fields = [ + "organization", + "publication", + ] + search_fields = [ + "publication__doi_label", + "organization__name", + "organization__name_original", + "organization__acronym", + ] + + @admin.display(description='doi label') + def doi_label_display(self, obj): + return (obj.publication.doi_label) + + +@admin.register(PubFracCompensation) +class PubFracCompensationAdmin(admin.ModelAdmin): + list_display = [ + "subsidy", + "doi_label_display", + "amount", + ] + autocomplete_fields = [ + "subsidy", + "pubfrac", + ] + search_fields = [ + "subsidy", + "pubfrac__organization__name", + "pubfrac__organization__name_original", + "pubfrac__organization__acronym", + "pubfrac__publication__doi_label", + ] + + @admin.display(description='doi label') + def doi_label_display(self, obj): + return (obj.pubfrac.publication.doi_label) + @admin.register(WorkLog) class WorkLogAdmin(admin.ModelAdmin): @@ -68,7 +117,6 @@ class WorkLogAdmin(admin.ModelAdmin): - admin.site.register(PeriodicReportType) admin.site.register(PeriodicReport) diff --git a/scipost_django/finances/migrations/0032_pubfrac.py b/scipost_django/finances/migrations/0032_pubfrac.py new file mode 100644 index 0000000000000000000000000000000000000000..2d915d0a11a5da308ebca47979526e66246218fd --- /dev/null +++ b/scipost_django/finances/migrations/0032_pubfrac.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.18 on 2024-03-14 15:58 + +from decimal import Decimal +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('journals', '0128_populate_submission_object_types'), + ('organizations', '0021_enable_unaccent'), + ('finances', '0031_alter_subsidyattachment_attachment'), + ] + + operations = [ + migrations.CreateModel( + name='PubFrac', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('fraction', models.DecimalField(decimal_places=3, default=Decimal('0.000'), max_digits=4)), + ('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pubfracs', to='organizations.organization')), + ('publication', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pubfracs', to='journals.publication')), + ], + options={ + 'unique_together': {('organization', 'publication')}, + }, + ), + ] diff --git a/scipost_django/finances/migrations/0033_populate_pubfracs.py b/scipost_django/finances/migrations/0033_populate_pubfracs.py new file mode 100644 index 0000000000000000000000000000000000000000..86aad1297c0f9b58a5658ce6e78dd6dbf34d99c2 --- /dev/null +++ b/scipost_django/finances/migrations/0033_populate_pubfracs.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.18 on 2024-03-14 15:59 + +from django.db import migrations + + +def populate_pubfracs(apps, schema_editor): + OrgPubFraction = apps.get_model("journals.OrgPubFraction") + PubFrac = apps.get_model("finances.PubFrac") + + # Copy all data from OrgPubFraction to the new PubFrac + for opf in OrgPubFraction.objects.all(): + pubfrac = PubFrac( + organization=opf.organization, + publication=opf.publication, + fraction=opf.fraction) + pubfrac.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('finances', '0032_pubfrac'), + ] + + operations = [ + migrations.RunPython( + populate_pubfracs, + reverse_code=migrations.RunPython.noop, + ) + ] diff --git a/scipost_django/finances/migrations/0034_alter_worklog_content_type_alter_worklog_user.py b/scipost_django/finances/migrations/0034_alter_worklog_content_type_alter_worklog_user.py new file mode 100644 index 0000000000000000000000000000000000000000..2e983d8517b5f4d8fc9e5da053fd1a3fd2dbc6fb --- /dev/null +++ b/scipost_django/finances/migrations/0034_alter_worklog_content_type_alter_worklog_user.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.10 on 2024-03-14 17:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("contenttypes", "0002_remove_content_type_name"), + ("finances", "0033_populate_pubfracs"), + ] + + operations = [ + migrations.AlterField( + model_name="worklog", + name="content_type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + migrations.AlterField( + model_name="worklog", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/scipost_django/finances/migrations/0035_pubfrac_cf_value.py b/scipost_django/finances/migrations/0035_pubfrac_cf_value.py new file mode 100644 index 0000000000000000000000000000000000000000..364c59f38c2f1e1dd346cdf23c314e730fd1d03e --- /dev/null +++ b/scipost_django/finances/migrations/0035_pubfrac_cf_value.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.10 on 2024-03-15 04:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("finances", "0034_alter_worklog_content_type_alter_worklog_user"), + ] + + operations = [ + migrations.AddField( + model_name="pubfrac", + name="cf_value", + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/scipost_django/finances/migrations/0036_populate_pubfrac_cf_value.py b/scipost_django/finances/migrations/0036_populate_pubfrac_cf_value.py new file mode 100644 index 0000000000000000000000000000000000000000..ffbe52be7f3da613b9ce2fe28f870831702b3536 --- /dev/null +++ b/scipost_django/finances/migrations/0036_populate_pubfrac_cf_value.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.10 on 2024-03-15 04:11 + +from django.db import migrations + + +def populate_pubfrac_cf_value(apps, schema_editor): + PubFrac = apps.get_model("finances.PubFrac") + Journal = apps.get_model("journals.Journal") + + # Some contortions required since model methods not available in migrations + for pf in PubFrac.objects.all(): + if pf.publication.in_journal: + journal = Journal.objects.get(pk=pf.publication.in_journal.id) + elif pf.publication.in_issue.in_journal: + journal = Journal.objects.get(pk=pf.publication.in_issue.in_journal.id) + else: + journal = Journal.objects.get(pk=pf.publication.in_issue.in_volume.in_journal.id) + cost_per_publication = journal.cost_info[pf.publication.publication_date.year] \ + if pf.publication.publication_date.year in journal.cost_info else \ + journal.cost_info["default"] + pf.cf_value = int(pf.fraction * cost_per_publication) + pf.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("finances", "0035_pubfrac_cf_value"), + ] + + operations = [ + migrations.RunPython( + populate_pubfrac_cf_value, + reverse_code=migrations.RunPython.noop, + ) + ] diff --git a/scipost_django/finances/migrations/0037_pubfraccompensation_and_more.py b/scipost_django/finances/migrations/0037_pubfraccompensation_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..246ccf37ef3e7bb06c34683283d56eb39d127056 --- /dev/null +++ b/scipost_django/finances/migrations/0037_pubfraccompensation_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.10 on 2024-03-15 05:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("finances", "0036_populate_pubfrac_cf_value"), + ] + + operations = [ + migrations.CreateModel( + name="PubFracCompensation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("amount", models.PositiveIntegerField()), + ( + "pubfrac", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pubfracs", + to="finances.pubfrac", + ), + ), + ( + "subsidy", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pubfrac_compensations", + to="finances.subsidy", + ), + ), + ], + options={ + "verbose_name_plural": "PubFrac Compensations", + }, + ), + migrations.AddConstraint( + model_name="pubfraccompensation", + constraint=models.UniqueConstraint( + fields=("subsidy", "pubfrac"), name="unique_subsidy_pubfrac" + ), + ), + ] diff --git a/scipost_django/finances/migrations/0038_alter_pubfrac_options.py b/scipost_django/finances/migrations/0038_alter_pubfrac_options.py new file mode 100644 index 0000000000000000000000000000000000000000..cf6b717c62aaf346923641a14e94dcd67c0b50a7 --- /dev/null +++ b/scipost_django/finances/migrations/0038_alter_pubfrac_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.10 on 2024-03-15 09:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("finances", "0037_pubfraccompensation_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="pubfrac", + options={"verbose_name": "PubFrac", "verbose_name_plural": "PubFracs"}, + ), + ] diff --git a/scipost_django/finances/models.py b/scipost_django/finances/models.py deleted file mode 100644 index b9fcd140da5a53fb90707f47c851c2aecfba6b55..0000000000000000000000000000000000000000 --- a/scipost_django/finances/models.py +++ /dev/null @@ -1,411 +0,0 @@ -__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" -__license__ = "AGPL v3" - - -import datetime -import os -from re import I -from typing import TYPE_CHECKING - -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.contrib.contenttypes.fields import GenericForeignKey -from django.db import models -from django.db.models import Sum -from django.db.models.signals import pre_save -from django.dispatch import receiver -from django.urls import reverse -from django.utils import timezone -from django.utils.html import format_html - -from .constants import SUBSIDY_TYPES, SUBSIDY_TYPE_SPONSORSHIPAGREEMENT, SUBSIDY_STATUS -from .managers import SubsidyQuerySet, SubsidyPaymentQuerySet, SubsidyAttachmentQuerySet -from .utils import id_to_slug - -from scipost.storage import SecureFileStorage - -if TYPE_CHECKING: - from organizations.models import Organization - - -class Subsidy(models.Model): - """ - A subsidy given to SciPost by an Organization. - Any fund given to SciPost, in any form, must be associated - to a corresponding Subsidy instance. - - This can for example be: - - * a Sponsorship agreement - * an incidental grant - * a development grant for a specific purpose - * a Collaboration Agreement - * 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 - after which the object of the Subsidy is officially terminated. - """ - - organization = models.ForeignKey["Organization"]( - "organizations.Organization", on_delete=models.CASCADE - ) - subsidy_type = models.CharField(max_length=256, choices=SUBSIDY_TYPES) - description = models.TextField() - amount = models.PositiveIntegerField(help_text="in € (rounded)") - amount_publicly_shown = models.BooleanField(default=True) - status = models.CharField(max_length=32, choices=SUBSIDY_STATUS) - paid_on = models.DateField(blank=True, null=True) - date_from = models.DateField() - date_until = models.DateField(blank=True, null=True) - renewable = models.BooleanField(null=True) - renewal_of = models.ManyToManyField( - "self", related_name="renewed_by", symmetrical=False, blank=True - ) - - objects = SubsidyQuerySet.as_manager() - - class Meta: - verbose_name_plural = "subsidies" - ordering = ["-date_from"] - - def __str__(self): - if self.amount_publicly_shown: - return format_html( - "{}: €{} from {}, for {}", - self.date_from, - self.amount, - self.organization, - self.description, - ) - return format_html( - "{}: from {}, for {}", self.date_from, self.organization, self.description - ) - - def get_absolute_url(self): - return reverse("finances:subsidy_details", args=(self.id,)) - - def value_in_year(self, year): - """ - Normalize the value of the subsidy per year. - """ - if self.date_until is None: - if self.date_from.year == year: - return self.amount - return 0 - if self.date_from.year <= year and self.date_until.year >= year: - # keep it simple: for all years covered, spread evenly - nr_years_covered = self.date_until.year - self.date_from.year + 1 - return int(self.amount / nr_years_covered) - return 0 - - @property - def renewal_action_date(self): - if self.date_until and self.subsidy_type == SUBSIDY_TYPE_SPONSORSHIPAGREEMENT: - return self.date_until - datetime.timedelta(days=122) - return "-" - - @property - def renewal_action_date_color_class(self): - if self.date_until and self.renewable: - if self.renewed_by.exists(): - return "transparent" - today = datetime.date.today() - if self.date_until < today + datetime.timedelta(days=122): - return "danger" - elif self.date_until < today + datetime.timedelta(days=153): - return "warning" - return "success" - return "transparent" - - @property - def date_until_color_class(self): - if self.date_until and self.renewable: - if self.renewed_by.exists(): - return "transparent" - today = datetime.date.today() - if self.date_until < today: - return "warning" - else: - return "success" - return "transparent" - - @property - def payments_all_scheduled(self): - """ - Verify that there exist SubsidyPayment objects covering full amount. - """ - return self.amount == self.payments.aggregate(Sum("amount"))["amount__sum"] - - -class SubsidyPayment(models.Model): - subsidy = models.ForeignKey( - "finances.Subsidy", - related_name="payments", - on_delete=models.CASCADE, - ) - reference = models.CharField(max_length=64, unique=True) - amount = models.PositiveIntegerField(help_text="in €") - date_scheduled = models.DateField() - invoice = models.OneToOneField( - "finances.SubsidyAttachment", - on_delete=models.SET_NULL, - blank=True, - null=True, - related_name="invoice_for", - ) - proof_of_payment = models.OneToOneField( - "finances.SubsidyAttachment", - on_delete=models.SET_NULL, - blank=True, - null=True, - related_name="proof_of_payment_for", - ) - - objects = SubsidyPaymentQuerySet.as_manager() - - def __str__(self): - return f"payment {self.reference} for {self.subsidy}" - - @property - def status(self): - if self.paid: - return "paid" - if self.invoiced: - return "invoiced" - return "scheduled" - - @property - def invoiced(self): - return self.invoice is not None - - @property - def invoice_date(self): - return self.invoice.date if self.invoice else None - - @property - def paid(self): - return self.proof_of_payment is not None - - @property - def payment_date(self): - return self.proof_of_payment.date if self.proof_of_payment else None - - -def subsidy_attachment_path(instance: "SubsidyAttachment", filename: str) -> str: - """ - Save the uploaded SubsidyAttachments to country-specific folders. - """ - if instance.subsidy is None: - return "uploads/finances/subsidies/orphaned/%s" % filename - - return "uploads/finances/subsidies/{0}/{1}/{2}".format( - instance.subsidy.date_from.strftime("%Y"), - instance.subsidy.organization.country, - filename, - ) - - -class SubsidyAttachment(models.Model): - """ - A document related to a Subsidy. - """ - - KIND_AGREEMENT = "agreement" - KIND_INVOICE = "invoice" - KIND_PROOF_OF_PAYMENT = "proofofpayment" - KIND_OTHER = "other" - KIND_CHOICES = ( - (KIND_AGREEMENT, "Agreement"), - (KIND_INVOICE, "Invoice"), - (KIND_PROOF_OF_PAYMENT, "Proof of payment"), - (KIND_OTHER, "Other"), - ) - - VISIBILITY_PUBLIC = "public" - VISIBILITY_INTERNAL = "internal" - VISIBILITY_FINADMINONLY = "finadminonly" - VISIBILITY_CHOICES = ( - (VISIBILITY_PUBLIC, "Publicly visible"), - (VISIBILITY_INTERNAL, "Internal (admin, Org Contacts)"), - (VISIBILITY_FINADMINONLY, "SciPost FinAdmin only"), - ) - - subsidy = models.ForeignKey["Subsidy"]( - "finances.Subsidy", - related_name="attachments", - null=True, - blank=True, - on_delete=models.CASCADE, - ) - - attachment = models.FileField( - max_length=256, - upload_to=subsidy_attachment_path, - storage=SecureFileStorage(), - ) - - git_url = models.URLField( - blank=True, help_text="URL to the file's location in GitLab" - ) - - kind = models.CharField( - max_length=32, - choices=KIND_CHOICES, - default=KIND_AGREEMENT, - ) - - date = models.DateField(blank=True, null=True) - - description = models.TextField(blank=True) - - visibility = models.CharField( - max_length=32, - choices=VISIBILITY_CHOICES, - default=VISIBILITY_FINADMINONLY, - ) - - objects = SubsidyAttachmentQuerySet.as_manager() - - def __str__(self): - return "%s, attachment to %s" % (self.attachment.name, self.subsidy) - - def get_absolute_url(self): - if self.subsidy: - return reverse( - "finances:subsidy_attachment", kwargs={"attachment_id": self.id} - ) - - @property - def filename(self): - return os.path.basename(self.attachment.name) - - @property - def publicly_visible(self): - return self.visibility == self.VISIBILITY_PUBLIC - - def visible_to_user(self, current_user): - if self.publicly_visible or current_user.has_perm( - "scipost.can_manage_subsidies" - ): - return True - if self.subsidy.organization.contactrole_set.filter( - contact__user=current_user - ).exists(): - return True - return False - - -# Delete attachment files with same name if they exist, allowing replacement without name change -@receiver(pre_save, sender=SubsidyAttachment) -def delete_old_attachment_file(sender, instance: SubsidyAttachment, **kwargs): - """ - Replace existing file on update if a new one is provided. - Move file to the new location if the subsidy changes. - """ - if instance.pk and instance.attachment: - old = SubsidyAttachment.objects.get(pk=instance.pk) - if old is None or old.attachment is None: - return - - # Delete old file if it is replaced - if old.attachment != instance.attachment: - old.attachment.delete(save=False) - - # Move file to new location if subsidy changes - if old.subsidy != instance.subsidy: - old_relative_path = old.attachment.name - new_relative_path = subsidy_attachment_path(instance, instance.filename) - - instance.attachment.storage.save(new_relative_path, instance.attachment) - instance.attachment.storage.delete(old_relative_path) - instance.attachment.name = new_relative_path - - -@receiver(models.signals.post_delete, sender=SubsidyAttachment) -def auto_delete_file_on_delete(sender, instance, **kwargs): - """ - Deletes file from filesystem when its object is deleted. - """ - if instance.attachment: - instance.attachment.delete(save=False) - - -########################### -# Work hours registration # -########################### - - -class WorkLog(models.Model): - HOURLY_RATE = 22.0 - - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - comments = models.TextField(blank=True) - log_type = models.CharField(max_length=128, blank=True) - duration = models.DurationField(blank=True, null=True) - work_date = models.DateField(default=timezone.now) - created = models.DateTimeField(auto_now_add=True) - - content_type = models.ForeignKey( - ContentType, blank=True, null=True, on_delete=models.CASCADE - ) - object_id = models.PositiveIntegerField(blank=True, null=True) - content = GenericForeignKey() - - class Meta: - default_related_name = "work_logs" - ordering = ["-work_date", "created"] - - def __str__(self): - return "Log of {0} {1} on {2}".format( - self.user.first_name, self.user.last_name, self.work_date - ) - - @property - def slug(self): - return id_to_slug(self.id) - - -#################### -# Periodic Reports # -#################### - - -class PeriodicReportType(models.Model): - name = models.CharField(max_length=256) - description = models.TextField() - - def __str__(self): - return self.name - - -def periodic_report_upload_path(instance, filename): - return f"uploads/finances/periodic_reports/{instance.for_year}/{filename}" - - -class PeriodicReport(models.Model): - """ - Any form of report (annual, financial, administrative etc). - """ - - _type = models.ForeignKey( - "finances.PeriodicReportType", - on_delete=models.CASCADE, - ) - _file = models.FileField( - upload_to=periodic_report_upload_path, - max_length=256, - ) - created_on = models.DateTimeField(default=timezone.now) - for_year = models.PositiveSmallIntegerField() - - class META: - ordering = ["-for_year", "_type__name"] - - def __str__(self): - return f"{self.for_year} {self._type}" - - def get_absolute_url(self): - if self._file: - return reverse("finances:periodicreport_file", kwargs={"pk": self.id}) diff --git a/scipost_django/finances/models/__init__.py b/scipost_django/finances/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a370b148d4ff1a0efca1f3a54905585b3613f4fe --- /dev/null +++ b/scipost_django/finances/models/__init__.py @@ -0,0 +1,24 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from .periodic_report import ( + PeriodicReportType, + periodic_report_upload_path, + PeriodicReport, +) + +from .pubfrac import PubFrac + +from .pubfrac_compensation import PubFracCompensation + +from .subsidy import Subsidy + +from .subsidy_payment import SubsidyPayment + +from .subsidy_attachment import ( + subsidy_attachment_path, + SubsidyAttachment, +) + +from .work_log import WorkLog diff --git a/scipost_django/finances/models/periodic_report.py b/scipost_django/finances/models/periodic_report.py new file mode 100644 index 0000000000000000000000000000000000000000..6ff289b8ce5363534503ff0e4edd6e1c45c36a8d --- /dev/null +++ b/scipost_django/finances/models/periodic_report.py @@ -0,0 +1,46 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models +from django.urls import reverse +from django.utils import timezone + + +class PeriodicReportType(models.Model): + name = models.CharField(max_length=256) + description = models.TextField() + + def __str__(self): + return self.name + + +def periodic_report_upload_path(instance, filename): + return f"uploads/finances/periodic_reports/{instance.for_year}/{filename}" + + +class PeriodicReport(models.Model): + """ + Any form of report (annual, financial, administrative etc). + """ + + _type = models.ForeignKey( + "finances.PeriodicReportType", + on_delete=models.CASCADE, + ) + _file = models.FileField( + upload_to=periodic_report_upload_path, + max_length=256, + ) + created_on = models.DateTimeField(default=timezone.now) + for_year = models.PositiveSmallIntegerField() + + class META: + ordering = ["-for_year", "_type__name"] + + def __str__(self): + return f"{self.for_year} {self._type}" + + def get_absolute_url(self): + if self._file: + return reverse("finances:periodicreport_file", kwargs={"pk": self.id}) diff --git a/scipost_django/finances/models/pubfrac.py b/scipost_django/finances/models/pubfrac.py new file mode 100644 index 0000000000000000000000000000000000000000..cd483acd7514f74b46235b07eb09833a1e2ace9f --- /dev/null +++ b/scipost_django/finances/models/pubfrac.py @@ -0,0 +1,57 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from decimal import Decimal + +from django.db import models +from django.db.models.signals import pre_save +from django.dispatch import receiver + + +class PubFrac(models.Model): + """ + A fraction of a given Publication related to an Organization, for expenditure redistribution. + + Fractions for a given Publication should sum up to one. + + This data is used to compile publicly-displayed information on Organizations + as well as to set suggested contributions from sponsoring Organizations. + """ + + organization = models.ForeignKey( + "organizations.Organization", + on_delete=models.CASCADE, + related_name="pubfracs", + blank=True, + null=True, + ) + publication = models.ForeignKey( + "journals.Publication", on_delete=models.CASCADE, related_name="pubfracs" + ) + fraction = models.DecimalField( + max_digits=4, decimal_places=3, default=Decimal("0.000") + ) + + # Calculated field + cf_value = models.PositiveIntegerField(blank=True, null=True) + + class Meta: + unique_together = (("organization", "publication"),) + verbose_name = "PubFrac" + verbose_name_plural = "PubFracs" + + def __str__(self): + return (f"{str(self.fraction)} (€{self.cf_value}) " + f"for {self.publication.doi_label} from {self.organization}") + + +@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.publication.publication_date.year + ) + ) diff --git a/scipost_django/finances/models/pubfrac_compensation.py b/scipost_django/finances/models/pubfrac_compensation.py new file mode 100644 index 0000000000000000000000000000000000000000..41625f2d28d1683f08c63e3d4bac9dae2431562c --- /dev/null +++ b/scipost_django/finances/models/pubfrac_compensation.py @@ -0,0 +1,33 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models + + +class PubFracCompensation(models.Model): + """ + An amount from a Subsidy which ascribed to a PubFrac as compensation. + """ + + subsidy = models.ForeignKey( + "finances.Subsidy", + related_name="pubfrac_compensations", + on_delete=models.CASCADE, + ) + + pubfrac = models.ForeignKey( + "finances.PubFrac", + related_name="pubfracs", + on_delete=models.CASCADE, + ) + + amount = models.PositiveIntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["subsidy", "pubfrac"], name="unique_subsidy_pubfrac" + ), + ] + verbose_name_plural = "PubFrac Compensations" diff --git a/scipost_django/finances/models/subsidy.py b/scipost_django/finances/models/subsidy.py new file mode 100644 index 0000000000000000000000000000000000000000..27e281dc61bfc6b610590990ebdaee6d0a97b325 --- /dev/null +++ b/scipost_django/finances/models/subsidy.py @@ -0,0 +1,118 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +import datetime + +from django.db import models +from django.db.models import Sum +from django.urls import reverse +from django.utils.html import format_html + +from ..constants import SUBSIDY_TYPES, SUBSIDY_TYPE_SPONSORSHIPAGREEMENT, SUBSIDY_STATUS +from ..managers import SubsidyQuerySet + + +class Subsidy(models.Model): + """ + A subsidy given to SciPost by an Organization. + Any fund given to SciPost, in any form, must be associated + to a corresponding Subsidy instance. + + This can for example be: + + * a Sponsorship agreement + * an incidental grant + * a development grant for a specific purpose + * a Collaboration Agreement + * 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 + after which the object of the Subsidy is officially terminated. + """ + + organization = models.ForeignKey["Organization"]( + "organizations.Organization", on_delete=models.CASCADE + ) + subsidy_type = models.CharField(max_length=256, choices=SUBSIDY_TYPES) + description = models.TextField() + amount = models.PositiveIntegerField(help_text="in € (rounded)") + amount_publicly_shown = models.BooleanField(default=True) + status = models.CharField(max_length=32, choices=SUBSIDY_STATUS) + paid_on = models.DateField(blank=True, null=True) + date_from = models.DateField() + date_until = models.DateField(blank=True, null=True) + renewable = models.BooleanField(null=True) + renewal_of = models.ManyToManyField( + "self", related_name="renewed_by", symmetrical=False, blank=True + ) + + objects = SubsidyQuerySet.as_manager() + + class Meta: + verbose_name_plural = "subsidies" + ordering = ["-date_from"] + + 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}") + + def get_absolute_url(self): + return reverse("finances:subsidy_details", args=(self.id,)) + + def value_in_year(self, year): + """ + Normalize the value of the subsidy per year. + """ + if self.date_until is None: + if self.date_from.year == year: + return self.amount + return 0 + if self.date_from.year <= year and self.date_until.year >= year: + # keep it simple: for all years covered, spread evenly + nr_years_covered = self.date_until.year - self.date_from.year + 1 + return int(self.amount / nr_years_covered) + return 0 + + @property + def renewal_action_date(self): + if self.date_until and self.subsidy_type == SUBSIDY_TYPE_SPONSORSHIPAGREEMENT: + return self.date_until - datetime.timedelta(days=122) + return "-" + + @property + def renewal_action_date_color_class(self): + if self.date_until and self.renewable: + if self.renewed_by.exists(): + return "transparent" + today = datetime.date.today() + if self.date_until < today + datetime.timedelta(days=122): + return "danger" + elif self.date_until < today + datetime.timedelta(days=153): + return "warning" + return "success" + return "transparent" + + @property + def date_until_color_class(self): + if self.date_until and self.renewable: + if self.renewed_by.exists(): + return "transparent" + today = datetime.date.today() + if self.date_until < today: + return "warning" + else: + return "success" + return "transparent" + + @property + def payments_all_scheduled(self): + """ + Verify that there exist SubsidyPayment objects covering full amount. + """ + return self.amount == self.payments.aggregate(Sum("amount"))["amount__sum"] diff --git a/scipost_django/finances/models/subsidy_attachment.py b/scipost_django/finances/models/subsidy_attachment.py new file mode 100644 index 0000000000000000000000000000000000000000..66d5882b169c32c260b8e614c099c17d3f85140c --- /dev/null +++ b/scipost_django/finances/models/subsidy_attachment.py @@ -0,0 +1,153 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +import os + +from django.db import models +from django.db.models.signals import pre_save +from django.dispatch import receiver +from django.urls import reverse + +from ..managers import SubsidyAttachmentQuerySet + +from scipost.storage import SecureFileStorage + + +def subsidy_attachment_path(instance: "SubsidyAttachment", filename: str) -> str: + """ + Save the uploaded SubsidyAttachments to country-specific folders. + """ + if instance.subsidy is None: + return "uploads/finances/subsidies/orphaned/%s" % filename + + return "uploads/finances/subsidies/{0}/{1}/{2}".format( + instance.subsidy.date_from.strftime("%Y"), + instance.subsidy.organization.country, + filename, + ) + + +class SubsidyAttachment(models.Model): + """ + A document related to a Subsidy. + """ + + KIND_AGREEMENT = "agreement" + KIND_INVOICE = "invoice" + KIND_PROOF_OF_PAYMENT = "proofofpayment" + KIND_OTHER = "other" + KIND_CHOICES = ( + (KIND_AGREEMENT, "Agreement"), + (KIND_INVOICE, "Invoice"), + (KIND_PROOF_OF_PAYMENT, "Proof of payment"), + (KIND_OTHER, "Other"), + ) + + VISIBILITY_PUBLIC = "public" + VISIBILITY_INTERNAL = "internal" + VISIBILITY_FINADMINONLY = "finadminonly" + VISIBILITY_CHOICES = ( + (VISIBILITY_PUBLIC, "Publicly visible"), + (VISIBILITY_INTERNAL, "Internal (admin, Org Contacts)"), + (VISIBILITY_FINADMINONLY, "SciPost FinAdmin only"), + ) + + subsidy = models.ForeignKey["Subsidy"]( + "finances.Subsidy", + related_name="attachments", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + + attachment = models.FileField( + max_length=256, + upload_to=subsidy_attachment_path, + storage=SecureFileStorage(), + ) + + git_url = models.URLField( + blank=True, help_text="URL to the file's location in GitLab" + ) + + kind = models.CharField( + max_length=32, + choices=KIND_CHOICES, + default=KIND_AGREEMENT, + ) + + date = models.DateField(blank=True, null=True) + + description = models.TextField(blank=True) + + visibility = models.CharField( + max_length=32, + choices=VISIBILITY_CHOICES, + default=VISIBILITY_FINADMINONLY, + ) + + objects = SubsidyAttachmentQuerySet.as_manager() + + def __str__(self): + return "%s, attachment to %s" % (self.attachment.name, self.subsidy) + + def get_absolute_url(self): + if self.subsidy: + return reverse( + "finances:subsidy_attachment", kwargs={"attachment_id": self.id} + ) + + @property + def filename(self): + return os.path.basename(self.attachment.name) + + @property + def publicly_visible(self): + return self.visibility == self.VISIBILITY_PUBLIC + + def visible_to_user(self, current_user): + if self.publicly_visible or current_user.has_perm( + "scipost.can_manage_subsidies" + ): + return True + if self.subsidy.organization.contactrole_set.filter( + contact__user=current_user + ).exists(): + return True + return False + + +# Delete attachment files with same name if they exist, allowing replacement without name change +@receiver(pre_save, sender=SubsidyAttachment) +def delete_old_attachment_file(sender, instance: SubsidyAttachment, **kwargs): + """ + Replace existing file on update if a new one is provided. + Move file to the new location if the subsidy changes. + """ + if instance.pk and instance.attachment: + old = SubsidyAttachment.objects.get(pk=instance.pk) + if old is None or old.attachment is None: + return + + # Delete old file if it is replaced + if old.attachment != instance.attachment: + old.attachment.delete(save=False) + + # Move file to new location if subsidy changes + if old.subsidy != instance.subsidy: + old_relative_path = old.attachment.name + new_relative_path = subsidy_attachment_path(instance, instance.filename) + + instance.attachment.storage.save(new_relative_path, instance.attachment) + instance.attachment.storage.delete(old_relative_path) + instance.attachment.name = new_relative_path + + +@receiver(models.signals.post_delete, sender=SubsidyAttachment) +def auto_delete_file_on_delete(sender, instance, **kwargs): + """ + Deletes file from filesystem when its object is deleted. + """ + if instance.attachment: + instance.attachment.delete(save=False) diff --git a/scipost_django/finances/models/subsidy_payment.py b/scipost_django/finances/models/subsidy_payment.py new file mode 100644 index 0000000000000000000000000000000000000000..65543c2950a9d85d2e426d8a9608630df4042c93 --- /dev/null +++ b/scipost_django/finances/models/subsidy_payment.py @@ -0,0 +1,61 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models + +from ..managers import SubsidyPaymentQuerySet + + +class SubsidyPayment(models.Model): + subsidy = models.ForeignKey( + "finances.Subsidy", + related_name="payments", + on_delete=models.CASCADE, + ) + reference = models.CharField(max_length=64, unique=True) + amount = models.PositiveIntegerField(help_text="in €") + date_scheduled = models.DateField() + invoice = models.OneToOneField( + "finances.SubsidyAttachment", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="invoice_for", + ) + proof_of_payment = models.OneToOneField( + "finances.SubsidyAttachment", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="proof_of_payment_for", + ) + + objects = SubsidyPaymentQuerySet.as_manager() + + def __str__(self): + return f"payment {self.reference} for {self.subsidy}" + + @property + def status(self): + if self.paid: + return "paid" + if self.invoiced: + return "invoiced" + return "scheduled" + + @property + def invoiced(self): + return self.invoice is not None + + @property + def invoice_date(self): + return self.invoice.date if self.invoice else None + + @property + def paid(self): + return self.proof_of_payment is not None + + @property + def payment_date(self): + return self.proof_of_payment.date if self.proof_of_payment else None diff --git a/scipost_django/finances/models/work_log.py b/scipost_django/finances/models/work_log.py new file mode 100644 index 0000000000000000000000000000000000000000..b808707ae1b06bb2b976da62c2a2891ec5dbd77d --- /dev/null +++ b/scipost_django/finances/models/work_log.py @@ -0,0 +1,41 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey +from django.db import models +from django.utils import timezone + +from ..utils import id_to_slug + + +class WorkLog(models.Model): + HOURLY_RATE = 22.0 + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + comments = models.TextField(blank=True) + log_type = models.CharField(max_length=128, blank=True) + duration = models.DurationField(blank=True, null=True) + work_date = models.DateField(default=timezone.now) + created = models.DateTimeField(auto_now_add=True) + + content_type = models.ForeignKey( + ContentType, blank=True, null=True, on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField(blank=True, null=True) + content = GenericForeignKey() + + class Meta: + default_related_name = "work_logs" + ordering = ["-work_date", "created"] + + def __str__(self): + return "Log of {0} {1} on {2}".format( + self.user.first_name, self.user.last_name, self.work_date + ) + + @property + def slug(self): + return id_to_slug(self.id) diff --git a/scipost_django/finances/templates/finances/business_model.html b/scipost_django/finances/templates/finances/business_model.html index aacc957d487d495889d451f4fcd8492fc83b5d6e..f6c8d0749e29695bdf0fe0cd65e5ab5a22042a3c 100644 --- a/scipost_django/finances/templates/finances/business_model.html +++ b/scipost_django/finances/templates/finances/business_model.html @@ -49,7 +49,7 @@ <ul> <li><a href="#finances">The Finances Pages</a></li> <li><a href="#organizations">The Organizations Pages</a></li> - <li><a href="#pubFractions">The PubFractions System</a></li> + <li><a href="#pubFracs">The PubFracs System</a></li> <li><a href="#sponsorshipLevels">Sponsorship Levels</a></li> <li><a href="#inKind">In-kind Support</a></li> </ul> @@ -91,10 +91,10 @@ <a href="{% url 'organizations:organizations' %}" target="_blank">Organizations pages</a> in the form of each Organization's NAP (Number of Associated Publications). </li> <li> - <strong>Determining PubFractions</strong><br> - After publication, authors are asked to specify PubFractions for their paper - (a publication's set of PubFractions answers the question "what was each supporting Organization's share of the support for the research leading to this publication?"; - see the <a href="#pubFractions">PubFractions system description</a> below). + <strong>Determining PubFracs</strong><br> + After publication, PubFracs are specified + (a publication's set of PubFracs answers the question "what was each supporting Organization's share of the support for the research leading to this publication?"; + see the <a href="#pubFracs">PubFracs system description</a> below). </li> <li> <strong>Determination of operational costs</strong><br> @@ -111,7 +111,7 @@ <li> <strong>Sponsorship</strong><br> Organizations can inspect all the data (in particular their NAPs and summed-up - PubFractions, together with the free-riding fraction) + PubFracs, together with the free-riding fraction) and hereby determine to which level they choose to support SciPost (through a sponsorship agreement). Sponsors can fall into different @@ -179,7 +179,7 @@ </div> </div> - <h3 class="highlight" id="pubFractions">The PubFractions System</h3> + <h3 class="highlight" id="pubFracs">The PubFracs System</h3> <div class="m-2"> <div class="row"> <div class="col-lg-6"> @@ -192,22 +192,20 @@ </p> <p> In order to resolve things more finely, we run an internal system - based on the idea of <strong>PubFractions</strong>, in which + based on the idea of <strong>PubFracs</strong>, in which each paper has one unit of support recognition to be distributed among the Organizations having supported the research detailed in that paper. This is not meant to be <em>extremely</em> accurate, but should still - somehow honestly reflect the support circumstances. Authors of a paper might - thus specify that Organizations A and B each have a $0.4$ pubfraction, - while C has $0.2$. This splitting can be made among an arbitrary number - of Organizations, as specified by the authors. The only requirement is that - any given paper's pubfractions sum up to $1$. + somehow honestly reflect the support circumstances. </p> <p> - This information is prefilled by our editorial administration - at the moment of publication based on a reasonable estimate, - which the authors are then asked to correct/complement/confirm - (see image). - These pubfractions are then automatically compiled and linked to the relevant + The weight is given by the following simple algorithm: first, the unit + is split equally among each of the authors. Then, for each author, + their share is split equally among their affiliations. + Any given paper's PubFracs sum up to $1$. + This information is filled by our editorial administration + at the moment of publication. + These PubFracs are then automatically compiled and linked to the relevant Organizations. This data is displayed on our Organization detail pages. </p> </div> @@ -260,11 +258,11 @@ <strong>Bronze</strong>: sponsorship level $\gt 0$; </li> <li> - <strong>Silver</strong>: sponsorship level $\geq$ (own PubFractions) + <strong>Silver</strong>: sponsorship level $\geq$ (own PubFracs) $\times$ average cost per publication; </li> <li> - <strong>Gold</strong>: sponsorship level $\geq$ (own PubFractions) $\times$ + <strong>Gold</strong>: sponsorship level $\geq$ (own PubFracs) $\times$ average cost per publication $/$ (1 - free-riding fraction); </li> <li> @@ -360,7 +358,7 @@ </ul> </p> <p> - Our consortial funding model with pubfractions-based recognition + Our consortial funding model with PubFracs-based recognition solves all these problems in one go. Our pooling of resources and maximally simple accounting drastically simplifies administration for everybody involved. Our transparency means that recognition is given where diff --git a/scipost_django/funders/migrations/0015_alter_grant_funder_alter_grant_recipient.py b/scipost_django/funders/migrations/0015_alter_grant_funder_alter_grant_recipient.py new file mode 100644 index 0000000000000000000000000000000000000000..32de8f795cf237788ea91bcbc5b07b209e7399b8 --- /dev/null +++ b/scipost_django/funders/migrations/0015_alter_grant_funder_alter_grant_recipient.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.10 on 2024-03-14 17:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("scipost", "0040_auto_20210310_2026"), + ("funders", "0014_enable_unaccent"), + ] + + operations = [ + migrations.AlterField( + model_name="grant", + name="funder", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="funders.funder" + ), + ), + migrations.AlterField( + model_name="grant", + name="recipient", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="scipost.contributor", + ), + ), + ] diff --git a/scipost_django/invitations/migrations/0016_alter_citationnotification_invitation.py b/scipost_django/invitations/migrations/0016_alter_citationnotification_invitation.py new file mode 100644 index 0000000000000000000000000000000000000000..8e076cf0b61db4cdf3fdcccdb4889e3c3df7b1dc --- /dev/null +++ b/scipost_django/invitations/migrations/0016_alter_citationnotification_invitation.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.10 on 2024-03-14 17:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("invitations", "0015_auto_20210310_2026"), + ] + + operations = [ + migrations.AlterField( + model_name="citationnotification", + name="invitation", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="invitations.registrationinvitation", + ), + ), + ] diff --git a/scipost_django/journals/admin.py b/scipost_django/journals/admin.py index f8265c6ed9614b048144bfd423762fa41cb3bad6..830637dbcdce5f99b87090f58b12a3a6a200aa3f 100644 --- a/scipost_django/journals/admin.py +++ b/scipost_django/journals/admin.py @@ -15,13 +15,13 @@ from journals.models import ( GenericDOIDeposit, Reference, PublicationAuthorsTable, - OrgPubFraction, PublicationUpdate, SubmissionTemplate, AutogeneratedFileContentTemplate, PublicationResource, ) +from finances.models import PubFrac from scipost.models import Contributor from submissions.models import Submission @@ -86,8 +86,8 @@ class PublicationResourceInline(admin.TabularInline): extra = 0 -class OrgPubFractionInline(admin.TabularInline): - model = OrgPubFraction +class PubFracInline(admin.TabularInline): + model = PubFrac list_display = ("organization", "publication", "fraction") autocomplete_fields = [ "organization", @@ -111,7 +111,7 @@ class PublicationAdmin(admin.ModelAdmin): inlines = [ AuthorsInline, ReferenceInline, - OrgPubFractionInline, + PubFracInline, PublicationResourceInline, ] autocomplete_fields = [ @@ -202,5 +202,3 @@ class PublicationUpdateAdmin(admin.ModelAdmin): autocomplete_fields = [ "publication", ] - - diff --git a/scipost_django/journals/api/filtersets/__init__.py b/scipost_django/journals/api/filtersets/__init__.py index 4076babe7d0e0c4e953c14f8e5a74d8ad163f6c4..a4d0af83f6b527691fee1f5d5fc8e1fef56c8df5 100644 --- a/scipost_django/journals/api/filtersets/__init__.py +++ b/scipost_django/journals/api/filtersets/__init__.py @@ -7,4 +7,4 @@ from .publication import ( PublicationPublicSearchAPIFilterSet, ) -from .pubfraction import PubFractionPublicAPIFilterSet +from .pubfrac import PubFracPublicAPIFilterSet diff --git a/scipost_django/journals/api/filtersets/pubfraction.py b/scipost_django/journals/api/filtersets/pubfrac.py similarity index 83% rename from scipost_django/journals/api/filtersets/pubfraction.py rename to scipost_django/journals/api/filtersets/pubfrac.py index a8543b79e0b44d3949de88cbdbff8e08d0b77194..220934998541b01d8a24a585e3cb092c4da2e69a 100644 --- a/scipost_django/journals/api/filtersets/pubfraction.py +++ b/scipost_django/journals/api/filtersets/pubfrac.py @@ -4,12 +4,12 @@ __license__ = "AGPL v3" from django_filters import rest_framework as df_filters -from journals.models import OrgPubFraction +from finances.models import PubFrac -class PubFractionPublicAPIFilterSet(df_filters.FilterSet): +class PubFracPublicAPIFilterSet(df_filters.FilterSet): class Meta: - model = OrgPubFraction + model = PubFrac fields = { "organization__name": ["icontains", "istartswith", "exact"], "organization__country": [ diff --git a/scipost_django/journals/api/serializers/__init__.py b/scipost_django/journals/api/serializers/__init__.py index b564fb91b86cf50b362554f59538bc07bd2c77d6..010c174121505ebc8b0d05b29575d63f8d0db71c 100644 --- a/scipost_django/journals/api/serializers/__init__.py +++ b/scipost_django/journals/api/serializers/__init__.py @@ -7,4 +7,4 @@ from .publication import ( PublicationPublicSearchSerializer, ) -from .pubfraction import PubFractionPublicSerializer +from .pubfrac import PubFracPublicSerializer diff --git a/scipost_django/journals/api/serializers/pubfraction.py b/scipost_django/journals/api/serializers/pubfrac.py similarity index 81% rename from scipost_django/journals/api/serializers/pubfraction.py rename to scipost_django/journals/api/serializers/pubfrac.py index ea6075965689d3c6b2b4e338b40aa2d99666a24f..764f43722aa149fd596012f82caa3084bde7c904 100644 --- a/scipost_django/journals/api/serializers/pubfraction.py +++ b/scipost_django/journals/api/serializers/pubfrac.py @@ -4,12 +4,12 @@ __license__ = "AGPL v3" from rest_framework import serializers -from journals.models import OrgPubFraction +from finances.models import PubFrac from journals.api.serializers import PublicationPublicSearchSerializer from organizations.api.serializers import OrganizationPublicSerializer -class PubFractionPublicSerializer(serializers.ModelSerializer): +class PubFracPublicSerializer(serializers.ModelSerializer): organization = OrganizationPublicSerializer( fields=["url", "name", "acronym", "country"] ) @@ -18,5 +18,5 @@ class PubFractionPublicSerializer(serializers.ModelSerializer): ) class Meta: - model = OrgPubFraction + model = PubFrac fields = ["organization", "publication", "fraction"] diff --git a/scipost_django/journals/api/viewsets/__init__.py b/scipost_django/journals/api/viewsets/__init__.py index e7344e94a68969446a27740e883702d8f4d48aad..cd1d727c8578a1b13590ab89681b4990dd636d39 100644 --- a/scipost_django/journals/api/viewsets/__init__.py +++ b/scipost_django/journals/api/viewsets/__init__.py @@ -7,4 +7,4 @@ from .publication import ( PublicationPublicSearchAPIViewSet, ) -from .pubfraction import PubFractionPublicAPIViewSet +from .pubfrac import PubFracPublicAPIViewSet diff --git a/scipost_django/journals/api/viewsets/pubfraction.py b/scipost_django/journals/api/viewsets/pubfrac.py similarity index 71% rename from scipost_django/journals/api/viewsets/pubfraction.py rename to scipost_django/journals/api/viewsets/pubfrac.py index 06d0462138cb7ab69a0ff10d238b3881e8c74625..7508d98284b06699e40045735ac31a54196f9012 100644 --- a/scipost_django/journals/api/viewsets/pubfraction.py +++ b/scipost_django/journals/api/viewsets/pubfrac.py @@ -11,26 +11,26 @@ from rest_framework_csv import renderers as r from api.viewsets.mixins import FilteringOptionsActionMixin -from journals.models import OrgPubFraction -from journals.api.serializers import PubFractionPublicSerializer +from finances.models import PubFrac +from journals.api.serializers import PubFracPublicSerializer -from journals.api.filtersets import PubFractionPublicAPIFilterSet +from journals.api.filtersets import PubFracPublicAPIFilterSet -class PubFractionPublicAPIViewSet( +class PubFracPublicAPIViewSet( FilteringOptionsActionMixin, viewsets.ReadOnlyModelViewSet ): - queryset = OrgPubFraction.objects.all() + queryset = PubFrac.objects.all() permission_classes = [ AllowAny, ] - serializer_class = PubFractionPublicSerializer + serializer_class = PubFracPublicSerializer renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (r.CSVRenderer,) search_fields = ["organization__name", "publication__publication_date__year"] ordering_fields = [ "-publication_date", ] - filterset_class = PubFractionPublicAPIFilterSet + filterset_class = PubFracPublicAPIFilterSet default_filtering_fields = [ "organization__name__icontains", "publication__publication_date__year__exact", diff --git a/scipost_django/journals/forms.py b/scipost_django/journals/forms.py index 74ebb7acf6c05c009d3b154274796e59faea3aa8..3b38810ffaf0dc1c857fcbc3bf305610f1e898bd 100644 --- a/scipost_django/journals/forms.py +++ b/scipost_django/journals/forms.py @@ -42,12 +42,12 @@ from .models import ( Reference, Volume, PublicationAuthorsTable, - OrgPubFraction, ) from .utils import JournalUtils from common.utils import get_current_domain, jatsify_tags +from finances.models import PubFrac from funders.models import Grant, Funder from journals.models import Journal from mails.utils import DirectMailUtil @@ -799,9 +799,9 @@ class DraftAccompanyingPublicationForm(forms.Form): link=reference.link, ) - # Add PubFractions - for pubfrac in anchor.pubfractions.all(): - OrgPubFraction.objects.create( + # Add PubFracs + for pubfrac in anchor.pubfracs.all(): + PubFrac.objects.create( organization=pubfrac.organization, publication=companion, fraction=pubfrac.fraction, @@ -1087,19 +1087,19 @@ class IssueForm(forms.ModelForm): return issue -class SetOrgPubFractionForm(forms.ModelForm): +class SetPubFracForm(forms.ModelForm): class Meta: - model = OrgPubFraction + model = PubFrac fields = ["organization", "publication", "fraction"] def __init__(self, *args, **kwargs): - super(SetOrgPubFractionForm, self).__init__(*args, **kwargs) + super(SetPubFracForm, self).__init__(*args, **kwargs) if self.instance.id: self.fields["organization"].disabled = True self.fields["publication"].widget = forms.HiddenInput() -class BaseOrgPubFractionsFormSet(BaseModelFormSet): +class BasePubFracsFormSet(BaseModelFormSet): def clean(self): """ Checks that the fractions add up to one. @@ -1114,11 +1114,11 @@ class BaseOrgPubFractionsFormSet(BaseModelFormSet): ) -OrgPubFractionsFormSet = modelformset_factory( - OrgPubFraction, +PubFracsFormSet = modelformset_factory( + PubFrac, fields=("publication", "organization", "fraction"), - formset=BaseOrgPubFractionsFormSet, - form=SetOrgPubFractionForm, + formset=BasePubFracsFormSet, + form=SetPubFracForm, extra=0, ) diff --git a/scipost_django/journals/migrations/0129_alter_issue_in_journal_alter_issue_in_volume_and_more.py b/scipost_django/journals/migrations/0129_alter_issue_in_journal_alter_issue_in_volume_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..27acc5ef6331ef276b2cc992899be5b24c5cda83 --- /dev/null +++ b/scipost_django/journals/migrations/0129_alter_issue_in_journal_alter_issue_in_volume_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 4.2.10 on 2024-03-14 17:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("submissions", "0147_auto_20240220_1404"), + ("funders", "0015_alter_grant_funder_alter_grant_recipient"), + ("ontology", "0009_populate_specialty_topics"), + ("journals", "0128_populate_submission_object_types"), + ] + + operations = [ + migrations.AlterField( + model_name="issue", + name="in_journal", + field=models.ForeignKey( + blank=True, + help_text="Assign either a Volume or Journal to the Issue", + limit_choices_to={"structure": "IO"}, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="journals.journal", + ), + ), + migrations.AlterField( + model_name="issue", + name="in_volume", + field=models.ForeignKey( + blank=True, + help_text="Assign either a Volume or Journal to the Issue", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="journals.volume", + ), + ), + migrations.AlterField( + model_name="publication", + name="accepted_submission", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="submissions.submission" + ), + ), + migrations.AlterField( + model_name="publication", + name="funders_generic", + field=models.ManyToManyField(blank=True, to="funders.funder"), + ), + migrations.AlterField( + model_name="publication", + name="grants", + field=models.ManyToManyField(blank=True, to="funders.grant"), + ), + migrations.AlterField( + model_name="publication", + name="in_issue", + field=models.ForeignKey( + blank=True, + help_text="Assign either an Issue or Journal to the Publication", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="journals.issue", + ), + ), + migrations.AlterField( + model_name="publication", + name="in_journal", + field=models.ForeignKey( + blank=True, + help_text="Assign either an Issue or Journal to the Publication", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="journals.journal", + ), + ), + migrations.AlterField( + model_name="publication", + name="topics", + field=models.ManyToManyField(blank=True, to="ontology.topic"), + ), + migrations.AlterField( + model_name="reference", + name="publication", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="journals.publication" + ), + ), + migrations.AlterField( + model_name="volume", + name="in_journal", + field=models.ForeignKey( + limit_choices_to={"structure": "IV"}, + on_delete=django.db.models.deletion.CASCADE, + to="journals.journal", + ), + ), + migrations.DeleteModel( + name="OrgPubFraction", + ), + ] diff --git a/scipost_django/journals/migrations/0130_remove_publication_pubfractions_confirmed_by_authors.py b/scipost_django/journals/migrations/0130_remove_publication_pubfractions_confirmed_by_authors.py new file mode 100644 index 0000000000000000000000000000000000000000..5861b462b5308614c50e36e153ec31921bb6c965 --- /dev/null +++ b/scipost_django/journals/migrations/0130_remove_publication_pubfractions_confirmed_by_authors.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.10 on 2024-03-14 19:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("journals", "0129_alter_issue_in_journal_alter_issue_in_volume_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="publication", + name="pubfractions_confirmed_by_authors", + ), + ] diff --git a/scipost_django/journals/models/__init__.py b/scipost_django/journals/models/__init__.py index ed55e253303f9e943710b9d9945674da0091094a..1ee4631377ad56861c77f135ab988b34f2ff57a2 100644 --- a/scipost_django/journals/models/__init__.py +++ b/scipost_django/journals/models/__init__.py @@ -6,7 +6,7 @@ from .journal import Journal from .volume import Volume from .issue import Issue from .submission_template import SubmissionTemplate -from .publication import PublicationAuthorsTable, Publication, Reference, OrgPubFraction +from .publication import PublicationAuthorsTable, Publication, Reference from .deposits import Deposit, DOAJDeposit, GenericDOIDeposit from .update import PublicationUpdate from .autogenerated_file import AutogeneratedFileContentTemplate diff --git a/scipost_django/journals/models/publication.py b/scipost_django/journals/models/publication.py index 7a8bd0914051d6de563740496ffd69df59d2f60c..c608d59515db6eb2ecdc2d1c959b89ecc278aeb7 100644 --- a/scipost_django/journals/models/publication.py +++ b/scipost_django/journals/models/publication.py @@ -157,7 +157,6 @@ class Publication(models.Model): funders_generic = models.ManyToManyField( "funders.Funder", blank=True ) # not linked to a grant - pubfractions_confirmed_by_authors = models.BooleanField(default=False) # Metadata metadata = models.JSONField(default=dict, blank=True, null=True) @@ -421,9 +420,9 @@ class Publication(models.Model): ) @property - def pubfractions_sum_to_1(self): + def pubfracs_sum_to_1(self): """Checks that the support fractions sum up to one.""" - return self.pubfractions.aggregate(Sum("fraction"))["fraction__sum"] == 1 + return self.pubfracs.aggregate(Sum("fraction"))["fraction__sum"] == 1 @property def citation(self): @@ -533,40 +532,3 @@ class Reference(models.Model): return "[{}] {}, {}".format( self.reference_number, self.authors[:30], self.citation[:30] ) - - -class OrgPubFraction(models.Model): - """ - Associates a fraction of the funding credit for a given publication to an Organization, - to help answer the question: who funded this research? - - Fractions for a given publication should sum up to one. - - This data is used to compile publicly-displayed information on Organizations - as well as to set suggested contributions from Partners. - - To be set (ideally) during production phase, based on information provided by the authors. - """ - - organization = models.ForeignKey( - "organizations.Organization", - on_delete=models.CASCADE, - related_name="pubfractions", - blank=True, - null=True, - ) - publication = models.ForeignKey( - "journals.Publication", on_delete=models.CASCADE, related_name="pubfractions" - ) - fraction = models.DecimalField( - max_digits=4, decimal_places=3, default=Decimal("0.000") - ) - - class Meta: - unique_together = (("organization", "publication"),) - - @property - def value(self): - return int(self.fraction * self.publication.get_journal().cost_per_publication( - self.publication.publication_date.year - )) diff --git a/scipost_django/journals/templates/journals/_publication_actions.html b/scipost_django/journals/templates/journals/_publication_actions.html index 43613a0b14b08443cac5697c729c8cd2af2cfdbb..ff41a03b5eb8224d9d3db4d219e459c4d108b11b 100644 --- a/scipost_django/journals/templates/journals/_publication_actions.html +++ b/scipost_django/journals/templates/journals/_publication_actions.html @@ -75,7 +75,7 @@ <a href="{% url 'journals:manage_publication_resources' publication.doi_label %}">Manage publication resources</a> </li> <li> - <a href="{% url 'journals:allocate_orgpubfractions' publication.doi_label %}">Allocate Org Pub Fractions</a> + <a href="{% url 'journals:allocate_pubfracs' publication.doi_label %}">Allocate PubFracs</a> </li> {% comment %} <li> <a href="{% url 'journals:update_references' doi_label=publication.doi_label %}">Update references</a> diff --git a/scipost_django/journals/templates/journals/allocate_orgpubfractions.html b/scipost_django/journals/templates/journals/allocate_pubfracs.html similarity index 56% rename from scipost_django/journals/templates/journals/allocate_orgpubfractions.html rename to scipost_django/journals/templates/journals/allocate_pubfracs.html index 648afac411952a6091b950e4d20cefa66e1251f7..945e3aa9b08d77086c670243d0ff8e9aa77c3676 100644 --- a/scipost_django/journals/templates/journals/allocate_orgpubfractions.html +++ b/scipost_django/journals/templates/journals/allocate_pubfracs.html @@ -1,6 +1,6 @@ {% extends 'scipost/base.html' %} -{% block pagetitle %}: Allocate support fractions{% endblock pagetitle %} +{% block pagetitle %}: Allocate Publication Fractions{% endblock pagetitle %} {% block breadcrumb %} <div class="breadcrumb-container"> @@ -8,7 +8,7 @@ <nav class="breadcrumb hidden-sm-down"> <a href="{% url 'journals:journals' %}" class="breadcrumb-item">Journals</a> <a href="{% url 'journals:manage_metadata' %}" class="breadcrumb-item">Administration</a> - <span class="breadcrumb-item active">Allocate support fractions</span> + <span class="breadcrumb-item active">Allocate Publication Fractions</span> </nav> </div> </div> @@ -26,13 +26,13 @@ <hr class="divider"> <h3 class="highlight">Which Organizations supported the research in this publication?</h3> - <p>Please indicate <strong>which Organizations should be credited with supporting the research published in this publication</strong>.<br/>Data provided here is indicative and does not need to be extremely accurate.<br/>Note however that this data <strong>is used</strong> to set the suggested level of support from external Organizations which SciPost needs to remain sustainable.<br/><br/>The Organizations listed here appear as either host institutions for the authors, or as acknowledged funders.<br/>Is the list of Organizations incomplete? <a href="mailto:edadmin@{{ request.get_host }}?subject=Missing Organizations for {{ publication.doi_label }}&body=[Please provide the missing data here]">Please email EdAdmin with details</a>.</p> + <p>Please indicate <strong>which Organizations should be credited with supporting the research published in this publication</strong>.<br/>Data provided here is indicative and does not need to be extremely accurate.<br/>Note however that this data <strong>is used</strong> to set the suggested level of support from external Organizations which SciPost needs to remain sustainable.<br/><br/>The Organizations listed here appear as either host institutions for the authors, or as acknowledged funders.</p> - {% if "edadmin" in user_roles %} - <p><span class="text-danger">EdAdmin</span>: <a href="{% url 'journals:preallocate_orgpubfractions_from_affiliations' doi_label=publication.doi_label %}">preallocate fractions based on author affiliations</a> - {% endif %} - <form method="post" action="{% url 'journals:allocate_orgpubfractions' doi_label=publication.doi_label %}"> + <p><a class="btn btn-primary" href="{% url 'journals:preallocate_pubfracs_from_affiliations' doi_label=publication.doi_label %}">Preallocate fractions based on author affiliations</a></p> + + + <form method="post" action="{% url 'journals:allocate_pubfracs' doi_label=publication.doi_label %}"> {% csrf_token %} {{ formset.management_form }} <table class="table"> @@ -64,18 +64,10 @@ {% if formset.non_form_errors %} <h4 class="text-danger">Error: {{ formset.non_form_errors }}</h4> {% endif %} - <p>These fractions {% if publication.pubfractions_confirmed_by_authors %}<span class="text-success">have been confirmed</span>{% else %}<span class="text-danger">have not yet been confirmed</span>{% endif %} by the authors.</p> - <input type="submit" class="btn btn-primary" value="Save/confirm fractions"> + <input type="submit" class="btn btn-primary" value="Save fractions"> </form> <br/> - {% if perms.scipost.can_publish_accepted_submission %} - <ul class="ul"> - {% if not publication.pubfractions_confirmed_by_authors %} - <li><a href="{% url 'journals:request_pubfrac_check' doi_label=publication.doi_label %}" class="btn btn-link">Email corresponding author to request check of this pubfraction allocation</a></li> - {% endif %} - <li><a href="{% url 'journals:manage_metadata' %}" class="btn btn-link">Back to Admin</a></li> - </ul> - {% endif %} + <p><a href="{% url 'journals:manage_metadata' %}" class="btn btn-link">Back to Admin</a></p> </div> </div> diff --git a/scipost_django/journals/templatetags/journals_extras.py b/scipost_django/journals/templatetags/journals_extras.py index 1981386939f92c8ce27bb883413753b82d58dfae..ab4fb670462a2578c38edd0332518e0f98364acc 100644 --- a/scipost_django/journals/templatetags/journals_extras.py +++ b/scipost_django/journals/templatetags/journals_extras.py @@ -83,10 +83,7 @@ def latest_successful_crossref_generic_deposit(_object): @register.filter(name="pubfracs_fixed") def pubfracs_fixed(publication): - return ( - publication.pubfractions_confirmed_by_authors - and publication.pubfractions_sum_to_1 - ) + return publication.pubfracs_sum_to_1 @register.simple_tag(takes_context=True) diff --git a/scipost_django/journals/urls/general.py b/scipost_django/journals/urls/general.py index bee4ccf5b87bdb01c2b71f76194862442579c13e..38f0f8de6e98e149ef020d112801f21411634092 100644 --- a/scipost_django/journals/urls/general.py +++ b/scipost_django/journals/urls/general.py @@ -281,21 +281,16 @@ urlpatterns = [ journals_views.publication_remove_topic, name="publication_remove_topic", ), - # PubFraction allocation: + # PubFrac allocation: path( - "allocate_orgpubfractions/<publication_doi_label:doi_label>", - journals_views.allocate_orgpubfractions, - name="allocate_orgpubfractions", + "allocate_pubfracs/<publication_doi_label:doi_label>", + journals_views.allocate_pubfracs, + name="allocate_pubfracs", ), path( - "preallocate_orgpubfractions_from_affiliations/<publication_doi_label:doi_label>", - journals_views.preallocate_orgpubfractions_from_affiliations, - name="preallocate_orgpubfractions_from_affiliations", - ), - path( - "request_pubfrac_check/<publication_doi_label:doi_label>", - journals_views.request_pubfrac_check, - name="request_pubfrac_check", + "preallocate_pubfracs_from_affiliations/<publication_doi_label:doi_label>", + journals_views.preallocate_pubfracs_from_affiliations, + name="preallocate_pubfracs_from_affiliations", ), # Citedby path( diff --git a/scipost_django/journals/views.py b/scipost_django/journals/views.py index c654e1eb7ee78fcaa07b6921e5cc86708f6376f1..799a3fb2c19f250df065eab21f82964ae54bc42a 100644 --- a/scipost_django/journals/views.py +++ b/scipost_django/journals/views.py @@ -55,7 +55,6 @@ from .models import ( DOAJDeposit, GenericDOIDeposit, PublicationAuthorsTable, - OrgPubFraction, PublicationUpdate, AutogeneratedFileContentTemplate, PublicationResource, @@ -78,7 +77,7 @@ from .forms import ( DraftPublicationApprovalForm, PublicationPublishForm, PublicationAuthorOrderingFormSet, - OrgPubFractionsFormSet, + PubFracsFormSet, PublicationDynSelForm, ) from .mixins import PublicationMixin, ProdSupervisorPublicationPermissionMixin @@ -88,6 +87,7 @@ from .utils import JournalUtils from comments.models import Comment from common.utils import get_current_domain from common.views import HTMXInlineCRUDModelFormView, HTMXInlineCRUDModelListView +from finances.models import PubFrac from funders.forms import FunderSelectForm, GrantSelectForm from funders.models import Grant, Funder from mails.views import MailEditorSubview @@ -1368,56 +1368,44 @@ def publication_remove_topic(request, doi_label, slug): @login_required -def allocate_orgpubfractions(request, doi_label): +@permission_required("scipost.can_publish_accepted_submission", return_403=True) +def allocate_pubfracs(request, doi_label): """ Set the relative support obtained from Organizations for the research contained in a Publication. - This view is accessible to EdAdmin as well as to the corresponding author - of the Publication. + This view is accessible to EdAdmin. """ publication = get_object_or_404(Publication, doi_label=doi_label) - if not request.user.is_authenticated: - raise Http404 - elif not ( - request.user == publication.accepted_submission.submitted_by.user - or request.user.has_perm("scipost.can_publish_accepted_submission") - ): - raise Http404 - - # Create OrgPubFraction objects from existing organization links + # Create PubFrac objects from existing organization links for org in publication.get_organizations(): - pubfrac, created = OrgPubFraction.objects.get_or_create( + pubfrac, created = PubFrac.objects.get_or_create( publication=publication, organization=org ) - - formset = OrgPubFractionsFormSet( - request.POST or None, queryset=publication.pubfractions.all() + formset = PubFracsFormSet( + request.POST or None, queryset=publication.pubfracs.all() ) if formset.is_valid(): formset.save() - if request.user == publication.accepted_submission.submitted_by.user: - publication.pubfractions_confirmed_by_authors = True - publication.save() messages.success(request, "Funding fractions successfully allocated.") return redirect(publication.get_absolute_url()) context = { "publication": publication, "formset": formset, } - return render(request, "journals/allocate_orgpubfractions.html", context) + return render(request, "journals/allocate_pubfracs.html", context) @login_required @permission_required("scipost.can_publish_accepted_submission", return_403=True) -def preallocate_orgpubfractions_from_affiliations(request, doi_label): +def preallocate_pubfracs_from_affiliations(request, doi_label): """ - Prefill the pubfractions based on the author affiliations. + Prefill the pubfracs based on the author affiliations. """ publication = get_object_or_404(Publication, doi_label=doi_label) nr_authors = publication.authors.all().count() # Reset all existing pubfracs to zero - OrgPubFraction.objects.filter(publication=publication).update(fraction=0) + PubFrac.objects.filter(publication=publication).update(fraction=0) fraction = {} for org in publication.get_organizations(): fraction[org.id] = 0 @@ -1426,40 +1414,18 @@ def preallocate_orgpubfractions_from_affiliations(request, doi_label): for aff in author.affiliations.all(): fraction[aff.id] += 1.0 / (nr_authors * nr_affiliations) for org in publication.get_organizations(): - OrgPubFraction.objects.filter( + PubFrac.objects.filter( publication=publication, organization=org, ).update(fraction=Decimal(fraction[org.id])) return redirect( reverse( - "journals:allocate_orgpubfractions", + "journals:allocate_pubfracs", kwargs={"doi_label": doi_label}, ) ) -@login_required -@permission_required("scipost.can_publish_accepted_submission", return_403=True) -def request_pubfrac_check(request, doi_label): - """ - This view is used by EdAdmin to request confirmation of the OrgPubFractions - for a given Publication. - - This occurs post-publication, after all the affiliations and funders have - been confirmed. - """ - publication = get_object_or_404(Publication, doi_label=doi_label) - mail_request = MailEditorSubview( - request, "authors/request_pubfrac_check", publication=publication - ) - if mail_request.is_valid(): - messages.success(request, "The corresponding author has been emailed.") - mail_request.send_mail() - return redirect("journals:manage_metadata") - else: - return mail_request.interrupt() - - @permission_required("scipost.can_publish_accepted_submission", return_403=True) def mark_doaj_deposit_success(request, deposit_id, success): deposit = get_object_or_404(DOAJDeposit, pk=deposit_id) diff --git a/scipost_django/organizations/api/viewsets/organization.py b/scipost_django/organizations/api/viewsets/organization.py index b788c5918bb5daa8cc632ea2af810ccfb0af9730..0073fa08babc5c956a855d9be11ac99f80f5c783 100644 --- a/scipost_django/organizations/api/viewsets/organization.py +++ b/scipost_django/organizations/api/viewsets/organization.py @@ -13,7 +13,7 @@ from rest_framework_csv import renderers as r from api.viewsets.mixins import FilteringOptionsActionMixin -from journals.api.serializers import PubFractionPublicSerializer +from journals.api.serializers import PubFracPublicSerializer from organizations.models import Organization from organizations.api.filtersets import OrganizationPublicAPIFilterSet from organizations.api.serializers import ( @@ -47,10 +47,10 @@ class OrganizationPublicAPIViewSet( ] @action(detail=True) - def pubfractions(self, request, pk=None): - pubfractions = self.get_object().pubfractions.all() - serializer = PubFractionPublicSerializer( - pubfractions, many=True, context={"request": self.request} + def pubfracs(self, request, pk=None): + pubfracs = self.get_object().pubfracs.all() + serializer = PubFracPublicSerializer( + pubfracs, many=True, context={"request": self.request} ) return Response(serializer.data) diff --git a/scipost_django/organizations/models.py b/scipost_django/organizations/models.py index 3fae1c8497ca49111e473aaeef32ba7427799be7..c60de9b9d08c07680f1e958a774e09c263e3799b 100644 --- a/scipost_django/organizations/models.py +++ b/scipost_django/organizations/models.py @@ -32,7 +32,8 @@ from scipost.fields import ChoiceArrayField from scipost.models import Contributor from affiliates.models import AffiliatePublication from colleges.models import Fellowship -from journals.models import Journal, Publication, OrgPubFraction +from finances.models import PubFrac +from journals.models import Journal, Publication from profiles.models import Profile @@ -208,7 +209,7 @@ class Organization(models.Model): def get_affiliate_publications(self, journal): return AffiliatePublication.objects.filter( - pubfractions__organization=self, + pubfracs__organization=self, journal=journal, ) @@ -319,14 +320,14 @@ class Organization(models.Model): self.cf_balance_info = self.get_balance_info() self.save() - def pubfraction_for_publication(self, doi_label): + def pubfrac_for_publication(self, doi_label): """ - Return the organization's pubfraction for a publication. + Return the organization's pubfrac for a publication. """ - pfs = OrgPubFraction.objects.filter(publication__doi_label=doi_label) + pfs = PubFrac.objects.filter(publication__doi_label=doi_label) try: return pfs.get(organization=self).fraction - except OrgPubFraction.DoesNotExist: + except PubFrac.DoesNotExist: children_ids = [k["id"] for k in list(self.children.all().values("id"))] children_contribs = pfs.filter(organization__id__in=children_ids).aggregate( Sum("fraction") @@ -334,8 +335,8 @@ class Organization(models.Model): if children_contribs is not None: message = "as parent (ascribed to " for child in self.children.all(): - pfc = child.pubfraction_for_publication(doi_label) - if pfc not in ["No PubFraction ascribed", "Not yet defined"]: + pfc = child.pubfrac_for_publication(doi_label) + if pfc not in ["No PubFrac ascribed", "Not yet defined"]: message += "%s: %s; " % (child, pfc) return message.rpartition(";")[0] + ")" return "Not yet defined" @@ -349,7 +350,7 @@ class Organization(models.Model): publication.publication_date.year ) try: - pf = OrgPubFraction.objects.get( + pf = PubFrac.objects.get( publication=publication, organization=self, ) @@ -359,12 +360,12 @@ class Organization(models.Model): "expenditure": int(pf.fraction * unitcost), "message": "", } - except OrgPubFraction.DoesNotExist: + except PubFrac.DoesNotExist: pass children_ids = [k["id"] for k in list(self.children.all().values("id"))] children_contrib_ids = set( c - for c in OrgPubFraction.objects.filter( + for c in PubFrac.objects.filter( publication=publication, organization__id__in=children_ids, ).values_list("organization__id", flat=True) @@ -382,20 +383,14 @@ class Organization(models.Model): "message": message, } - def pubfractions_in_year(self, year): + def pubfracs_in_year(self, year): """ - Returns the sum of pubfractions for the given year. + Returns the sum of pubfracs for the given year. """ - fractions = OrgPubFraction.objects.filter( + fractions = PubFrac.objects.filter( organization=self, publication__publication_date__year=year ) return { - "confirmed": fractions.filter( - publication__pubfractions_confirmed_by_authors=True - ).aggregate(Sum("fraction"))["fraction__sum"], - "estimated": fractions.filter( - publication__pubfractions_confirmed_by_authors=False - ).aggregate(Sum("fraction"))["fraction__sum"], "total": fractions.aggregate(Sum("fraction"))["fraction__sum"], } @@ -461,7 +456,7 @@ class Organization(models.Model): cumulative_balance = 0 cumulative_expenditures = 0 cumulative_contribution = 0 - pf = self.pubfractions.all() + pf = self.pubfracs.all() for year in pubyears: rep[str(year)] = {} contribution = self.total_subsidies_in_year(year) @@ -495,17 +490,21 @@ class Organization(models.Model): ] journal_labels = set(jl1 + jl2 + jl3) for journal_label in journal_labels: - sumpf = pfy.filter( + qs = pfy.filter( publication__doi_label__istartswith=journal_label + "." - ).aggregate(Sum("fraction"))["fraction__sum"] + ) + nap = qs.count() + sumpf = qs.aggregate(Sum("fraction"))["fraction__sum"] costperpaper = get_object_or_404( Journal, doi_label=journal_label ).cost_per_publication(year) expenditures = int(costperpaper * sumpf) if sumpf > 0: rep[str(year)]["expenditures"][journal_label] = { - "pubfractions": float(sumpf), "costperpaper": costperpaper, + "nap": nap, + "undivided_expenditures": nap * costperpaper, + "pubfracs": float(sumpf), "expenditures": expenditures, } year_expenditures += expenditures diff --git a/scipost_django/organizations/templates/organizations/_organization_card.html b/scipost_django/organizations/templates/organizations/_organization_card.html index f8412cab4ecebef3d36a3c7922d389c7bccd1bfb..a6d0b11cf4e85fe9156cc5b7cf2b76560a9b1081 100644 --- a/scipost_django/organizations/templates/organizations/_organization_card.html +++ b/scipost_django/organizations/templates/organizations/_organization_card.html @@ -17,7 +17,7 @@ <a class="nav-link" id="details-{{ org.id }}-tab" data-bs-toggle="tab" href="#details-{{ org.id }}" role="tab" aria-controls="details-{{ org.id }}" aria-selected="true">Details</a> </li> <li class="nav-item"> - <a class="nav-link active" id="publications-{{ org.id }}-tab" data-bs-toggle="tab" href="#publications-{{ org.id }}" role="tab" aria-controls="publications-{{ org.id }}" aria-selected="true">Publications{% if perms.scipost.can_manage_organizations %} & PubFractions{% endif %}</a> + <a class="nav-link active" id="publications-{{ org.id }}-tab" data-bs-toggle="tab" href="#publications-{{ org.id }}" role="tab" aria-controls="publications-{{ org.id }}" aria-selected="true">Publications{% if perms.scipost.can_manage_organizations %} & PubFracs{% endif %}</a> </li> <li class="nav-item"> <a class="nav-link" id="authors-{{ org.id }}-tab" data-bs-toggle="tab" href="#authors-{{ org.id }}" role="tab" aria-controls="authors-{{ org.id }}" aria-selected="true">Associated Authors</a> @@ -75,9 +75,9 @@ <div class="tab-pane show active pt-4" id="publications-{{ org.id }}" role="tabpanel" aria-labelledby="publications-{{ org.id }}-tab"> <h3>Publications associated to this Organization {% if perms.scipost.can_manage_organizations %} - <span class="text-muted small">(with PubFractions <span data-bs-toggle="tooltip" data-bs-html="true" title="" data-original-title="Fraction of a publication's funding/institutional support associated to a given Organization">{% include 'bi/info-circle-fill.html' %}</span>)</span>{% endif %}:</h3> + <span class="text-muted small">(with PubFracs <span data-bs-toggle="tooltip" data-bs-html="true" title="" data-original-title="Fraction of a publication's funding/institutional support associated to a given Organization">{% include 'bi/info-circle-fill.html' %}</span>)</span>{% endif %}:</h3> {% for pubyear in pubyears %} - <h4>{{ pubyear }}{% if perms.scipost.can_manage_organizations %} <span class="text-muted small">(PubFractions {{ org|pubfractions_in_year:pubyear }})</span>{% endif %}</h4> + <h4>{{ pubyear }}{% if perms.scipost.can_manage_organizations %} <span class="text-muted small">(PubFracs {{ org|pubfracs_in_year:pubyear }})</span>{% endif %}</h4> <ul> {% for publication in org.get_publications %} {% if publication.publication_date|date:'Y'|add:"0" == pubyear %} @@ -286,10 +286,14 @@ <table class="table table-bordered"> <thead class="table-dark"> <tr> - <th>Journal</th> - <th class="text-end">Sum of PubFractions</th> - <th class="text-end">Cost per publication</th> - <th class="text-end">Expenditures</th> + <th class="align-top">Journal<br></th> + <th class="text-end">SciPost Expenditure<br>per publication</th> + <th class="text-end">NAP<br>for this Org</th> + <th class="text-end"> + Full Expenditures (unshared)<br>by SciPost for these Publications + </th> + <th class="text-end">Sum of PubFracs<br>for this Org</th> + <th class="text-end">Expenditures share<br>for this Org</th> </tr> </thead> <tbody> @@ -297,15 +301,17 @@ {% if journal != 'total' %} <tr> <td>{{ journal }}</td> - <td class="text-end">{{ journaldata.pubfractions }}</td> <td class="text-end">{{ journaldata.costperpaper }}</td> + <td class="text-end">{{ journaldata.nap }}</td> + <td class="text-end">{{ journaldata.undivided_expenditures }}</td> + <td class="text-end">{{ journaldata.pubfracs }}</td> <td class="text-end">{{ journaldata.expenditures }}</td> </tr> {% endif %} {% endfor %} </tbody> </table> - <p>You can see the associated publications and their PubFractions under the <em>Publications & PubFractions</em> tab.</p> + <p>You can see the associated publications and their PubFracs under the <em>Publications & PubFracs</em> tab.</p> </div> </td> </tr> diff --git a/scipost_django/organizations/templatetags/organizations_extras.py b/scipost_django/organizations/templatetags/organizations_extras.py index 2ae7bb56ab8a0a545217cd550ada5c2c02673c4b..58bb01857377cb364b2cff8e4592ddf919ab6f0a 100644 --- a/scipost_django/organizations/templatetags/organizations_extras.py +++ b/scipost_django/organizations/templatetags/organizations_extras.py @@ -7,9 +7,9 @@ from django import template register = template.Library() -@register.filter(name="pubfraction_for_publication") -def pubfraction_for_publication(org, publication): - return org.pubfraction_for_publication(publication.doi_label) +@register.filter(name="pubfrac_for_publication") +def pubfrac_for_publication(org, publication): + return org.pubfrac_for_publication(publication.doi_label) @register.filter(name="expenditure_for_publication") @@ -18,20 +18,10 @@ def expenditure_for_publication(org, publication): return org.cf_expenditure_for_publication[publication.doi_label]["expenditure"] -@register.filter(name="pubfractions_in_year") -def pubfractions_in_year(org, year): - fractions = org.pubfractions_in_year(int(year)) +@register.filter(name="pubfracs_in_year") +def pubfracs_in_year(org, year): + fractions = org.pubfracs_in_year(int(year)) if not fractions["total"]: return "total: 0" text = "total: %s" % fractions["total"] - if fractions["confirmed"] == fractions["total"]: - text += " (confirmed)" - return text - elif fractions["estimated"] == fractions["total"]: - text += " (estimated)" - return text - text += " (confirmed: %s; estimated: %s)" % ( - fractions["confirmed"], - fractions["estimated"], - ) return text diff --git a/scipost_django/organizations/views.py b/scipost_django/organizations/views.py index 3b55b714545effc5817aea2b2c25f8f710340c4c..75ee133a8e0a31731804a1e2ffe28a36a4d93302 100644 --- a/scipost_django/organizations/views.py +++ b/scipost_django/organizations/views.py @@ -188,7 +188,6 @@ class OrganizationDetailView(DetailView): def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) context["pubyears"] = range(int(timezone.now().strftime("%Y")), 2015, -1) - # context["balance"] = self.object.get_balance_info() context["balance"] = self.object.cf_balance_info return context @@ -207,7 +206,7 @@ class OrganizationDetailView(DetailView): "contactrole_set", "funder_set", "organizationevent_set", - "pubfractions", + "pubfracs", ) diff --git a/scipost_django/petitions/migrations/0010_alter_petitionsignatory_organization_and_more.py b/scipost_django/petitions/migrations/0010_alter_petitionsignatory_organization_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..58f0255ccc934c17d008439483255fcd47a0e1c7 --- /dev/null +++ b/scipost_django/petitions/migrations/0010_alter_petitionsignatory_organization_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.10 on 2024-03-14 17:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0021_enable_unaccent"), + ("scipost", "0040_auto_20210310_2026"), + ("petitions", "0009_auto_20210310_2026"), + ] + + operations = [ + migrations.AlterField( + model_name="petitionsignatory", + name="organization", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="organizations.organization", + ), + ), + migrations.AlterField( + model_name="petitionsignatory", + name="petition", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="petitions.petition" + ), + ), + migrations.AlterField( + model_name="petitionsignatory", + name="signatory", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="scipost.contributor", + ), + ), + ] diff --git a/scipost_django/proceedings/migrations/0011_alter_proceedings_fellowships.py b/scipost_django/proceedings/migrations/0011_alter_proceedings_fellowships.py new file mode 100644 index 0000000000000000000000000000000000000000..ad9fa90bc9c3f5b59434bb2b7d2e60d4a7b8b886 --- /dev/null +++ b/scipost_django/proceedings/migrations/0011_alter_proceedings_fellowships.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.10 on 2024-03-14 17:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("colleges", "0047_fellowshipnominationvotinground_type"), + ("proceedings", "0010_add_proceedings_preface"), + ] + + operations = [ + migrations.AlterField( + model_name="proceedings", + name="fellowships", + field=models.ManyToManyField(blank=True, to="colleges.fellowship"), + ), + ] diff --git a/scipost_django/profiles/migrations/0039_alter_profileemail_profile.py b/scipost_django/profiles/migrations/0039_alter_profileemail_profile.py new file mode 100644 index 0000000000000000000000000000000000000000..6ff0cfb8f726c8b0a7dbd74d32bdb9df4dbeaa1e --- /dev/null +++ b/scipost_django/profiles/migrations/0039_alter_profileemail_profile.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.10 on 2024-03-14 17:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("profiles", "0038_auto_20240220_1604"), + ] + + operations = [ + migrations.AlterField( + model_name="profileemail", + name="profile", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="profiles.profile" + ), + ), + ] diff --git a/scipost_django/scipost/migrations/0041_alter_remark_contributor_alter_remark_recommendation_and_more.py b/scipost_django/scipost/migrations/0041_alter_remark_contributor_alter_remark_recommendation_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..240b03342eb23dfdf81bb196b69bbe17ded0ca18 --- /dev/null +++ b/scipost_django/scipost/migrations/0041_alter_remark_contributor_alter_remark_recommendation_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.10 on 2024-03-14 17:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("submissions", "0147_auto_20240220_1404"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("scipost", "0040_auto_20210310_2026"), + ] + + operations = [ + migrations.AlterField( + model_name="remark", + name="contributor", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="scipost.contributor" + ), + ), + migrations.AlterField( + model_name="remark", + name="recommendation", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="submissions.eicrecommendation", + ), + ), + migrations.AlterField( + model_name="remark", + name="submission", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="submissions.submission", + ), + ), + migrations.AlterField( + model_name="totpdevice", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/scipost_django/scipost/templates/scipost/personal_page/_hx_publications.html b/scipost_django/scipost/templates/scipost/personal_page/_hx_publications.html index e3af784ddf1d4fa1fc707003d67f2575ed509598..6bc467885e92fb1e5634efa007b09c3325fdf767 100644 --- a/scipost_django/scipost/templates/scipost/personal_page/_hx_publications.html +++ b/scipost_django/scipost/templates/scipost/personal_page/_hx_publications.html @@ -24,11 +24,6 @@ <li> <div class="card bg-light card-publication" id="{{pub.doi_label}}"> {% include 'journals/_publication_card_content.html' with publication=pub current_user=request.user %} - {% if request.user == pub.accepted_submission.submitted_by.user %} - {% if not pub.pubfractions_confirmed_by_authors or not pub.pubfractions_sum_to_1 %} - <h4 class="m-2"><a href="{% url 'journals:allocate_orgpubfractions' doi_label=pub.doi_label %}"><span class="text-danger">Intervention needed:</span> review support fractions</a></h4> - {% endif %} - {% endif %} </div> </li> {% empty %} diff --git a/scipost_django/submissions/migrations/0148_alter_editorialassignment_submission_and_more.py b/scipost_django/submissions/migrations/0148_alter_editorialassignment_submission_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..73901ca3915f64a81318abf8dc384b5a1e9fc5cf --- /dev/null +++ b/scipost_django/submissions/migrations/0148_alter_editorialassignment_submission_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 4.2.10 on 2024-03-14 17:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "scipost", + "0041_alter_remark_contributor_alter_remark_recommendation_and_more", + ), + ("submissions", "0147_auto_20240220_1404"), + ] + + operations = [ + migrations.AlterField( + model_name="editorialassignment", + name="submission", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="submissions.submission" + ), + ), + migrations.AlterField( + model_name="editorialassignment", + name="to", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="scipost.contributor" + ), + ), + migrations.AlterField( + model_name="editorialcommunication", + name="referee", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="scipost.contributor", + ), + ), + migrations.AlterField( + model_name="editorialcommunication", + name="submission", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="submissions.submission" + ), + ), + ] diff --git a/scipost_django/templates/email/authors/request_pubfrac_check.html b/scipost_django/templates/email/authors/request_pubfrac_check.html deleted file mode 100644 index 3172794f4b73c166664d67879ff538ea18f80ffa..0000000000000000000000000000000000000000 --- a/scipost_django/templates/email/authors/request_pubfrac_check.html +++ /dev/null @@ -1,23 +0,0 @@ -<p> - Dear {{ publication.accepted_submission.submitted_by.profile.get_title_display }} {{ publication.accepted_submission.submitted_by.user.last_name }}, -</p> -<p> - For your recent SciPost publication, -</p> -<p> - <a href="https://{{ domain }}/{{ publication.get_absolute_url }}">{{ publication.title }}</a><br> - by {{ publication.author_list }} -</p> -<p> - we would like you to help us determine who supported the research contained in this publication. This info is of great use for our sustainability efforts. One or two minutes of your attention are all that is required. -</p> -<p> - Could we beg you to navigate (login required) to <a href="https://{{ domain }}{% url 'journals:allocate_orgpubfractions' doi_label=publication.doi_label %}">this page</a> and check/correct the data we have prepared? -</p> -<p> - Many thanks in advance, -</p> -<p>The SciPost Team</p> -{% include 'email/_footer.html' %} - -{% include 'email/_submission_thread_uuid.html' with submission=publication.accepted_submission %} diff --git a/scipost_django/templates/email/authors/request_pubfrac_check.json b/scipost_django/templates/email/authors/request_pubfrac_check.json deleted file mode 100644 index 25feeddd5acad9f49e8466c97bff0a8c923dc0b6..0000000000000000000000000000000000000000 --- a/scipost_django/templates/email/authors/request_pubfrac_check.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "subject": "SciPost: please check your publication's support info", - "recipient_list": [ - "accepted_submission.submitted_by.user.email" - ], - "bcc": [ - "edadmin@" - ], - "from_name": "SciPost Editorial Administration", - "from_email": "edadmin@" -}