From 129972553189b46ad7a5a7839fdd919e9047434f Mon Sep 17 00:00:00 2001
From: George Katsikas <giorgakis.katsikas@gmail.com>
Date: Fri, 31 Jan 2025 17:44:16 +0100
Subject: [PATCH] =?UTF-8?q?feat(graphs):=20=E2=9C=A8=20add=20many=20model?=
 =?UTF-8?q?=20field=20plotters=20and=20fields?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 scipost_django/graphs/graphs/plotter.py | 228 +++++++++++++++++++++---
 1 file changed, 208 insertions(+), 20 deletions(-)

diff --git a/scipost_django/graphs/graphs/plotter.py b/scipost_django/graphs/graphs/plotter.py
index 04b4d54b0..48ac57b55 100644
--- a/scipost_django/graphs/graphs/plotter.py
+++ b/scipost_django/graphs/graphs/plotter.py
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
 from django import forms
 from django.db import models
 from django.db.models.functions import Coalesce, Concat, ExtractDay
+from django.utils.timezone import datetime
 from matplotlib.figure import Figure
 import matplotlib.pyplot as plt
 
@@ -21,7 +22,7 @@ from submissions.models import Report, Submission
 from submissions.models.decision import EditorialDecision
 from submissions.models.recommendation import EICRecommendation
 from submissions.models.referee_invitation import RefereeInvitation
-from submissions.models.submission import SubmissionAuthorProfile
+from submissions.models.submission import SubmissionAuthorProfile, SubmissionEvent
 
 from .options import BaseOptions
 
@@ -146,10 +147,10 @@ class PublicationPlotter(ModelFieldPlotter):
     name = "Publication"
 
     class Options(ModelFieldPlotter.Options):
