From 66e8c1af56fb9ecc99274c7d8adea859ec717cbb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jean-S=C3=A9bastien=20Caux?= <git@jscaux.org>
Date: Thu, 19 Jan 2023 06:00:49 +0100
Subject: [PATCH] Preassignment: match submission authors to their Profile

---
 .../_hx_submission_tab_contents_edadmin.html  |   2 +
 .../preassignment/_hx_author_profile_row.html |  58 ++++++++
 .../_hx_author_profiles_details_contents.html |  28 ++++
 .../_hx_author_profiles_details_summary.html  |   3 +
 .../_submission_preassignment.html            |  27 ++++
 scipost_django/edadmin/urls/preassignment.py  |  38 +++++
 scipost_django/edadmin/views/preassignment.py | 136 ++++++++++++++++++
 scipost_django/profiles/forms.py              |   2 +
 .../profiles/_hx_profile_dynsel_list.html     |   1 +
 scipost_django/profiles/views.py              |   1 +
 scipost_django/submissions/admin.py           |  11 ++
 .../0135_submissionauthorprofile.py           |  29 ++++
 ...6_alter_submissionauthorprofile_profile.py |  20 +++
 scipost_django/submissions/models/__init__.py |   7 +-
 .../submissions/models/submission.py          |  41 ++++++
 15 files changed, 403 insertions(+), 1 deletion(-)
 create mode 100644 scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profile_row.html
 create mode 100644 scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profiles_details_contents.html
 create mode 100644 scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profiles_details_summary.html
 create mode 100644 scipost_django/edadmin/templates/edadmin/preassignment/_submission_preassignment.html
 create mode 100644 scipost_django/edadmin/urls/preassignment.py
 create mode 100644 scipost_django/edadmin/views/preassignment.py
 create mode 100644 scipost_django/submissions/migrations/0135_submissionauthorprofile.py
 create mode 100644 scipost_django/submissions/migrations/0136_alter_submissionauthorprofile_profile.py

diff --git a/scipost_django/edadmin/templates/edadmin/_hx_submission_tab_contents_edadmin.html b/scipost_django/edadmin/templates/edadmin/_hx_submission_tab_contents_edadmin.html
index 9aff7d372..afd9e3910 100644
--- a/scipost_django/edadmin/templates/edadmin/_hx_submission_tab_contents_edadmin.html
+++ b/scipost_django/edadmin/templates/edadmin/_hx_submission_tab_contents_edadmin.html
@@ -1,4 +1,6 @@
 <h1>Editorial administration</h1>
 {% if submission.in_stage_incoming %}
   {% include "edadmin/incoming/_submission_incoming.html" with submission=submission %}
+{% elif submission.in_stage_preassignment %}
+  {% include "edadmin/preassignment/_submission_preassignment.html" with submission=submission %}
 {% endif %}
