SciPost Code Repository

Skip to content
Snippets Groups Projects
models.py 12.7 KiB
Newer Older
__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
from django.db import models
Jorran de Wit's avatar
Jorran de Wit committed
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 django.conf import settings
from .constants import (
    PRODUCTION_STREAM_STATUS,
    PRODUCTION_STREAM_INITIATED,
    PRODUCTION_EVENTS,
    EVENT_MESSAGE,
    EVENT_HOUR_REGISTRATION,
    PRODUCTION_STREAM_COMPLETED,
    PROOFS_STATUSES,
    PROOFS_UPLOADED,
    PROOFS_REPO_STATUSES,
    PROOFS_REPO_UNINITIALIZED,
)
from .managers import (
    ProductionStreamQuerySet,
    ProductionEventManager,
    ProofsQuerySet,
    ProductionUserQuerySet,
)
Jorran de Wit's avatar
Jorran de Wit committed
from .utils import proofs_id_to_slug
Jorran de Wit's avatar
Jorran de Wit committed
from finances.models import WorkLog
Jorran de Wit's avatar
Jorran de Wit committed
from scipost.storage import SecureFileStorage
class ProductionUser(models.Model):
    """
    Production Officers will have a ProductionUser object related to their account
    to relate all production related actions to.
    """

    user = models.OneToOneField(
        User,
        on_delete=models.PROTECT,
        unique=True,
        related_name="production_user",
        null=True,
    )
    name = models.CharField(max_length=128, blank=True)
    objects = ProductionUserQuerySet.as_manager()

    def __str__(self):
            return "%s, %s" % (self.user.last_name, self.user.first_name)
        return "%s (deactivated)" % self.name
class ProductionStream(models.Model):
    submission = models.OneToOneField(
        "submissions.Submission",
        on_delete=models.CASCADE,
        related_name="production_stream",
    )
Jorran de Wit's avatar
Jorran de Wit committed
    opened = models.DateTimeField(auto_now_add=True)
    closed = models.DateTimeField(default=timezone.now)
    status = models.CharField(
        max_length=32,
        choices=PRODUCTION_STREAM_STATUS,
        default=PRODUCTION_STREAM_INITIATED,
    )

    officer = models.ForeignKey(
        "production.ProductionUser",
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        related_name="streams",
    )
    supervisor = models.ForeignKey(
        "production.ProductionUser",
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        related_name="supervised_streams",
    )
    invitations_officer = models.ForeignKey(
        "production.ProductionUser",
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        related_name="invitations_officer_streams",
    )

    work_logs = GenericRelation(WorkLog, related_query_name="streams")
Jorran de Wit's avatar
Jorran de Wit committed
    objects = ProductionStreamQuerySet.as_manager()
    class Meta:
        permissions = (
            ("can_work_for_stream", "Can work for stream"),
            ("can_perform_supervisory_actions", "Can perform supervisory actions"),
        return "{arxiv}, {title}".format(
            arxiv=self.submission.preprint.identifier_w_vn_nr,
            title=self.submission.title,
        )
    def get_absolute_url(self):
        return reverse("production:stream", args=(self.id,))
    @cached_property
    def total_duration(self):
        totdur = self.work_logs.aggregate(models.Sum("duration"))
        return totdur["duration__sum"]
    @cached_property
    def completed(self):
        return self.status == PRODUCTION_STREAM_COMPLETED

Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def latest_activity(self):
        if self.events.last():
            return self.events.last().noted_on
        return self.closed or self.opened


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)
    comments = models.TextField(blank=True, null=True)
Jorran de Wit's avatar
Jorran de Wit committed
    noted_on = models.DateTimeField(default=timezone.now)
    noted_by = models.ForeignKey("production.ProductionUser", on_delete=models.CASCADE, related_name="events")
    noted_to = models.ForeignKey(
        "production.ProductionUser",
        on_delete=models.CASCADE,
        blank=True,
        null=True,
        related_name="received_events",
    )
    duration = models.DurationField(blank=True, null=True)

    objects = ProductionEventManager()

        ordering = ["noted_on"]
        return "%s: %s" % (self.stream, self.get_event_display())

    def get_absolute_url(self):
        return self.stream.get_absolute_url()

    @cached_property
    def editable(self):
        return self.event in [EVENT_MESSAGE, EVENT_HOUR_REGISTRATION] and not self.stream.completed
def production_event_upload_location(instance, filename):
    submission = instance.production_event.stream.submission
    return "UPLOADS/PRODSTREAMS/{year}/{thread_hash_head}/{filename}".format(
        year=submission.submission_date.year,
        thread_hash_head=str(submission.thread_hash).partition("-")[0],
        filename=filename,
    )


class ProductionEventAttachment(models.Model):
    """
    An ProductionEventAttachment is in general used by authors to reply to a Proofs version
    with their version of the Proofs with comments.
    """

    production_event = models.ForeignKey(
        "production.ProductionEvent",
        on_delete=models.CASCADE,
        related_name="attachments",
    )
    attachment = models.FileField(upload_to=production_event_upload_location, storage=SecureFileStorage())

    def get_absolute_url(self):
        return reverse(
            "production:production_event_attachment_pdf",
            args=(
                self.production_event.stream.id,
                self.id,
            ),
        )
