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