diff --git a/scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profile_row.html b/scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profile_row.html
new file mode 100644
index 000000000..847b1a1e4
--- /dev/null
+++ b/scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profile_row.html
@@ -0,0 +1,58 @@
+<tr id="submission-{{ submission.pk }}-author-profile-row-{{ order }}"
+    class="{% if profile %}bg-success{% else %}bg-warning{% endif %} bg-opacity-10"
+>
+  <td>{{ author_string }}</td>
+  <td>{{ order }}</td>
+  <td>
+    {{ profile }}
+  </td>
+  <td>
+    {% if profile %}
+      <button class="ms-4 px-1 py-0 btn btn-small btn-danger text-white"
+	      hx-get="{% url 'edadmin:preassignment:_hx_author_profile_action' identifier_w_vn_nr=submission.preprint.identifier_w_vn_nr order=order profile_id=profile.pk action='unmatch' %}"
+	      hx-target="#submission-{{ submission.pk }}-author-profile-row-{{ order }}"
+	      hx-swap="outerHTML"
+      >
+	{% include 'bi/trash-fill.html' %}
+      </button>
+    {% else %}
+      {% load crispy_forms_tags %}
+      <div class="row mb-0">
+	<div class="col-9">
+	  <form
+	      hx-post="{% url 'profiles:_hx_profile_dynsel_list' %}"
+	      hx-trigger="load, keyup delay:200ms, change"
+	      hx-target="#submission-{{ submission.id }}-profile-{{ order }}-dynsel-results"
+	      hx-swap="innerHTML"
+	      hx-indicator="#submission-{{ submission.id }}-profile-{{ order }}-dynsel-results-indicator"
+	  >
+	    <div id="profile_{{ order }}_dynsel_form">{% crispy profile_dynsel_form %}</div>
+	  </form>
+	</div>
+	<div class="col-3">
+	  <div id="submission-{{ submission.id }}-profile-{{ order }}-dynsel-results-indicator" class="htmx-indicator">
+	    <button class="btn btn-sm btn-warning" type="button" disabled>
+	      <strong>Loading results...</strong>
+	      <div class="spinner-grow spinner-grow-sm ms-2" role="status" aria-hidden="true"></div>
+	    </button>
+	  </div>
+	</div>
+      </div>
+      <div class="row mb-0">
+	<div class="col-9">
+	  <div id="submission-{{ submission.id }}-profile-{{ order }}-dynsel-results" class="border border-light m-0 p-1"></div>
+	</div>
+	<div class="col-3">
+	  <div id="submission-{{ submission.pk }}-author-profile-row-{{ order }}-indicator"
+	       class="htmx-indicator"
+	  >
+	    <button class="btn btn-sm btn-warning" type="button" disabled>
+	      <strong>Loading...</strong>
+	      <div class="spinner-grow spinner-grow-sm ms-2" role="status" aria-hidden="true"></div>
+	    </button>
+	  </div>
+	</div>
+      </div>
+    {% endif %}
+  </td>
+</tr>
diff --git a/scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profiles_details_contents.html b/scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profiles_details_contents.html
new file mode 100644
index 000000000..48f888c61
--- /dev/null
+++ b/scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profiles_details_contents.html
@@ -0,0 +1,28 @@
+<table class="table table-bordered">
+  <thead>
+    <tr>
+      <th>Name (from author list)</th>
+      <th>Order</th>
+      <th>Matched Profile</th>
+      <th>Candidate Profiles</th>
+    </tr>
+  </thead>
+  <tbody>
+    {% for item in matches_list %}
+      <tr id="submission-{{ submission.pk }}-author-profile-row-{{ item.1 }}"
+	  class="{% if item.2 %}bg-success{% else %}bg-warning{% endif %} bg-opacity-10"
+	  hx-get="{% url 'edadmin:preassignment:_hx_author_profile_row' identifier_w_vn_nr=submission.preprint.identifier_w_vn_nr order=item.1 %}"
+	  hx-swap="outerHTML"
+	  hx-trigger="revealed"
+      >
+      </tr>
+    {% empty %}
+      <tr>
+	<td colspan="4">None found</td>
+      </tr>
+    {% endfor %}
+  </tbody>
+</table>
+
+<h3 class="mb-2">Needed Profiles not found?</h3>
+<p>Then add to our database by <a href="{% url 'profiles:profile_create' %}" target="_blank">creating a new Profile</a> (opens in new window).</p>
diff --git a/scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profiles_details_summary.html b/scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profiles_details_summary.html
new file mode 100644
index 000000000..dd88f1db1
--- /dev/null
+++ b/scipost_django/edadmin/templates/edadmin/preassignment/_hx_author_profiles_details_summary.html
@@ -0,0 +1,3 @@
+{% with submission.authors_as_list|length as nr_authors %}
+  <h2>Author Profiles <small class="{% if matches == nr_authors %}bg-success{% else %}bg-warning{% endif %} ms-4 p-1 text-white">matched: {{ matches }} out of {{ nr_authors }}</small></h2>
+{% endwith %}
diff --git a/scipost_django/edadmin/templates/edadmin/preassignment/_submission_preassignment.html b/scipost_django/edadmin/templates/edadmin/preassignment/_submission_preassignment.html
new file mode 100644
index 000000000..4cf353aef
--- /dev/null
+++ b/scipost_django/edadmin/templates/edadmin/preassignment/_submission_preassignment.html
@@ -0,0 +1,27 @@
+<details id="submission-{{ submission.id }}-author-profiles-details"
+	 class="border border-2"
+>
+  <summary class="bg-primary bg-opacity-10 p-2">
+    <span id="submission-{{ submission.pk }}-author-profiles-details-summary"
+	  hx-get="{% url 'edadmin:preassignment:_hx_author_profiles_details_summary' identifier_w_vn_nr=submission.preprint.identifier_w_vn_nr %}"
+	  hx-trigger="load, submission-{{ submission.pk }}-author-profiles-details-updated from:body"
+    >
+    </span>
+  </summary>
+
+  <div id="submission-{{ submission.pk }}-author-profiles-details-contents"
+       class="p-2"
+       hx-get="{% url 'edadmin:preassignment:_hx_author_profiles_details_contents' identifier_w_vn_nr=submission.preprint.identifier_w_vn_nr %}"
+       hx-trigger="toggle once from:#submission-{{ submission.pk }}-author-profiles-details"
+  >
+  </div>
+  <div id="submission-{{ submission.pk }}-author-profiles-details-contents-indicator"
+       class="htmx-indicator"
+  >
+    <button class="btn btn-sm btn-warning" type="button" disabled>
+      <strong>Loading form...</strong>
+      <div class="spinner-grow spinner-grow-sm ms-2" role="status" aria-hidden="true"></div>
+    </button>
+  </div>
+
+</details>
diff --git a/scipost_django/edadmin/urls/preassignment.py b/scipost_django/edadmin/urls/preassignment.py
new file mode 100644
index 000000000..cb556b86f
--- /dev/null
+++ b/scipost_django/edadmin/urls/preassignment.py
@@ -0,0 +1,38 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.urls import include, path
+
+from ..views import preassignment
+
+app_name = "preassignment"
+
+
+urlpatterns = [ # building on /edadmin/preassigmnent/
+    path( # <identifier>/
+        "<identifier:identifier_w_vn_nr>/",
+        include([
+            path( # /edadmin/preassignment/<identifier>/author_profiles
+                "author_profiles_details_summary",
+                preassignment._hx_author_profiles_details_summary,
+                name="_hx_author_profiles_details_summary",
+            ),
+            path( # /edadmin/preassignment/<identifier>/author_profiles
+                "author_profiles_details_contents",
+                preassignment._hx_author_profiles_details_contents,
+                name="_hx_author_profiles_details_contents",
+            ),
+            path( # /edadmin/preassignment/<identifier>/author_profile_row/<order>
+                "author_profile_row/<int:order>",
+                preassignment._hx_author_profile_row,
+                name="_hx_author_profile_row",
+            ),
+            path( # /edadmin/preassignment/<identifier>/author_profile_dynsel
+                "author_profile_action/<int:order>/<int:profile_id>/<slug:action>",
+                preassignment._hx_author_profile_action,
+                name="_hx_author_profile_action",
+            ),
+        ])
+    ),
+]
diff --git a/scipost_django/edadmin/views/preassignment.py b/scipost_django/edadmin/views/preassignment.py
new file mode 100644
index 000000000..c6785e88d
--- /dev/null
+++ b/scipost_django/edadmin/views/preassignment.py
@@ -0,0 +1,136 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.contrib.auth.decorators import login_required, user_passes_test
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, render, redirect
+from django.urls import reverse
+
+from colleges.permissions import is_edadmin
+from profiles.models import Profile
+from profiles.forms import ProfileDynSelForm
+from submissions.models import Submission, SubmissionAuthorProfile
+
+
+###########################
+# Author Profile matching #
+###########################
+@login_required
+@user_passes_test(is_edadmin)
+def _hx_author_profiles_details_summary(request, identifier_w_vn_nr):
+    submission = get_object_or_404(
+        Submission, preprint__identifier_w_vn_nr=identifier_w_vn_nr
+    )
+    matches = submission.author_profiles.exclude(profile__isnull=True).count()
+    context = {
+        "submission": submission,
+        "matches": matches,
+    }
+    return render(
+        request,
+        "edadmin/preassignment/_hx_author_profiles_details_summary.html",
+        context,
+    )
+
+
+@login_required
+@user_passes_test(is_edadmin)
+def _hx_author_profiles_details_contents(request, identifier_w_vn_nr):
+    submission = get_object_or_404(
+        Submission, preprint__identifier_w_vn_nr=identifier_w_vn_nr
+    )
+    matches_list = [ (
+        author_string,
+        index + 1,
+        submission.author_profiles.filter(
+            order=index + 1,
+            profile__isnull=False,
+        ).first(),
+    ) for index, author_string in enumerate(submission.authors_as_list) ]
+
+    context = {
+        "submission": submission,
+        "matches_list": matches_list,
+    }
+    return render(
+        request,
+        "edadmin/preassignment/_hx_author_profiles_details_contents.html",
+        context,
+    )
+
+
+@login_required
+@user_passes_test(is_edadmin)
+def _hx_author_profile_row(request, identifier_w_vn_nr, order: int):
+    submission = get_object_or_404(
+        Submission, preprint__identifier_w_vn_nr=identifier_w_vn_nr
+    )
+    author_string = submission.authors_as_list[order-1]
+    profile = submission.author_profiles.filter(
+            order=order,
+            profile__isnull=False,
+    ).first()
+    context = {
+        "submission": submission,
+        "author_string": author_string,
+        "order": order,
+        "profile": profile,
+    }
+    if profile is None:
+        profile_dynsel_form = ProfileDynSelForm(
+            initial={
+                "q": author_string.rpartition(". ")[2],
+                "action_url_name": "edadmin:preassignment:_hx_author_profile_action",
+                "action_url_base_kwargs": {
+                    "identifier_w_vn_nr": identifier_w_vn_nr,
+                    "order": order,
+                    "action": "match",
+                },
+                "action_target_element_id":
+                f"submission-{submission.pk}-author-profile-row-{order}",
+                "action_target_swap": "outerHTML",
+            }
+        )
+        context["profile_dynsel_form"] = profile_dynsel_form
+    response = render(
+        request,
+        "edadmin/preassignment/_hx_author_profile_row.html",
+        context,
+    )
+    response["HX-Trigger-After-Settle"] = f"submission-{submission.pk}-author-profiles-details-updated"
+    return response
+
+
+@login_required
+@user_passes_test(is_edadmin)
+def _hx_author_profile_action(
+        request,
+        identifier_w_vn_nr,
+        order,
+        profile_id,
+        action: str="match",
+):
+    submission = get_object_or_404(
+        Submission, preprint__identifier_w_vn_nr=identifier_w_vn_nr
+    )
+    profile = get_object_or_404(Profile, pk=profile_id)
+    author_profile, created = SubmissionAuthorProfile.objects.get_or_create(
+        submission=submission,
+        order=order,
+    )
+    if action == "match":
+        author_profile.profile = profile
+    elif action == "unmatch":
+        author_profile.profile = None
+    author_profile.save()
+    response = redirect(
+        reverse(
+            "edadmin:preassignment:_hx_author_profile_row",
+            kwargs={
+                "identifier_w_vn_nr": identifier_w_vn_nr,
+                "order": order,
+            }
+        )
+    )
+    return response
diff --git a/scipost_django/profiles/forms.py b/scipost_django/profiles/forms.py
index d720b08e4..c9f2f2183 100644
--- a/scipost_django/profiles/forms.py
+++ b/scipost_django/profiles/forms.py
@@ -229,6 +229,7 @@ class ProfileDynSelForm(forms.Form):
     action_url_name = forms.CharField()
     action_url_base_kwargs = forms.JSONField(required=False)
     action_target_element_id = forms.CharField()
