diff --git a/scipost_django/production/admin.py b/scipost_django/production/admin.py index ac10d9871ead9b7ff01ec0ab3e7a5de867dcbfa6..645fc77ac952b5335078de76f3639077e25a2bcb 100644 --- a/scipost_django/production/admin.py +++ b/scipost_django/production/admin.py @@ -12,6 +12,7 @@ from .models import ( ProductionUser, Proofs, ProductionEventAttachment, + ProofsRepository, ) @@ -95,3 +96,17 @@ admin.site.register(Proofs, ProductionProofsAdmin) admin.site.register(ProductionEventAttachment) + + +class ProofsRepositoryAdmin(GuardedModelAdmin): + search_fields = [ + "stream__submission__author_list", + "stream__submission__title", + "stream__submission__preprint__identifier_w_vn_nr", + ] + list_filter = ["status"] + list_display = ["stream", "status", "git_path"] + readonly_fields = ["template_path", "git_path"] + + +admin.site.register(ProofsRepository, ProofsRepositoryAdmin) diff --git a/scipost_django/production/constants.py b/scipost_django/production/constants.py index 556a5ee61c98eb993c3e853723ee7093a8420c99..917a3b85248973c9f93d938fbdfc6c3294fa7025 100644 --- a/scipost_django/production/constants.py +++ b/scipost_django/production/constants.py @@ -74,3 +74,16 @@ PRODUCTION_ALL_WORK_LOG_TYPES = ( "Cited people have been notified/invited to SciPost", ), ) + +PROOFS_REPO_UNINITIALIZED = "uninitialized" +PROOFS_REPO_CREATED = "created" +PROOFS_REPO_TEMPLATE_ONLY = "template_only" +PROOFS_REPO_TEMPLATE_FORMATTED = "template_formatted" +PROOFS_REPO_PRODUCTION_READY = "production_ready" +PROOFS_REPO_STATUSES = ( + (PROOFS_REPO_UNINITIALIZED, "The repository does not exist"), + (PROOFS_REPO_CREATED, "The repository exists but is empty"), + (PROOFS_REPO_TEMPLATE_ONLY, "The repository contains the bare template"), + (PROOFS_REPO_TEMPLATE_FORMATTED, "The repository contains the automatically formatted template"), + (PROOFS_REPO_PRODUCTION_READY, "The repository is ready for production"), +) diff --git a/scipost_django/production/migrations/0006_proofsrepository.py b/scipost_django/production/migrations/0006_proofsrepository.py new file mode 100644 index 0000000000000000000000000000000000000000..8865b3441e8fe8335de26d6a681dd95d8a99a440 --- /dev/null +++ b/scipost_django/production/migrations/0006_proofsrepository.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.18 on 2023-05-15 14:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('production', '0005_auto_20190511_1141'), + ] + + operations = [ + migrations.CreateModel( + name='ProofsRepository', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('uninitialized', 'The repository does not exist'), ('created', 'The repository exists but is empty'), ('template_only', 'The repository contains the bare template'), ('template_formatted', 'The repository contains the automatically formatted template'), ('production_ready', 'The repository is ready for production')], default='uninitialized', max_length=32)), + ('stream', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='proofs_repository', to='production.productionstream')), + ], + options={ + 'verbose_name_plural': 'proofs repositories', + }, + ), + ] diff --git a/scipost_django/production/models.py b/scipost_django/production/models.py index 65ef7165884446c0f51b4fe9e3d1ab2cafd7c388..c970ff471774a802987c01bf7e3d4310010e0fa8 100644 --- a/scipost_django/production/models.py +++ b/scipost_django/production/models.py @@ -6,8 +6,14 @@ from django.db import models from django.contrib.contenttypes.fields import GenericRelation from django.urls import reverse from django.contrib.auth.models import User +from profiles.models import Profile from django.utils import timezone from django.utils.functional import cached_property +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.db.models import Value +from django.db.models.functions import Concat + from .constants import ( PRODUCTION_STREAM_STATUS, @@ -18,6 +24,8 @@ from .constants import ( PRODUCTION_STREAM_COMPLETED, PROOFS_STATUSES, PROOFS_UPLOADED, + PROOFS_REPO_STATUSES, + PROOFS_REPO_UNINITIALIZED, ) from .managers import ( ProductionStreamQuerySet, @@ -126,17 +134,11 @@ class ProductionStream(models.Model): class ProductionEvent(models.Model): - stream = models.ForeignKey( - ProductionStream, on_delete=models.CASCADE, related_name="events" - ) - event = models.CharField( - max_length=64, choices=PRODUCTION_EVENTS, default=EVENT_MESSAGE - ) + stream = models.ForeignKey(ProductionStream, on_delete=models.CASCADE, related_name="events") + event = models.CharField(max_length=64, choices=PRODUCTION_EVENTS, default=EVENT_MESSAGE) comments = models.TextField(blank=True, null=True) noted_on = models.DateTimeField(default=timezone.now) - noted_by = models.ForeignKey( - "production.ProductionUser", on_delete=models.CASCADE, related_name="events" - ) + noted_by = models.ForeignKey("production.ProductionUser", on_delete=models.CASCADE, related_name="events") noted_to = models.ForeignKey( "production.ProductionUser", on_delete=models.CASCADE, @@ -159,10 +161,7 @@ class ProductionEvent(models.Model): @cached_property def editable(self): - return ( - self.event in [EVENT_MESSAGE, EVENT_HOUR_REGISTRATION] - and not self.stream.completed - ) + return self.event in [EVENT_MESSAGE, EVENT_HOUR_REGISTRATION] and not self.stream.completed def production_event_upload_location(instance, filename): @@ -185,9 +184,7 @@ class ProductionEventAttachment(models.Model): on_delete=models.CASCADE, related_name="attachments", ) - attachment = models.FileField( - upload_to=production_event_upload_location, storage=SecureFileStorage() - ) + attachment = models.FileField(upload_to=production_event_upload_location, storage=SecureFileStorage()) def get_absolute_url(self): return reverse( @@ -213,20 +210,12 @@ class Proofs(models.Model): Proofs are directly related to a ProductionStream and Submission in SciPost. """ - attachment = models.FileField( - upload_to=proofs_upload_location, storage=SecureFileStorage() - ) + attachment = models.FileField(upload_to=proofs_upload_location, storage=SecureFileStorage()) version = models.PositiveSmallIntegerField(default=0) - stream = models.ForeignKey( - "production.ProductionStream", on_delete=models.CASCADE, related_name="proofs" - ) - uploaded_by = models.ForeignKey( - "production.ProductionUser", on_delete=models.CASCADE, related_name="+" - ) + stream = models.ForeignKey("production.ProductionStream", on_delete=models.CASCADE, related_name="proofs") + uploaded_by = models.ForeignKey("production.ProductionUser", on_delete=models.CASCADE, related_name="+") created = models.DateTimeField(auto_now_add=True) - status = models.CharField( - max_length=16, choices=PROOFS_STATUSES, default=PROOFS_UPLOADED - ) + status = models.CharField(max_length=16, choices=PROOFS_STATUSES, default=PROOFS_UPLOADED) accessible_for_authors = models.BooleanField(default=False) objects = ProofsQuerySet.as_manager() @@ -239,9 +228,7 @@ class Proofs(models.Model): return reverse("production:proofs_pdf", kwargs={"slug": self.slug}) def __str__(self): - return "Proofs {version} for Stream {stream}".format( - version=self.version, stream=self.stream.submission.title - ) + return "Proofs {version} for Stream {stream}".format(version=self.version, stream=self.stream.submission.title) def save(self, *args, **kwargs): # Control Report count per Submission. @@ -252,3 +239,96 @@ class Proofs(models.Model): @property def slug(self): return proofs_id_to_slug(self.id) + + +class ProofsRepository(models.Model): + """ + ProofsRepository is a GitLab repository of Proofs for a Submission. + """ + + stream = models.OneToOneField( + ProductionStream, + on_delete=models.CASCADE, + related_name="proofs_repository", + ) + status = models.CharField( + max_length=32, + choices=PROOFS_REPO_STATUSES, + default=PROOFS_REPO_UNINITIALIZED, + ) + + @property + def name(self) -> str: + """ + Return the name of the repository in the form of "id_lastname". + """ + # Get the last name of the first author by getting the first author string from the submission + first_author_str = self.stream.submission.authors_as_list[0] + first_author_profile = ( + Profile.objects.annotate( + full_name=Concat("first_name", Value(" "), "last_name") + ) + .filter(full_name=first_author_str) + .first() + ) + if first_author_profile is None: + first_author_last_name = first_author_str.split(" ")[-1] + else: + first_author_last_name = first_author_profile.last_name + # Keep only the last of the last names + first_author_last_name = first_author_last_name.split(" ")[-1] + + return "{preprint_id}_{last_name}".format( + preprint_id=self.stream.submission.preprint.identifier_w_vn_nr, + last_name=first_author_last_name, + ) + + @property + def journal_path_abbrev(self) -> str: + # The DOI label is used to determine the path of the repository and template + journal_abbrev = ( + self.stream.submission.editorial_decision.for_journal.doi_label + ) + return journal_abbrev + + @property + def git_path(self) -> str: + # Get creation date of the stream + # Warning: The month grouping of streams was done using the tasked date, + # but should now instead be the creation (opened) date. + creation_year, creation_month = self.stream.opened.strftime( + "%Y-%m" + ).split("-") + + return "/Proofs/{journal}/{year}/{month}/{repo_name}".format( + journal=self.journal_path_abbrev, + year=creation_year, + month=creation_month, + repo_name=self.name, + ) + + @property + def template_path(self) -> str: + return "Templates/{journal}".format(journal=self.journal_path_abbrev) + + def __str__(self) -> str: + return f"Proofs repo for {self.stream}" + + class Meta: + verbose_name_plural = "proofs repositories" + + +@receiver(post_save, sender=ProductionStream) +def production_stream_create_proofs_repo(sender, instance, created, **kwargs): + """ + If a ProductionStream instance is created, a Proofs Repository instance is created + and linked to it. + """ + if created: + ProofsRepository.objects.create( + stream=instance, + status=PROOFS_REPO_UNINITIALIZED, + ) + + +post_save.connect(production_stream_create_proofs_repo, sender=ProductionStream) diff --git a/scipost_django/production/tests/test_models.py b/scipost_django/production/tests/test_models.py index ddef03c4df91383dfce15a034976e3a469d77d70..397b48bdfed4f1c053759760ae62208f45f7cc42 100644 --- a/scipost_django/production/tests/test_models.py +++ b/scipost_django/production/tests/test_models.py @@ -1,7 +1,175 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" +import datetime from django.test import TestCase # Create your tests here. + +from submissions.constants import EIC_REC_PUBLISH +from journals.models import Journal +from submissions.models import Submission, EditorialDecision +from production.models import ProductionStream, ProofsRepository +from preprints.models import Preprint +from ontology.models import AcademicField, Branch, Specialty +from colleges.models import College +from scipost.models import Contributor +from profiles.models import Profile + + +from django.contrib.auth.models import User + + +class TestProofRepository(TestCase): + def _create_submitter_contributor(self): + random_user = User.objects.create_user( + username="testuser", + password="testpassword", + ) + user_profile = Profile.objects.create( + title="DR", + first_name="Test", + last_name="User", + ) + Contributor.objects.create(user=random_user, profile=user_profile) + + def _create_college(self): + College.objects.create( + name="College of Quantum Physics", + acad_field=AcademicField.objects.get(name="Quantum Physics"), + slug="college-of-quantum-physics", + order=10, + ) + + def _create_journal(self): + Journal.objects.create( + college=College.objects.get(name="College of Quantum Physics"), + name="SciPost Physics", + name_abbrev="SciPost Phys.", + doi_label="SciPostPhys", + cf_metrics='{"":""}', + ) + + def _create_editorial_decision(self): + EditorialDecision.objects.create( + submission=Submission.objects.get( + preprint__identifier_w_vn_nr="scipost_202101_00001v1" + ), + for_journal=Journal.objects.get(name="SciPost Physics"), + decision=EIC_REC_PUBLISH, + status=EditorialDecision.FIXED_AND_ACCEPTED, + ) + + def _create_specialty(self): + Specialty.objects.create( + acad_field=AcademicField.objects.get(name="Quantum Physics"), + name="Quantum Information", + slug="quantum-information", + order=10, + ) + + def _create_academic_field(self): + AcademicField.objects.create( + branch=Branch.objects.get(name="Physics"), + name="Quantum Physics", + slug="quantum-physics", + order=10, + ) + + def _create_branch(self): + Branch.objects.create( + name="Physics", + slug="physics", + order=10, + ) + + def _create_preprint(self): + Preprint.objects.create(identifier_w_vn_nr="scipost_202101_00001v1") + + def _create_submission(self): + submission = Submission.objects.create( + preprint=Preprint.objects.get( + identifier_w_vn_nr="scipost_202101_00001v1" + ), + submitted_to=Journal.objects.get(name="SciPost Physics"), + title="Test submission", + abstract="Test abstract", + author_list="Test User", + acad_field=AcademicField.objects.get(name="Quantum Physics"), + # specialties=Specialty.objects.filter(name="Quantum Information"), + submitted_by=Contributor.objects.get(user__username="testuser"), + ) + submission.authors.add( + Contributor.objects.get(user__username="testuser") + ) + submission.save() + + def _create_production_stream(self): + stream = ProductionStream.objects.create( + submission=Submission.objects.get( + preprint__identifier_w_vn_nr="scipost_202101_00001v1" + ), + ) + stream.opened = datetime.datetime( + 2021, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ) + stream.save() + + def setUp(self): + self._create_submitter_contributor() + self._create_branch() + self._create_academic_field() + self._create_specialty() + self._create_college() + self._create_journal() + self._create_preprint() + self._create_submission() + self._create_editorial_decision() + self._create_production_stream() + + def test_repo_scipostphys_existing_profile(self): + proofs_repo = ProofsRepository.objects.get( + stream__submission__preprint__identifier_w_vn_nr="scipost_202101_00001v1" + ) + + self.assertEqual( + proofs_repo.git_path, + "Proofs/SciPostPhys/2021/01/scipost_202101_00001v1_User", + ) + + self.assertEqual(proofs_repo.template_path, "Templates/SciPostPhys") + + def test_repo_scipostphys_nonexisting_profile(self): + proofs_repo = ProofsRepository.objects.get( + stream__submission__preprint__identifier_w_vn_nr="scipost_202101_00001v1" + ) + + # delete profile + Contributor.objects.get(user__username="testuser").profile.delete() + + self.assertEqual( + proofs_repo.git_path, + "Proofs/SciPostPhys/2021/01/scipost_202101_00001v1_User", + ) + self.assertEqual(proofs_repo.template_path, "Templates/SciPostPhys") + + def test_repo_scipostphys_double_last_name_profile(self): + proofs_repo = ProofsRepository.objects.get( + stream__submission__preprint__identifier_w_vn_nr="scipost_202101_00001v1" + ) + + proofs_repo.stream.submission.author_list = "Test Usable User" + + user_profile = Contributor.objects.get( + user__username="testuser" + ).profile + user_profile.last_name = "Usable User" + user_profile.save() + + self.assertEqual( + proofs_repo.git_path, + "Proofs/SciPostPhys/2021/01/scipost_202101_00001v1_User", + ) + + self.assertEqual(proofs_repo.template_path, "Templates/SciPostPhys")