-        journals = forms.ModelChoiceField(
+        journals = forms.ModelMultipleChoiceField(
             queryset=Journal.objects.all().active(), required=False
         )
-        collections = forms.ModelChoiceField(
+        collections = forms.ModelMultipleChoiceField(
             queryset=Collection.objects.all(), required=False
         )
         model_fields = ModelFieldPlotter.Options.model_fields + (
@@ -169,13 +170,8 @@ class PublicationPlotter(ModelFieldPlotter):
         )
 
     def get_queryset(self):
-        qs = (
-            super()
-            .get_queryset()
-            .filter(
-                pubtype="article",
-            )
-        )
+        qs = super().get_queryset()
+        qs = qs.filter(pubtype="article")
 
         qs = qs.annotate(
             acceptance_duration=ExtractDay(
@@ -197,10 +193,10 @@ class PublicationPlotter(ModelFieldPlotter):
             ),
         )
 
-        if journal := self.options.get("journals", None):
-            qs = qs.for_journal(journal.name)
+        if journals := self.options.get("journals", None):
+            qs = qs.for_journals(journals)
         if collections := self.options.get("collections", None):
-            qs = qs.filter(collections__in=[collections])
+            qs = qs.filter(collections__in=collections)
 
         return qs
 
@@ -246,6 +242,23 @@ class SubmissionsPlotter(ModelFieldPlotter):
             ("nr_invitations", ("int", "Number of invitations")),
             ("nr_reports", ("int", "Number of reports")),
             ("report_turnover", ("float", "Report turnover")),
+            ("preassignment_completed_date", ("date", "Preassignment completed date")),
+            ("editor_first_assigned_date", ("date", "Editor first assigned date")),
+            ("first_referee_invited_date", ("date", "First referee invited date")),
+            ("withdrawal_date", ("date", "Withdrawal date")),
+            (
+                "preassignment_completed_duration",
+                ("int", "Preassignment duration (days)"),
+            ),
+            ("eic_assignment_duration", ("int", "EIC assignment duration (days)")),
+            (
+                "first_referee_invitation_submission_duration",
+                ("int", "Submission to first ref. invitation sent (days)"),
+            ),
+            (
+                "first_referee_invitation_assignment_duration",
+                ("int", "EIC assignment to first ref. invitation sent (days)"),
+            ),
         )
 
     def get_queryset(self):
@@ -263,11 +276,30 @@ class SubmissionsPlotter(ModelFieldPlotter):
             qs = qs.filter(specialties__in=specialties)
 
         qs = qs.annotate(
-            submission_date__year_month=Concat(
-                models.F("submission_date__year"),
-                models.Value("-"),
-                models.F("submission_date__month"),
-                output_field=models.CharField(),
+            preassignment_completed_date=models.Subquery(
+                SubmissionEvent.objects.filter(
+                    submission=models.OuterRef("id"),
+                    text__regex=r"Submission (passed|failed) pre(-screening|assignment)\.",
+                ).values("created")[:1]
+            ),
+            editor_first_assigned_date=models.Subquery(
+                SubmissionEvent.objects.filter(
+                    submission=models.OuterRef("id"),
+                    text="The Editor-in-charge has been assigned.",
+                ).values("created")[:1]
+            ),
+            first_referee_invited_date=models.Subquery(
+                RefereeInvitation.objects.filter(
+                    submission=models.OuterRef("id"),
+                )
+                .order_by("date_invited")[:1]
+                .values("date_invited")
+            ),
+            withdrawal_date=models.Subquery(
+                SubmissionEvent.objects.filter(
+                    submission=models.OuterRef("id"),
+                    text__contains="withdrawn by the authors",
+                ).values("created")[:1]
             ),
             nr_invitations=Coalesce(
                 models.Subquery(
@@ -295,6 +327,20 @@ class SubmissionsPlotter(ModelFieldPlotter):
                 default=models.F("nr_reports") / models.F("nr_invitations"),
                 output_field=models.FloatField(),
             ),
+            preassignment_completed_duration=ExtractDay(
+                models.F("preassignment_completed_date") - models.F("submission_date")
+            ),
+            eic_assignment_duration=ExtractDay(
+                models.F("editor_first_assigned_date")
+                - models.F("preassignment_completed_date")
+            ),
+            first_referee_invitation_submission_duration=ExtractDay(
+                models.F("first_referee_invited_date") - models.F("submission_date")
+            ),
+            first_referee_invitation_assignment_duration=ExtractDay(
+                models.F("first_referee_invited_date")
+                - models.F("editor_first_assigned_date")
+            ),
         )
 
         match self.options.get("per_thread", None):
@@ -392,6 +438,22 @@ class FellowshipPlotter(ModelFieldPlotter):
     model = Fellowship
 
     class Options(ModelFieldPlotter.Options):
+        college = Fellowship._meta.get_field("college").formfield(required=False)
+        status = Fellowship._meta.get_field("status").formfield(
+            required=False,
+            choices=((None, "All"),) + Fellowship.STATUS_CHOICES,
+        )
+
+        active = forms.ChoiceField(
+            required=False,
+            label="Active",
+            choices=[
+                ("all", "All"),
+                ("active", "Active"),
+                ("inactive", "Inactive"),
+            ],
+        )
+
         model_fields = ModelFieldPlotter.Options.model_fields + (
             ("latest_affiliation_country", ("country", "Latest affiliation country")),
             ("latest_affiliation_name", ("str", "Latest affiliation name")),
@@ -405,7 +467,7 @@ class FellowshipPlotter(ModelFieldPlotter):
     def get_queryset(self) -> models.QuerySet[Fellowship]:
         qs = super().get_queryset()
 
-        return qs.annotate(
+        qs = qs.annotate(
             latest_affiliation_country=models.Subquery(
                 Affiliation.objects.filter(
                     profile=models.OuterRef("contributor__profile")
@@ -422,6 +484,33 @@ class FellowshipPlotter(ModelFieldPlotter):
             ),
         )
 
+        if college := self.options.get("college", None):
+            qs = qs.filter(college=college)
+
+        if status := self.options.get("status", None):
+            qs = qs.filter(status=status)
+
+        now = datetime.today()
+        match self.options.get("active", "all"):
+            case "active":
+                qs = qs.filter(start_date__lte=now, until_date__gte=now)
+            case "inactive":
+                qs = qs.filter(
+                    models.Q(start_date__gt=now) | models.Q(until_date__lt=now)
+                )
+            case "all":
+                pass
+
+        return qs
+
+    @classmethod
+    def get_plot_options_form_layout_row_content(cls):
+        return Layout(
+            Div(Field("college"), css_class="col-12"),
+            Div(Field("status"), css_class="col-6"),
+            Div(Field("active"), css_class="col-6"),
+        )
+
 
 class PubFracPlotter(ModelFieldPlotter):
     model = PubFrac
@@ -498,6 +587,9 @@ class ReportPlotter(ModelFieldPlotter):
     model = Report
 
     class Options(ModelFieldPlotter.Options):
+        submission_journals = forms.ModelMultipleChoiceField(
+            queryset=Journal.objects.all().active(), required=False
+        )
         model_fields = ModelFieldPlotter.Options.model_fields + (
             ("date_submitted", ("date", "Report date")),
             ("date_submitted__year", ("int", "Year of report")),
@@ -519,7 +611,7 @@ class ReportPlotter(ModelFieldPlotter):
     def get_queryset(self) -> models.QuerySet[Report]:
         qs = super().get_queryset()
 
-        return qs.annotate(
+        qs = qs.annotate(
             latest_affiliation_country=models.Subquery(
                 Affiliation.objects.filter(profile=models.OuterRef("author"))
                 .order_by("-date_from")
@@ -544,6 +636,11 @@ class ReportPlotter(ModelFieldPlotter):
             ),
         )
 
+        if journals := self.options.get("submission_journals", None):
+            qs = qs.filter(submission__submitted_to__in=journals)
+
+        return qs
+
 
 class SubsidyPlotter(ModelFieldPlotter):
     model = Subsidy
@@ -593,6 +690,9 @@ class EditorialDecisionPlotter(ModelFieldPlotter):
                 ("False", "No (Target)"),
             ],
         )
+        submission_journals = forms.ModelMultipleChoiceField(
+            queryset=Journal.objects.all().active(), required=False
+        )
         model_fields = ModelFieldPlotter.Options.model_fields + (
             ("taken_on", ("date", "Decision date")),
             ("for_journal__name", ("str", "Decision journal")),
@@ -637,4 +737,92 @@ class EditorialDecisionPlotter(ModelFieldPlotter):
         if decision := self.options.get("decision", None):
             qs = qs.filter(decision=decision)
 
+        if journals := self.options.get("submission_journals", None):
+            qs = qs.filter(submission__submitted_to__in=journals)
+
         return qs
+
+    @classmethod
+    def get_plot_options_form_layout_row_content(cls):
+        return Layout(
+            Div(Field("status"), css_class="col-12"),
+            Div(Field("decision"), css_class="col-6"),
+            Div(Field("is_alternative"), css_class="col-6"),
+        )
+
+
+class RefereeInvitationPlotter(ModelFieldPlotter):
+    model = RefereeInvitation
+
+    class Options(ModelFieldPlotter.Options):
+        accepted = forms.ChoiceField(
+            required=False,
+            label="Response",
+            choices=[
+                ("any", "Any"),
+                (None, "Pending"),
+                (True, "Accepted"),
+                (False, "Declined"),
+                ("responded", "Accepted or declined"),
+            ],
+        )
+        fulfilled = forms.ChoiceField(
+            required=False,
+            label="Fulfilled",
+            choices=[
+                ("any", "Any"),
+                (True, "Fulfilled"),
+                (False, "Not fulfilled"),
+            ],
+        )
+        cancelled = forms.ChoiceField(
+            required=False,
+            label="Cancelled",
+            choices=[
+                ("any", "Any"),
+                (True, "Cancelled"),
+                (False, "Not cancelled"),
+            ],
+        )
+        model_fields = ModelFieldPlotter.Options.model_fields + (
+            ("date_invited", ("date", "Invitation date")),
+            ("date_responded", ("date", "Response date")),
+            ("intended_delivery_date", ("date", "Intended delivery date")),
+            ("accepted", ("str", "Accepted")),
+            ("refusal_reason", ("str", "Refusal reason")),
+            ("auto_reminders_allowed", ("str", "Auto reminders allowed")),
+            ("nr_reminders", ("int", "Number of reminders")),
+            ("date_last_reminded", ("date", "Last reminded")),
+            ("fulfilled", ("str", "Fulfilled")),
+            ("cancelled", ("str", "Cancelled")),
+            ("report_response_duration", ("int", "Report response duration (days)")),
+            ("has_responded_int", ("int", "Has responded")),
+            ("has_delivered_int", ("int", "Has delivered")),
+        )
+
+    def get_queryset(self) -> models.QuerySet[Any]:
+        qs = super().get_queryset()
+
+        qs = qs.annotate(
+            report_response_duration=ExtractDay(
+                models.F("date_responded") - models.F("date_invited")
+            ),
+            has_responded_int=models.Q(date_responded__isnull=False),
+            has_delivered_int=models.Q(fulfilled=True),
+        )
+
+        accepted = self.options.get("accepted", "any")
+        if accepted == "responded":
+            qs = qs.filter(accepted__isnull=False)
+        elif accepted != "any":
+            qs = qs.filter(accepted=accepted)
+
+        return qs
+
+    @classmethod
+    def get_plot_options_form_layout_row_content(cls):
+        return Layout(
+            Div(Field("accepted"), css_class="col-12"),
+            Div(Field("fulfilled"), css_class="col-6"),
+            Div(Field("cancelled"), css_class="col-6"),
+        )
-- 
GitLab