+    action_target_swap = forms.CharField()
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -238,6 +239,7 @@ class ProfileDynSelForm(forms.Form):
             Field("action_url_name", type="hidden"),
             Field("action_url_base_kwargs", type="hidden"),
             Field("action_target_element_id", type="hidden"),
+            Field("action_target_swap", type="hidden"),
         )
 
     def search_results(self):
diff --git a/scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html b/scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html
index 1a850fd67..a665da83b 100644
--- a/scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html
+++ b/scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html
@@ -9,6 +9,7 @@
 	<a
 	    hx-get="{% profile_dynsel_action_url profile %}"
 	    hx-target="#{{ action_target_element_id }}"
+	    hx-swap="{{ action_target_swap }}"
 	    hx-indicator="#{{ action_target_element_id }}-indicator"
 	>
 	  {{ profile }}
diff --git a/scipost_django/profiles/views.py b/scipost_django/profiles/views.py
index 810cc7281..b82026056 100644
--- a/scipost_django/profiles/views.py
+++ b/scipost_django/profiles/views.py
@@ -350,6 +350,7 @@ def _hx_profile_dynsel_list(request):
             else {}
         ),
         "action_target_element_id": form.cleaned_data["action_target_element_id"],
+        "action_target_swap": form.cleaned_data["action_target_swap"],
     }
     return render(request, "profiles/_hx_profile_dynsel_list.html", context)
 
