From 60d875810b40a0fd70a692af399d43b40bd61c5c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jean-S=C3=A9bastien=20Caux?= <git@jscaux.org>
Date: Sat, 22 Jan 2022 20:34:57 +0100
Subject: [PATCH] Add htmx-driven `expected_authors` edadmin to Collection
 detail

---
 scipost_django/colleges/forms.py              |  1 -
 scipost_django/proceedings/views.py           |  2 +-
 scipost_django/profiles/forms.py              | 30 +++++++++++++
 .../profiles/_hx_profile_dynsel_list.html     | 18 ++++++++
 .../profiles/templatetags/profiles_extras.py  |  8 ++++
 scipost_django/profiles/urls.py               |  6 +++
 scipost_django/profiles/views.py              | 17 ++++++-
 .../_hx_collection_expected_authors.html      | 44 +++++++++++++++++++
 .../templates/series/collection_detail.html   | 37 ++++++----------
 scipost_django/series/urls.py                 | 32 ++++++++------
 scipost_django/series/views.py                | 41 ++++++++++++-----
 11 files changed, 184 insertions(+), 52 deletions(-)
 create mode 100644 scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html
 create mode 100644 scipost_django/series/templates/series/_hx_collection_expected_authors.html

diff --git a/scipost_django/colleges/forms.py b/scipost_django/colleges/forms.py
index 681235ccb..06f13cf2b 100644
--- a/scipost_django/colleges/forms.py
+++ b/scipost_django/colleges/forms.py
@@ -10,7 +10,6 @@ from django.db.models import Q
 from crispy_forms.helper import FormHelper
 from crispy_forms.layout import Layout, Field
 from crispy_bootstrap5.bootstrap5 import FloatingField
-
 from dal import autocomplete
 
 from proceedings.models import Proceedings
diff --git a/scipost_django/proceedings/views.py b/scipost_django/proceedings/views.py
index daf990851..dffd8a24a 100644
--- a/scipost_django/proceedings/views.py
+++ b/scipost_django/proceedings/views.py
@@ -88,7 +88,7 @@ def _hx_proceedings_fellowship_action(request, id, fellowship_id, action):
         if proceedings.submissions.filter(editor_in_charge=fellowship.contributor).exists():
             messages.error(
                 request,
-                f"Fellow {fellowship.contributor} is EiC for some Submissions; removal aborted."
+                f'Fellow {fellowship.contributor} is EiC for some Submissions; removal aborted.'
             )
         else:
             proceedings.fellowships.remove(fellowship)
diff --git a/scipost_django/profiles/forms.py b/scipost_django/profiles/forms.py
index 033e1c3ec..7a84157db 100644
--- a/scipost_django/profiles/forms.py
+++ b/scipost_django/profiles/forms.py
@@ -3,7 +3,11 @@ __license__ = "AGPL v3"
 
 
 from django import forms
+from django.db.models import Q
 
+from crispy_forms.helper import FormHelper
+from crispy_forms.layout import Layout, Field
+from crispy_bootstrap5.bootstrap5 import FloatingField
 from dal import autocomplete
 
 from common.forms import ModelChoiceFieldwithid
@@ -185,6 +189,32 @@ class ProfileSelectForm(forms.Form):
         help_text=('Start typing, and select from the popup.'),
     )
 
