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> ...</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 }}  - <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