diff --git a/scipost_django/submissions/admin.py b/scipost_django/submissions/admin.py
index e7c927af9..dd910f0d7 100644
--- a/scipost_django/submissions/admin.py
+++ b/scipost_django/submissions/admin.py
@@ -9,6 +9,7 @@ from django import forms
 from guardian.admin import GuardedModelAdmin
 
 from submissions.models import (
+    SubmissionAuthorProfile,
     Submission,
     EditorialAssignment,
     RefereeInvitation,
@@ -73,6 +74,15 @@ class QualificationInline(admin.StackedInline):
     ]
 
 
+class SubmissionAuthorProfileInline(admin.TabularInline):
+    model = SubmissionAuthorProfile
+    extra = 0
+    autocomplete_fields = [
+        "profile",
+        "affiliations",
+    ]
+
+
 class SubmissionTieringInline(admin.StackedInline):
     model = SubmissionTiering
     extra = 0
@@ -123,6 +133,7 @@ class SubmissionAdmin(GuardedModelAdmin):
     inlines = [
         InternalPlagiarismAssessmentInline,
         iThenticatePlagiarismAssessmentInline,
+        SubmissionAuthorProfileInline,
         QualificationInline,
         SubmissionTieringInline,
     ]
diff --git a/scipost_django/submissions/migrations/0135_submissionauthorprofile.py b/scipost_django/submissions/migrations/0135_submissionauthorprofile.py
new file mode 100644
index 000000000..fe73d07a3
--- /dev/null
+++ b/scipost_django/submissions/migrations/0135_submissionauthorprofile.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.16 on 2023-01-18 08:14
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('profiles', '0035_alter_profile_title'),
+        ('organizations', '0019_auto_20220314_0723'),
+        ('submissions', '0134_rename_status_qualification_expertise_level'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SubmissionAuthorProfile',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('order', models.PositiveSmallIntegerField()),
+                ('affiliations', models.ManyToManyField(blank=True, to='organizations.Organization')),
+                ('profile', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='profiles.profile')),
+                ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='author_profiles', to='submissions.submission')),
+            ],
+            options={
+                'ordering': ('submission', 'order'),
+            },
+        ),
+    ]
diff --git a/scipost_django/submissions/migrations/0136_alter_submissionauthorprofile_profile.py b/scipost_django/submissions/migrations/0136_alter_submissionauthorprofile_profile.py
new file mode 100644
index 000000000..c69c30493
--- /dev/null
+++ b/scipost_django/submissions/migrations/0136_alter_submissionauthorprofile_profile.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.16 on 2023-01-18 08:24
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('profiles', '0035_alter_profile_title'),
+        ('submissions', '0135_submissionauthorprofile'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='submissionauthorprofile',
+            name='profile',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='profiles.profile'),
+        ),
+    ]
diff --git a/scipost_django/submissions/models/__init__.py b/scipost_django/submissions/models/__init__.py
index 0add63fac..5963d6b9b 100644
--- a/scipost_django/submissions/models/__init__.py
+++ b/scipost_django/submissions/models/__init__.py
@@ -2,7 +2,12 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
 __license__ = "AGPL v3"
 
 