Jorran de Wit's avatar
Jorran de Wit committed
def proofs_upload_location(instance, filename):
    submission = instance.stream.submission
    return "UPLOADS/PROOFS/{year}/{thread_hash_head}/{filename}".format(
Jorran de Wit's avatar
Jorran de Wit committed
        year=submission.submission_date.year,
        thread_hash_head=str(submission.thread_hash).partition("-")[0],
        filename=filename,
    )
Jorran de Wit's avatar
Jorran de Wit committed
class Proofs(models.Model):
Jorran de Wit's avatar
Jorran de Wit committed
    """
Jorran de Wit's avatar
Jorran de Wit committed
    Proofs are directly related to a ProductionStream and Submission in SciPost.
Jorran de Wit's avatar
Jorran de Wit committed
    """
    attachment = models.FileField(upload_to=proofs_upload_location, storage=SecureFileStorage())
Jorran de Wit's avatar
Jorran de Wit committed
    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="+")
Jorran de Wit's avatar
Jorran de Wit committed
    created = models.DateTimeField(auto_now_add=True)
    status = models.CharField(max_length=16, choices=PROOFS_STATUSES, default=PROOFS_UPLOADED)
Jorran de Wit's avatar
Jorran de Wit committed
    accessible_for_authors = models.BooleanField(default=False)

Jorran de Wit's avatar
Jorran de Wit committed
    objects = ProofsQuerySet.as_manager()

Jorran de Wit's avatar
Jorran de Wit committed
    class Meta:
        ordering = ["stream", "version"]
        verbose_name_plural = "Proofs"
Jorran de Wit's avatar
Jorran de Wit committed

    def get_absolute_url(self):
        return reverse("production:proofs_pdf", kwargs={"slug": self.slug})
Jorran de Wit's avatar
Jorran de Wit committed
    def __str__(self):
        return "Proofs {version} for Stream {stream}".format(version=self.version, stream=self.stream.submission.title)
Jorran de Wit's avatar
Jorran de Wit committed

Jorran de Wit's avatar
Jorran de Wit committed
    def save(self, *args, **kwargs):
        # Control Report count per Submission.
        if not self.version:
            self.version = self.stream.proofs.count() + 1
        return super().save(*args, **kwargs)

    @property
    def slug(self):
Jorran de Wit's avatar
Jorran de Wit committed
        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_abbrev(self) -> str:
        # The DOI label is used to determine the path of the repository and template
        return self.stream.submission.editorial_decision.for_journal.doi_label

    @property
    def journal_subdivision(self) -> str:
        """
        Return the subdivision of the repository depending on the journal type.
        Regular journals are subdivided per year and month,
        while proceedings are subdivided per year and conference.
        """

        # TODO: Removing the whitespace should be more standardised
        # Refactor: journal and year are common to both cases
        # perhaps it is best to only return the subdivision month/conference
        if proceedings_issue := self.stream.submission.proceedings:
            return "{journal}/{year}/{conference}".format(
                journal=self.journal_abbrev,
                year=self.stream.submission.proceedings.event_end_date.year,
                conference=proceedings_issue.event_suffix.replace(" ", ""),
            )
        else:
            # 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.
            opened_year, opened_month = self.stream.opened.strftime("%Y-%m").split("-")

            return "{journal}/{year}/{month}".format(
                journal=self.journal_abbrev,
                year=opened_year,
                month=opened_month,
            )

    @property
    def git_path(self) -> str:
        return "{ROOT}/Proofs/{journal_subdivision}/{repo_name}".format(
            ROOT=settings.GITLAB_ROOT,
            journal_subdivision=self.journal_subdivision,
            repo_name=self.name,
        )

    @property
    def git_url(self) -> str:
        return "https://{GITLAB_URL}/{git_path}".format(
            GITLAB_URL=settings.GITLAB_URL,
            git_path=self.git_path,
        )

    @property
    def git_ssh_clone_url(self) -> str:
        return "git:{GITLAB_URL}/{git_path}.git".format(
            GITLAB_URL=settings.GITLAB_URL,
            git_path=self.git_path,
        )

    @property
    def template_path(self) -> str:
        """
        Return the path to the template repository.
        """
        if self.stream.submission.proceedings is not None:
            return "{ROOT}/Templates/{journal_subdivision}".format(
                ROOT=settings.GITLAB_ROOT,
                journal_subdivision=self.journal_subdivision,
            )
        else:
            return "{ROOT}/Templates/{journal}".format(
                ROOT=settings.GITLAB_ROOT,
                journal=self.journal_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):
    """
    When a ProductionStream instance is created, a ProofsRepository 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)


@receiver(post_save, sender=ProofsRepository)
def advance_repo_on_gitlab(sender, instance, created, **kwargs):
    """
    When a ProofsRepository instance is created, run the advance_git_repos command on it.
    """
    if created:
        from django.core import management

        management.call_command(
            "advance_git_repos",
            id=instance.stream.submission.preprint.identifier_w_vn_nr,
        )


post_save.connect(advance_repo_on_gitlab, sender=ProofsRepository)