+
+class ProfileDynSelForm(forms.Form):
+    q = forms.CharField(max_length=32, label='Search (by name)')
+    action_url_name = forms.CharField()
+    action_url_base_kwargs = forms.JSONField()
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.helper = FormHelper()
+        self.helper.layout = Layout(
+            FloatingField('q', autocomplete='off'),
+            Field('action_url_name', type='hidden'),
+            Field('action_url_base_kwargs', type='hidden'),
+        )
+
+    def search_results(self):
+        if self.cleaned_data['q']:
+            profiles = Profile.objects.filter(
+                Q(last_name__icontains=self.cleaned_data['q']) |
+                Q(first_name__icontains=self.cleaned_data['q'])
+            ).distinct()
+            return profiles
+        else:
+            return Profile.objects.none()
+
+
 class AffiliationForm(forms.ModelForm):
     organization = forms.ModelChoiceField(
         queryset=Organization.objects.all(),
diff --git a/scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html b/scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html
new file mode 100644
index 000000000..d5f604391
--- /dev/null
+++ b/scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html
@@ -0,0 +1,18 @@
+{% load profiles_extras %}
+
+<ul class="list list-unstyled">
+  {% for profile in profiles|slice:":11" %}
+    {% if forloop.counter == 11 %}
+      <li>&emsp;...</li>
+    {% else  %}
+      <li class="m-1">
+	<a
+	    hx-get="{% profile_dynsel_action_url profile %}"
+	    hx-target="#profiles"
+	>
+	  {{ profile }}
+	</a>
+      </li>
+    {% endif %}
+  {% endfor %}
+</ul>
diff --git a/scipost_django/profiles/templatetags/profiles_extras.py b/scipost_django/profiles/templatetags/profiles_extras.py
index 0f00a482e..02a93fea9 100644
--- a/scipost_django/profiles/templatetags/profiles_extras.py
+++ b/scipost_django/profiles/templatetags/profiles_extras.py
@@ -3,6 +3,7 @@ __license__ = "AGPL v3"
 
 
 from django import template
+from django.urls import reverse
 
 from ..models import get_profiles as profiles_get_profiles
 
@@ -12,3 +13,10 @@ register = template.Library()
 @register.simple_tag
 def get_profiles(slug):
     return profiles_get_profiles(slug)
+
+
+@register.simple_tag(takes_context=True)
+def profile_dynsel_action_url(context, profile):
+    kwargs = context['action_url_base_kwargs']
+    kwargs['profile_id'] = profile.id
+    return reverse(context['action_url_name'], kwargs=kwargs)
diff --git a/scipost_django/profiles/urls.py b/scipost_django/profiles/urls.py
index 70108dc9f..bad969474 100644
--- a/scipost_django/profiles/urls.py
+++ b/scipost_django/profiles/urls.py
@@ -42,6 +42,12 @@ urlpatterns = [
         name='profiles'
     ),
 
+    path(
+        '_hx_profile_dynsel_list',
+        views._hx_profile_dynsel_list,
+        name='_hx_profile_dynsel_list'
+    ),
+
     # Instance CBVs
     path(
         '<int:pk>/', include([
diff --git a/scipost_django/profiles/views.py b/scipost_django/profiles/views.py
index 5e3597a2a..a5f695e88 100644
--- a/scipost_django/profiles/views.py
+++ b/scipost_django/profiles/views.py
@@ -26,7 +26,7 @@ from invitations.models import RegistrationInvitation
 from submissions.models import RefereeInvitation
 
 from .models import Profile, ProfileEmail, Affiliation
-from .forms import ProfileForm, ProfileMergeForm, ProfileEmailForm, AffiliationForm
+from .forms import ProfileForm, ProfileDynSelForm, ProfileMergeForm, ProfileEmailForm, AffiliationForm
 
 
 ################
@@ -290,6 +290,21 @@ class ProfileDuplicateListView(PermissionsMixin, PaginationMixin, ListView):
         return context
 
 
+@permission_required('scipost.can_create_profiles')
+def _hx_profile_dynsel_list(request):
+    form = ProfileDynSelForm(request.POST or None)
+    if form.is_valid():
+        profiles = form.search_results()
+    else:
+        profiles = Profile.objects.none()
+    context = {
+        'profiles': profiles,
+        'action_url_name': form.cleaned_data['action_url_name'],
+        'action_url_base_kwargs': form.cleaned_data['action_url_base_kwargs'],
+    }
+    return render(request, 'profiles/_hx_profile_dynsel_list.html', context)
+
+
 @transaction.atomic
 @permission_required('scipost.can_create_profiles')
 def profile_merge(request):
diff --git a/scipost_django/series/templates/series/_hx_collection_expected_authors.html b/scipost_django/series/templates/series/_hx_collection_expected_authors.html
new file mode 100644
index 000000000..d3ed1fbbb
--- /dev/null
+++ b/scipost_django/series/templates/series/_hx_collection_expected_authors.html
@@ -0,0 +1,44 @@
+{% load crispy_forms_tags %}
+
+<div class="row">
+  <div class="col-md-8">
+    {% include 'scipost/messages.html' %}
+    <table class="table">
+      <thead>
+	<tr>
+	  <th>Profile</th>
+	  <th>Actions</th>
+	</tr>
+      </thead>
+      {% for profile in collection.expected_authors.all %}
+	<tr>
+	  <td><a href="{{ profile.get_absolute_url }}">{{ profile }}</a></td>
+	  <td>
+	    <a
+	      class="btn btn-sm btn-outline-danger"
+		hx-get="{% url 'series:_hx_collection_expected_author_action' slug=collection.slug profile_id=profile.id action='remove' %}"
+		hx-target="#profiles"
+		hx-confirm="Are you sure you want to remove {{ profile }} from expected authors in this Collection?"
+	    ><small>Remove</small></a>
+	  </td>
+	</tr>
+      {% empty %}
+	<tr>
+	  <td colspan="4">No expected authors yet</td>
+	</tr>
+      {% endfor %}
+    </table>
+  </div>
+  <div class="col-md-4 p-4">
+    <h4>Add an expected author</h4>
+    <form
+	hx-post="{% url 'profiles:_hx_profile_dynsel_list' %}"
+	hx-trigger="keyup delay:200ms, change"
+	hx-target="#profile_search_results"
+    >
+      {% csrf_token %}
+      <div id="profile_search_form">{% crispy profile_search_form %}</div>
+    </form>
+    <div id="profile_search_results" class="border border-light m-2 p-1"></div>
+  </div>
+</div>
diff --git a/scipost_django/series/templates/series/collection_detail.html b/scipost_django/series/templates/series/collection_detail.html
index 6f45902c3..d59301629 100644
--- a/scipost_django/series/templates/series/collection_detail.html
+++ b/scipost_django/series/templates/series/collection_detail.html
@@ -40,31 +40,20 @@
       {% if is_ed_admin %}
 	<div class="border border-danger mt-1 p-1">
 	  <h3>Editorial Administration</h3>
-	  <ul>
-	    <li>Expected authors for this Collection
-	      <div class="row">
-		<div class="col-md-6">
-		  <ul>
-		    {% for author in collection.expected_authors.all %}
-		      <li>{{ author }}&emsp;
-			<a href="{% url 'series:collection_remove_expected_author' slug=collection.slug profile_id=author.id %}"><span class="text-danger">{% include 'bi/x-square-fill.html' %}</span></a>
-		      </li>
-		    {% empty %}
-		      <li>None yet defined</li>
-		    {% endfor %}
-		  </ul>
-		</div>
-		<div class="col-md-6">
-		  <h4>Add an expected author</h4>
-		  <form action="{% url 'series:collection_add_expected_author' slug=collection.slug %}" method="post">
-		    {% csrf_token %}
-		    {{ expected_author_form }}
-		    <input type="submit" class="btn btn-sm btn-primary text-white m-1 p-1" value="Add"/>
-		  </form>
-		</div>
+
+	  <div class="card my-4">
+	    <div class="card-header">
+	      Expected authors for this Collection
+	    </div>
+	    <div class="card-body">
+	      <div
+		  id="profiles"
+		  hx-get="{% url 'series:_hx_collection_expected_authors' slug=collection.slug %}"
+		  hx-trigger="load"
+	      >
 	      </div>
-	    </li>
-	  </ul>
+	    </div>
+	  </div>
 	</div>
       {% endif %}
       <h2 class="highlight">
diff --git a/scipost_django/series/urls.py b/scipost_django/series/urls.py
index 74e768cfb..0fd019baa 100644
--- a/scipost_django/series/urls.py
+++ b/scipost_django/series/urls.py
@@ -2,7 +2,7 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
 __license__ = "AGPL v3"
 
 
-from django.urls import path
+from django.urls import path, include
 
 from . import views
 
@@ -21,18 +21,22 @@ urlpatterns = [
         name='series_detail'
     ),
     path(
-        'collection/<slug:slug>',
-         views.CollectionDetailView.as_view(),
-         name='collection_detail'
-    ),
-    path(
-        'collection/<slug:slug>/add_expected_author',
-        views.collection_add_expected_author,
-        name='collection_add_expected_author'
-    ),
-    path(
-        'collection/<slug:slug>/remove_expected_author/<int:profile_id>',
-        views.collection_remove_expected_author,
-        name='collection_remove_expected_author'
+        'collection/<slug:slug>/', include([
+            path(
+                '',
+                views.CollectionDetailView.as_view(),
+                name='collection_detail'
+            ),
+            path(
+                '_hx_collection_expected_authors',
+                views._hx_collection_expected_authors,
+                name='_hx_collection_expected_authors'
+            ),
+            path(
+                '_hx_collection_expected_author_action/<int:profile_id>/<str:action>',
+                views._hx_collection_expected_author_action,
+                name='_hx_collection_expected_author_action'
+            ),
+        ])
     ),
 ]
diff --git a/scipost_django/series/views.py b/scipost_django/series/views.py
index 632e109af..181e42824 100644
--- a/scipost_django/series/views.py
+++ b/scipost_django/series/views.py
@@ -4,10 +4,12 @@ __license__ = "AGPL v3"
 
 from django.contrib.auth.decorators import permission_required
 from django.shortcuts import get_object_or_404, render, redirect
+from django.urls import reverse
 from django.views.generic.detail import DetailView
 from django.views.generic.list import ListView
 
-from profiles.forms import ProfileSelectForm
+from profiles.models import Profile
+from profiles.forms import ProfileSelectForm, ProfileDynSelForm
 
 from .models import Series, Collection
 
@@ -39,18 +41,35 @@ class CollectionDetailView(DetailView):
 
 
 @permission_required('scipost.can_manage_series')
-def collection_add_expected_author(request, slug):
+def _hx_collection_expected_authors(request, slug):
     collection = get_object_or_404(Collection, slug=slug)
-    expected_author_form=ProfileSelectForm(request.POST or None)
-    if expected_author_form.is_valid():
-        collection.expected_authors.add(expected_author_form.cleaned_data['profile'])
-        collection.save()
-    return redirect(collection.get_absolute_url())
+    form = ProfileDynSelForm(
+        initial={
+            'action_url_name': 'series:_hx_collection_expected_author_action',
+            'action_url_base_kwargs': {'slug': collection.slug, 'action': 'add'}
+        }
+    )
+    context = {
+        'collection': collection,
+        'profile_search_form': form
+    }
+    return render(request, 'series/_hx_collection_expected_authors.html', context)
 
 
 @permission_required('scipost.can_manage_series')
-def collection_remove_expected_author(request, slug, profile_id):
+def _hx_collection_expected_author_action(request, slug, profile_id, action):
     collection = get_object_or_404(Collection, slug=slug)
-    collection.expected_authors.remove(profile_id)
-    collection.save()
-    return redirect(collection.get_absolute_url())
+    profile = get_object_or_404(Profile, pk=profile_id)
+    if action == 'add':
+        collection.expected_authors.add(profile)
+    if action == 'remove':
+        # If this person already has a Publication, abort
+        if collection.publications.filter(authors__profile=profile).exists():
+            messages.error(
+                request,
+                f'{profile} is author of a Publication; removal aborted.'
+            )
+        else:
+            collection.expected_authors.remove(profile)
+    return redirect(reverse('series:_hx_collection_expected_authors',
+                            kwargs={'slug': collection.slug}))
-- 
GitLab