-from .submission import Submission, SubmissionEvent, SubmissionTiering
+from .submission import (
+    SubmissionAuthorProfile,
+    Submission,
+    SubmissionEvent,
+    SubmissionTiering,
+)
 
 from .plagiarism_assessment import (
     PlagiarismAssessment,
diff --git a/scipost_django/submissions/models/submission.py b/scipost_django/submissions/models/submission.py
index 13cd9f55d..1626d20e9 100644
--- a/scipost_django/submissions/models/submission.py
+++ b/scipost_django/submissions/models/submission.py
@@ -41,6 +41,47 @@ from ..managers import SubmissionQuerySet, SubmissionEventQuerySet
 from ..refereeing_cycles import ShortCycle, DirectCycle, RegularCycle
 
 
+class SubmissionAuthorProfile(models.Model):
+
+    submission = models.ForeignKey(
+        "submissions.Submission",
+        on_delete=models.CASCADE,
+        related_name="author_profiles",
+    )
+    profile = models.ForeignKey(
+        "profiles.Profile", on_delete=models.PROTECT, blank=True, null=True,
+    )
+    affiliations = models.ManyToManyField("organizations.Organization", blank=True)
+    order = models.PositiveSmallIntegerField()
+
+    class Meta:
+        ordering = ("submission", "order",)
+
+    def __str__(self):
+        return str(self.profile)
+
+    def save(self, *args, **kwargs):
+        """Auto increment order number if not explicitly set."""
+        if not self.order:
+            self.order = self.submission.author_profiles.count() + 1
+        return super().save(*args, **kwargs)
+
+    @property
+    def is_registered(self):
+        """Check if author is registered at SciPost."""
+        return self.profile.contributor is not None
+
+    @property
+    def first_name(self):
+        """Return first name of author."""
+        return self.profile.first_name
+
+    @property
+    def last_name(self):
+        """Return last name of author."""
+        return self.profile.last_name
+
+
 class Submission(models.Model):
     """
     A Submission is a preprint sent to SciPost for consideration.
-- 
GitLab