diff --git a/scipost_django/finances/views.py b/scipost_django/finances/views.py index 68504106cae576159b45102c012242dce2dd99fc..1774b925080766f627f4280f8121d0fcb06ada11 100644 --- a/scipost_django/finances/views.py +++ b/scipost_django/finances/views.py @@ -40,7 +40,7 @@ from comments.utils import validate_file_extention from journals.models import Journal, Publication from organizations.models import Organization from scipost.mixins import PermissionsMixin -from scipost.views import HTMXPermissionsDenied, HTMXResponse +from scipost.permissions import HTMXPermissionsDenied, HTMXResponse def publishing_years(): diff --git a/scipost_django/production/permissions.py b/scipost_django/production/permissions.py index 909d5b9a5c4dbe4b40f91c9e5cbbd50c219d94a8..841dd945192969a81446bd66df6b8c987ec5dab2 100644 --- a/scipost_django/production/permissions.py +++ b/scipost_django/production/permissions.py @@ -3,9 +3,6 @@ __license__ = "AGPL v3" from django.contrib.auth.decorators import user_passes_test -from scipost.views import HTMXPermissionsDenied -from functools import wraps -from django.contrib import messages def is_production_user(): @@ -18,27 +15,3 @@ def is_production_user(): return False return user_passes_test(test) - - -def permission_required_htmx( - perm, - message="You do not have the required permissions.", - **message_kwargs, -): - def decorator(view_func): - @wraps(view_func) - def _wrapped_view(request, *args, **kwargs): - if isinstance(perm, str): - perms = (perm,) - else: - perms = perm - - if request.user.has_perms(perms): - return view_func(request, *args, **kwargs) - else: - messages.error(request, message) - return HTMXPermissionsDenied(message, **message_kwargs) - - return _wrapped_view - - return decorator diff --git a/scipost_django/production/views.py b/scipost_django/production/views.py index 679b5770823d319f0b638c455cf677a6eed8fa03..f95e42c53d774d8a4a245602a405609f7c38ef8b 100644 --- a/scipost_django/production/views.py +++ b/scipost_django/production/views.py @@ -21,7 +21,11 @@ from guardian.shortcuts import assign_perm, remove_perm from finances.forms import WorkLogForm from mails.views import MailEditorSubviewHTMX -from scipost.views import HTMXPermissionsDenied, HTMXResponse +from scipost.permissions import ( + HTMXPermissionsDenied, + HTMXResponse, + permission_required_htmx, +) from . import constants from .models import ( @@ -44,7 +48,7 @@ from .forms import ( ProofsDecisionForm, AssignInvitationsOfficerForm, ) -from .permissions import is_production_user, permission_required_htmx +from .permissions import is_production_user from .utils import proofs_slug_to_id, ProductionUtils diff --git a/scipost_django/profiles/templates/profiles/profile_list.html b/scipost_django/profiles/templates/profiles/profile_list.html index 16c8c1907eb13ac46ed8e19aa82d4a048a0b433f..0a066f0113debf0390aa7d24ae86c128f90e528c 100644 --- a/scipost_django/profiles/templates/profiles/profile_list.html +++ b/scipost_django/profiles/templates/profiles/profile_list.html @@ -8,178 +8,372 @@ {% block breadcrumb_items %} - {{ block.super }} - <span class="breadcrumb-item">Profiles</span> + {{ block.super }} + <span class="breadcrumb-item">Profiles</span> {% endblock %} -{% block meta_description %}{{ block.super }} Profiles List{% endblock meta_description %} -{% block pagetitle %}: Profiles{% endblock pagetitle %} +{% block meta_description %} + {{ block.super }} Profiles List +{% endblock meta_description %} +{% block pagetitle %} + : Profiles +{% endblock pagetitle %} {% block content %} - {% is_ed_admin request.user as is_ed_admin %} - {% is_scipost_admin request.user as is_scipost_admin %} - - <div class="row"> - <div class="col-12"> - <h4>Profiles-related Actions:</h4> - <ul> - {% if is_scipost_admin or is_ed_admin %} - {% if nr_contributors_w_duplicate_names > 0 %} - <li><span class="text-warning">{% include 'bi/exclamation-circle-fill.html' %}</span><a href="{% url 'scipost:contributor_duplicates' %}?kind=names">Handle Contributors with duplicate names ({{ nr_contributors_w_duplicate_names }} to handle)</a></li> - {% else %} - <li><span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> No name-duplicate Contributors found</li> - {% endif %} - {% if nr_contributors_w_duplicate_emails > 0 %} - <li><a href="{% url 'scipost:contributor_duplicates' %}?kind=emails">Handle Contributors with duplicate emails ({{ nr_contributors_w_duplicate_emails }} to handle)</a></li> - {% else %} - <li><span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> No email-duplicate Contributors found</li> - {% endif %} - {% if next_contributor_wo_profile %} - <li><span class="text-warning">{% include 'bi/exclamation-circle-fill.html' %}</span> Create a Profile for <a href="{% url 'profiles:profile_create' from_type='contributor' pk=next_contributor_wo_profile.id %}">the next</a> Contributor without one ({{ nr_contributors_wo_profile }} to handle)</li> - {% else %} - <li><span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> All registered Contributors have a Profile</li> - {% endif %} - {% if nr_potential_duplicate_profiles > 0 %} - <li><span class="text-warning">{% include 'bi/exclamation-circle-fill.html' %}</span> <a href="{% url 'profiles:duplicates' %}">Check for duplicate Profiles ({{ nr_potential_duplicate_profiles }} to handle)</a></li> - {% else %} - <li><span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> No potential duplicate Profiles detected</li> - {% endif %} - {% if next_reginv_wo_profile %} - <li><span class="text-warning">{% include 'bi/exclamation-circle-fill.html' %}</span> Create a Profile for <a href="{% url 'profiles:profile_create' from_type='registrationinvitation' pk=next_reginv_wo_profile.id %}">the next</a> Registration Invitation without one ({{ nr_reginv_wo_profile }} to handle)</li> - {% else %} - <li><span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> All Registration Invitations have a Profile</li> - {% endif %} - {% if next_refinv_wo_profile %} - <li><span class="text-warning">{% include 'bi/exclamation-circle-fill.html' %}</span> Create a Profile for <a href="{% url 'profiles:profile_create' from_type='refereeinvitation' pk=next_refinv_wo_profile.id %}">the next</a> Referee Invitation without one ({{ nr_refinv_wo_profile }} to handle)</li> - {% else %} - <li><span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> All Referee Invitations have a Profile</li> - {% endif %} - {% endif %} - <li><a href="{% url 'profiles:profile_create' %}">Add a Profile</a></li> - </ul> - </div> - </div> - - <div class="row"> - <div class="col-12"> - <table class="table table-bordered table-secondary"> - <thead class="table-dark"> - <tr> - <th><h3 class="mb-0">Branch</h3></th> - <th><h3 class="mb-0">Fields</h3></th> - </tr> - </thead> - <tbody> - {% for branch in branches %} - <tr> - <td class="align-middle"> - <small>{{ branch.name }}</small> - </td> - <td> - <ul class="list-inline m-0"> - {% for acad_field in branch.academic_fields.all %} - <li class="list-inline-item"> - {% if acad_field.profiles.all|length > 0 %} - <div class="dropdown"> - <button class="btn btn-sm btn-primary dropdown-toggle" type="button" id="dropdownMenuButton{{ acad_field.slug }}" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><small>{{ acad_field }}</small></button> - <div class="dropdown-menu" aria-labelledby="dropdownMenuButton{{ acad_field.slug }}"> - <a class="dropdown-item" href="{% add_get_parameters field=acad_field.slug specialty='' %}">View all in {{ acad_field }}</a> - {% for specialty in acad_field.specialties.all %} - <a class="dropdown-item" href="{% add_get_parameters field=acad_field.slug specialty=specialty.slug %}">{{ specialty }}</a> - {% endfor %} - </div> - </div> - {% else %} - <button type="button" class="btn btn-sm btn-outline-secondary m-1"><small><em>{{ acad_field.name }}</em></small></button> - {% endif %} - </li> - {% endfor %} - </td> - </tr> - {% endfor %} - </tbody> - </table> - - - <h4>Specialize the list by selecting from the table above, or:</h4> - <ul> - <li> - <ul class="list-inline"> - <li class="list-inline-item"> - <a href="{% url 'profiles:profiles' %}">View all</a> - </li> - </ul> - </li> - <li>View only Profiles <a href="{% add_get_parameters contributor=True %}">with</a> or <a href="{% add_get_parameters contributor=False %}">without</a> an associated Contributor</li> - <li> - <ul class="list-inline"> - <li class="list-inline-item">Last name contains:</li> - <li class="list-inline-item"> - <form action="" method="get">{{ searchform }} - {% if request.GET.field %} - <input type="hidden" name="field" value="{{ request.GET.field }}"> - {% if request.GET.specialty %} - <input type="hidden" name="specialty" value="{{ request.GET.specialty }}"> - {% endif %} - {% endif %} - {% if request.GET.contributor %} - <input type="hidden" name="contributor" value="{{ request.GET.contributor }}"> - {% endif %} - </li> - <li class="list-inline-item"><input class="btn btn-outline-secondary" type="submit" value="Search"></form> - </li> - </ul> - </li> - </ul> - </div> - </div> - - <div class="row"> - <div class="col-12"> - <h3>Profiles {% if request.GET.text %}with last name starting with {{ request.GET.text }}{% endif %} {% if request.GET.field %}in {{ request.GET.field }}{% if request.GET.specialty %}, {{ request.GET.specialty }}{% endif %}{% endif %} ({% if request.GET.contributor == "True" %}registered Contributors{% elif request.GET.contributor == "False" %}unregistered as Contributors{% else %}all registered/unregistered{% endif %}): {{ page_obj.paginator.count }} found</h3> - <br/> - - <table class="table table-hover mb-5"> - <thead class="table-light"> - <tr> - <th>Name</th> - <th>Academic field</th> - <th>Specialties</th> - <th>Contributor?</th> - </tr> - </thead> - <tbody> - {% for profile in object_list %} - <tr class="table-row" data-href="{% url 'profiles:profile_detail' pk=profile.id %}" target="_blank" style="cursor: pointer;"> - <td>{{ profile }}</td> - <td>{{ profile.acad_field }}</td> - <td> - {% for specialty in profile.specialties.all %} - <div class="single d-inline" data-specialty="{{ specialty.code }}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ specialty }}">{{ specialty.code }}</div> - {% endfor %} - </td> - <td>{% if profile.has_active_contributor %}<span class="text-success">{% include 'bi/check-circle-fill.html' %}</span>{% else %}<span class="text-danger">{% include 'bi/x-circle-fill.html' %}</span>{% endif %}</td> - </tr> - {% empty %} - <tr> - <td colspan="4">No Profiles found</td> - </tr> - {% endfor %} - </tbody> - </table> - - {% if is_paginated %} - <div class="col-12"> - {% include '_pagination.html' with page_obj=page_obj %} - </div> - {% endif %} + {% is_ed_admin request.user as is_ed_admin %} + {% is_scipost_admin request.user as is_scipost_admin %} + <div class="row"> + <div class="col-12"> + <h4>Profiles-related Actions:</h4> + <ul> + + {% if is_scipost_admin or is_ed_admin %} + + {% if nr_contributors_w_duplicate_names > 0 %} + + <li> + <span class="text-warning">{% include 'bi/exclamation-circle-fill.html' %}</span><a href="{% url 'scipost:contributor_duplicates' group_by="names" %}">Handle Contributors with duplicate names ({{ nr_contributors_w_duplicate_names }} to handle)</a> + </li> + + {% else %} + + <li> + <span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> No name-duplicate Contributors found + </li> + + {% endif %} + + {% if nr_contributors_w_duplicate_emails > 0 %} + + <li> + <a href="{% url 'scipost:contributor_duplicates' group_by="emails" %}">Handle Contributors with duplicate emails ({{ nr_contributors_w_duplicate_emails }} to handle)</a> + </li> + + {% else %} + + <li> + <span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> No email-duplicate Contributors found + </li> + + {% endif %} + + {% if next_contributor_wo_profile %} + + <li> + <span class="text-warning">{% include 'bi/exclamation-circle-fill.html' %}</span> Create a Profile for <a href="{% url 'profiles:profile_create' from_type='contributor' pk=next_contributor_wo_profile.id %}">the next</a> Contributor without one ({{ nr_contributors_wo_profile }} to handle) + </li> + + {% else %} + + <li> + <span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> All registered Contributors have a Profile + </li> + + {% endif %} + + {% if nr_potential_duplicate_profiles > 0 %} + + <li> + <span class="text-warning">{% include 'bi/exclamation-circle-fill.html' %}</span> <a href="{% url 'profiles:duplicates' %}">Check for duplicate Profiles ({{ nr_potential_duplicate_profiles }} to handle)</a> + </li> + + {% else %} + + <li> + <span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> No potential duplicate Profiles detected + </li> + + {% endif %} + + {% if next_reginv_wo_profile %} + + <li> + <span class="text-warning">{% include 'bi/exclamation-circle-fill.html' %}</span> Create a Profile for <a href="{% url 'profiles:profile_create' from_type='registrationinvitation' pk=next_reginv_wo_profile.id %}">the next</a> Registration Invitation without one ({{ nr_reginv_wo_profile }} to handle) + </li> + + {% else %} + + <li> + <span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> All Registration Invitations have a Profile + </li> + + {% endif %} + + {% if next_refinv_wo_profile %} + + <li> + <span class="text-warning">{% include 'bi/exclamation-circle-fill.html' %}</span> Create a Profile for <a href="{% url 'profiles:profile_create' from_type='refereeinvitation' pk=next_refinv_wo_profile.id %}">the next</a> Referee Invitation without one ({{ nr_refinv_wo_profile }} to handle) + </li> + + {% else %} + + <li> + <span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> All Referee Invitations have a Profile + </li> + + {% endif %} + + {% endif %} + + <li> + <a href="{% url 'profiles:profile_create' %}">Add a Profile</a> + </li> + </ul> + </div> </div> - </div> -{% endblock content %} -{% block footer_script %} - <script src="{% static 'scipost/table-row.js' %}"></script> -{% endblock footer_script %} + <div class="row"> + <div class="col-12"> + <table class="table table-bordered table-secondary"> + + <thead class="table-dark"> + + <tr> + + <th> + <h3 class="mb-0">Branch</h3> + </th> + + <th> + <h3 class="mb-0">Fields</h3> + </th> + + </tr> + + </thead> + + <tbody> + + {% for branch in branches %} + + <tr> + + <td class="align-middle"> + <small>{{ branch.name }}</small> + + </td> + + <td> + + <ul class="list-inline m-0"> + + {% for acad_field in branch.academic_fields.all %} + + <li class="list-inline-item"> + + {% if acad_field.profiles.all|length > 0 %} + + <div class="dropdown"> + + <button class="btn btn-sm btn-primary dropdown-toggle" + type="button" + id="dropdownMenuButton{{ acad_field.slug }}" + data-bs-toggle="dropdown" + aria-haspopup="true" + aria-expanded="false"> + <small>{{ acad_field }}</small> + </button> + + <div class="dropdown-menu" + aria-labelledby="dropdownMenuButton{{ acad_field.slug }}"> + <a class="dropdown-item" + href="{% add_get_parameters field=acad_field.slug specialty='' %}">View all in {{ acad_field }}</a> + + {% for specialty in acad_field.specialties.all %} + <a class="dropdown-item" + href="{% add_get_parameters field=acad_field.slug specialty=specialty.slug %}">{{ specialty }}</a> + + {% endfor %} + + </div> + + </div> + + {% else %} + + <button type="button" class="btn btn-sm btn-outline-secondary m-1"> + <small><em>{{ acad_field.name }}</em></small> + </button> + + {% endif %} + + </li> + + {% endfor %} + + </td> + + </tr> + + {% endfor %} + + </tbody> + </table> + + + <h4>Specialize the list by selecting from the table above, or:</h4> + <ul> + + <li> + + <ul class="list-inline"> + + <li class="list-inline-item"> + <a href="{% url 'profiles:profiles' %}">View all</a> + + </li> + + </ul> + + </li> + + <li> + View only Profiles <a href="{% add_get_parameters contributor=True %}">with</a> or <a href="{% add_get_parameters contributor=False %}">without</a> an associated Contributor + </li> + + <li> + + <ul class="list-inline"> + + <li class="list-inline-item">Last name contains:</li> + + <li class="list-inline-item"> + + <form action="" method="get"> + {{ searchform }} + + {% if request.GET.field %} + + <input type="hidden" name="field" value="{{ request.GET.field }}"> + + {% if request.GET.specialty %}<input type="hidden" name="specialty" value="{{ request.GET.specialty }}">{% endif %} + + {% endif %} + + {% if request.GET.contributor %} + + <input type="hidden" + name="contributor" + value="{{ request.GET.contributor }}"> + + {% endif %} + + </li> + + <li class="list-inline-item"> + <input class="btn btn-outline-secondary" type="submit" value="Search"> + </form> + + </li> + + </ul> + + </li> + </ul> + </div> + </div> + + <div class="row"> + <div class="col-12"> + <h3> + Profiles + {% if request.GET.text %}with last name starting with {{ request.GET.text }}{% endif %} + + {% if request.GET.field %} + in {{ request.GET.field }} + {% if request.GET.specialty %}, {{ request.GET.specialty }}{% endif %} + {% endif %} + ( + {% if request.GET.contributor == "True" %} + registered Contributors + {% elif request.GET.contributor == "False" %} + unregistered as Contributors + {% else %} + all registered/unregistered + {% endif %} + ): {{ page_obj.paginator.count }} found + </h3> + <br /> + + <table class="table table-hover mb-5"> + + <thead class="table-light"> + + <tr> + + <th>Name</th> + + <th>Academic field</th> + + <th>Specialties</th> + + <th>Contributor?</th> + + </tr> + + </thead> + + <tbody> + + {% for profile in object_list %} + + <tr class="table-row" + data-href="{% url 'profiles:profile_detail' pk=profile.id %}" + target="_blank" + style="cursor: pointer"> + + <td>{{ profile }}</td> + + <td>{{ profile.acad_field }}</td> + + <td> + + {% for specialty in profile.specialties.all %} + + <div class="single d-inline" + data-specialty="{{ specialty.code }}" + data-bs-toggle="tooltip" + data-bs-placement="bottom" + title="{{ specialty }}">{{ specialty.code }}</div> + + {% endfor %} + + </td> + + <td> + {% if profile.has_active_contributor %} + <span class="text-success">{% include 'bi/check-circle-fill.html' %}</span> + {% else %} + <span class="text-danger">{% include 'bi/x-circle-fill.html' %}</span> + {% endif %} + </td> + + </tr> + {% empty %} + + <tr> + + <td colspan="4">No Profiles found</td> + + </tr> + + {% endfor %} + + </tbody> + </table> + + {% if is_paginated %} + + <div class="col-12">{% include '_pagination.html' with page_obj=page_obj %}</div> + {% endif %} + + </div> + </div> + {% endblock content %} + + {% block footer_script %} + <script src="{% static 'scipost/table-row.js' %}"></script> + {% endblock footer_script %} diff --git a/scipost_django/scipost/forms.py b/scipost_django/scipost/forms.py index 3691701b13b044275b2053be23996be412ca1ed9..6daea1fc8a0711643983eb88c850e148bc874e1b 100644 --- a/scipost_django/scipost/forms.py +++ b/scipost_django/scipost/forms.py @@ -14,6 +14,7 @@ from django.contrib.auth.password_validation import validate_password from django.contrib.auth.validators import UnicodeUsernameValidator from django.core.exceptions import ValidationError from django.http import Http404 +from django.urls import reverse from django.utils import timezone from django.utils.dates import MONTHS @@ -643,12 +644,55 @@ class UnavailabilityPeriodForm(forms.ModelForm): class ContributorMergeForm(forms.Form): to_merge = ModelChoiceFieldwithid( - queryset=Contributor.objects.all(), empty_label=None + queryset=Contributor.objects.all(), + empty_label=None, + label="Merge this contributor", ) to_merge_into = ModelChoiceFieldwithid( - queryset=Contributor.objects.all(), empty_label=None + queryset=Contributor.objects.all(), + empty_label=None, + label="Into this contributor", ) + def __init__(self, *args, **kwargs): + queryset = kwargs.pop("queryset", None) + super().__init__(*args, **kwargs) + if queryset: + self.fields["to_merge"].queryset = queryset + self.fields["to_merge_into"].queryset = queryset + + self.helper = FormHelper() + self.helper.attrs = { + "hx-target": "#merge-form-info", + "hx-get": reverse( + "scipost:_hx_contributor_comparison", + ), + "hx-trigger": "intersect once, change from:select", + } + self.layout = Layout( + Div( + Div( + Field("to_merge"), + css_id="to_merge", + css_class="col-12 col-md", + ), + Div( + Field("to_merge_into"), + css_id="to_merge_into", + css_class="col-12 col-md", + ), + css_class="row mb-0", + ), + Div( + Div( + css_class="col-12", + css_id="merge-form-info", + ), + css_class="row mb-0", + ), + ) + self.helper.layout = self.layout + def clean(self): data = super().clean() if self.cleaned_data["to_merge"] == self.cleaned_data["to_merge_into"]: diff --git a/scipost_django/scipost/permissions.py b/scipost_django/scipost/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..09e6e7e73510cd0a0465459417f0beaf54f24067 --- /dev/null +++ b/scipost_django/scipost/permissions.py @@ -0,0 +1,54 @@ +from functools import wraps + +from django.contrib import messages +from django.http import HttpResponse + +#################### +# HTMX inline alerts +#################### + + +class HTMXResponse(HttpResponse): + tag = "primary" + message = "" + css_class = "" + + def __init__(self, *args, **kwargs): + tag = kwargs.pop("tag", self.tag) + message = args[0] if args else kwargs.pop("message", self.message) + css_class = kwargs.pop("css_class", self.css_class) + + alert_html = f"""<div class="text-{tag} border border-{tag} p-3 {css_class}"> + {message} + </div>""" + + super().__init__(alert_html, *args, **kwargs) + + +class HTMXPermissionsDenied(HTMXResponse): + tag = "danger" + message = "You do not have the required permissions." + + +def permission_required_htmx( + perm, + message="You do not have the required permissions.", + **message_kwargs, +): + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + if isinstance(perm, str): + perms = (perm,) + else: + perms = perm + + if request.user.has_perms(perms): + return view_func(request, *args, **kwargs) + else: + messages.error(request, message) + return HTMXPermissionsDenied(message, **message_kwargs) + + return _wrapped_view + + return decorator diff --git a/scipost_django/scipost/templates/scipost/_hx_contributor_comparison.html b/scipost_django/scipost/templates/scipost/_hx_contributor_comparison.html new file mode 100644 index 0000000000000000000000000000000000000000..16f0951616b9496f8d3626d3e75315a23a4b6039 --- /dev/null +++ b/scipost_django/scipost/templates/scipost/_hx_contributor_comparison.html @@ -0,0 +1,33 @@ +{% load crispy_forms_tags %} +{% load scipost_extras %} + +<div class="row"> + <div class="col-12 col-md-6"> + <h3 class="highlight">Merge Contributor {{ contributor_to_merge.id }}</h3> + {% include "scipost/_public_info_as_table.html" with contributor=contributor_to_merge %} + </div> + <div class="col-12 col-md-6"> + <h3 class="highlight">into Contributor {{ contributor_to_merge_into.id }}</h3> + {% include "scipost/_public_info_as_table.html" with contributor=contributor_to_merge_into %} + </div> +</div> + +<div class="row"> + <div class="col-12"> + {% if contributor_to_merge.user.is_active and not contributor_to_merge_into.user.is_active %} + <h3 class="text-danger"> + Warning: the contributor to merge is active, while the one to merge into is not. Consider swapping the order with "Swap & Merge". + </h3> + <div id="contributor-swap-merge-btn" + class="btn btn-warning me-2" + hx-post="{% url "scipost:_hx_contributor_merge" to_merge=contributor_to_merge.id to_merge_into=contributor_to_merge_into.id %}"> + Swap & Merge + </div> + {% endif %} + <div id="contributor-merge-btn" + class="btn btn-primary" + hx-post="{% url "scipost:_hx_contributor_merge" to_merge=contributor_to_merge_into.id to_merge_into=contributor_to_merge.id %}"> + Merge + </div> + </div> +</div> diff --git a/scipost_django/scipost/templates/scipost/_hx_contributor_duplicate_merger.html b/scipost_django/scipost/templates/scipost/_hx_contributor_duplicate_merger.html new file mode 100644 index 0000000000000000000000000000000000000000..ab894f96603176d85ca63585585600e80be00bb1 --- /dev/null +++ b/scipost_django/scipost/templates/scipost/_hx_contributor_duplicate_merger.html @@ -0,0 +1,24 @@ +{% load crispy_forms_tags %} + +<div class="row mt-4"> + <div class="d-none d-lg-block col-3"> + <h3>All found duplicates</h3> + <div style='max-height: 50vh' class="overflow-scroll"> + {% for contributor in duplicate_contributors %} + <li> + <div class="text-nowrap text-truncate" title="{{ contributor }}"> + {{ contributor }} (<em>id={{ contributor.id }}</em>) + </div> + </li> + {% empty %} + <em>No duplicates found</em> + {% endfor %} + </div> + </div> + + <div class="col"> + {% if form %} + {% crispy form %} + {% endif %} + </div> +</div> diff --git a/scipost_django/scipost/templates/scipost/contributor_duplicate_list.html b/scipost_django/scipost/templates/scipost/contributor_duplicate_list.html deleted file mode 100644 index 635fe084e60e3cd144328132626c037422a0e35c..0000000000000000000000000000000000000000 --- a/scipost_django/scipost/templates/scipost/contributor_duplicate_list.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends 'profiles/base.html' %} - -{% load bootstrap %} - -{% block breadcrumb_items %} - {{ block.super }} - <span class="breadcrumb-item">Contributor duplicates</span> -{% endblock %} - -{% load scipost_extras %} - -{% block pagetitle %}: Contributor duplicates{% endblock pagetitle %} - -{% block content %} - <div class="row"> - <div class="col-12"> - <h1 class="highlight">Potentially duplicate Contributors</h1> - {% if merge_form %} - <form action="{% url 'scipost:contributor_merge' %}" method="get"> - {{ merge_form|bootstrap }} - <input class="btn btn-outline-secondary" type="submit" value="Check"> - </form> - {% endif %} - - <br> - <h3>All found duplicates</h3> - <ul> - {% for contrib_dup in object_list %} - <li>{{ contrib_dup }} (<em>id={{ contrib_dup.id }}</em>)</li> - {% empty %} - <li<em>No duplicates found</em></li> - {% endfor %} - </ul> - - {% if is_paginated %} - <div class="col-12"> - {% include '_pagination.html' with page_obj=page_obj %} - </div> - {% endif %} - - </div> - </div> - -{% endblock content %} diff --git a/scipost_django/scipost/templates/scipost/contributor_duplicates.html b/scipost_django/scipost/templates/scipost/contributor_duplicates.html new file mode 100644 index 0000000000000000000000000000000000000000..c70db0a23bcfb6ec357413c063abda4a79cf7228 --- /dev/null +++ b/scipost_django/scipost/templates/scipost/contributor_duplicates.html @@ -0,0 +1,25 @@ +{% extends 'profiles/base.html' %} + +{% block breadcrumb_items %} + {{ block.super }} + <span class="breadcrumb-item">Contributor duplicates</span> +{% endblock %} + +{% load scipost_extras %} + +{% block pagetitle %} + : Contributor duplicates +{% endblock pagetitle %} + +{% block content %} + <div class="row"> + <div class="col-12"> + <h1 class="highlight">Potentially duplicate Contributors</h1> + + <div hx-trigger="intersect once, htmx:trigger from:body target:.btn delay:1000" + hx-get="{% url 'scipost:_hx_contributor_duplicate_merger' %}?kind={{ group_by }}"></div> + + </div> + </div> + +{% endblock content %} diff --git a/scipost_django/scipost/templates/scipost/contributor_merge.html b/scipost_django/scipost/templates/scipost/contributor_merge.html deleted file mode 100644 index b74612df222822bd54efaad9b57e96e7e055396c..0000000000000000000000000000000000000000 --- a/scipost_django/scipost/templates/scipost/contributor_merge.html +++ /dev/null @@ -1,50 +0,0 @@ -{% extends 'scipost/base.html' %} - -{% load bootstrap %} - -{% block breadcrumb_items %} - {{ block.super }} - <span class="breadcrumb-item"><a href="{% url 'scipost:contributor_duplicates' %}">Duplicates</a></span> - <span class="breadcrumb-item">Merge Contributors {{ contributor_to_merge.id }} and {{ contributor_to_merge_into.id }}</span> -{% endblock %} - -{% load scipost_extras %} - -{% block pagetitle %}: Contributor duplicates: merge{% endblock pagetitle %} - -{% block content %} - <div class="row"> - <div class="col-12"> - <h1 class="highlight">Merge Contributors {{ contributor_to_merge.id }} and {{ contributor_to_merge_into.id }}</h1> - {% if contributor_to_merge.user.is_active and not contributor_to_merge_into.user.is_active %} - <h3 class="text-danger">Warning: the contributor to merge is active, while the one to merge into is not</h3> - <p>Consider <a href="{% url 'scipost:contributor_merge' %}?to_merge={{ contributor_to_merge_into.id }}&to_merge_into={{ contributor_to_merge.id }}" method="get">merging the other way around</a></p> - {% endif %} - </div> - </div> - <div class="row"> - <div class="col-12"> - <h3 class="highlight">Contributor {{ contributor_to_merge.id }}</h3> - {% include "scipost/_public_info_as_table.html" with contributor=contributor_to_merge %} - </div> - </div> - <div class="row"> - <div class="col-12"> - <h3 class="highlight">Contributor {{ contributor_to_merge_into.id }}</h3> - {% include "scipost/_public_info_as_table.html" with contributor=contributor_to_merge_into %} - </div> - </div> - - <div class="row"> - <div class="col-12"> - <h3 class="highlight">Merge:</h3> - <form method="post"> - {% csrf_token %} - {{ merge_form|bootstrap }} - <input class="btn btn-primary" type="submit" value="Confirm merge"> - <a class="text-warning" href="{% url 'scipost:contributor_merge' %}?to_merge={{ contributor_to_merge_into.id }}&to_merge_into={{ contributor_to_merge.id }}" method="get">Merge the other way around</a></p> - </form> - </div> - </div> - -{% endblock content %} diff --git a/scipost_django/scipost/urls.py b/scipost_django/scipost/urls.py index f1f4cd38541933820d6630bcd325115ade047a06..371ccd1decb82bc32df89f510f9344bb5ae4c749 100644 --- a/scipost_django/scipost/urls.py +++ b/scipost_django/scipost/urls.py @@ -463,11 +463,25 @@ urlpatterns = [ # Potential duplicates ####################### path( - "contributor_duplicates/", - views.ContributorDuplicateListView.as_view(), + "contributor_duplicates/<str:group_by>", + views.contributor_duplicates, name="contributor_duplicates", ), - path("contributor_merge/", views.contributor_merge, name="contributor_merge"), + path( + "_hx_contributor_duplicate_merger", + views.ContributorDuplicateListView.as_view(), + name="_hx_contributor_duplicate_merger", + ), + path( + "_hx_contributor_comparison", + views._hx_contributor_comparison, + name="_hx_contributor_comparison", + ), + path( + "_hx_contributor_merge/<int:to_merge>/<int:to_merge_into>", + views._hx_contributor_merge, + name="_hx_contributor_merge", + ), # ################### # Email facilities diff --git a/scipost_django/scipost/views.py b/scipost_django/scipost/views.py index 34b196241ec688b631c42198e782e480f7ea5dae..56f8684738251cbfa46e1eabd50451a04be590c0 100644 --- a/scipost_django/scipost/views.py +++ b/scipost_django/scipost/views.py @@ -42,6 +42,7 @@ from django.views.static import serve from dal import autocomplete from guardian.decorators import permission_required +from scipost.permissions import permission_required_htmx, HTMXResponse from .constants import SciPost_from_addresses_dict, NORMAL_CONTRIBUTOR from .decorators import has_contributor, is_contributor_user @@ -182,33 +183,6 @@ def _hx_messages(request): return render(request, "scipost/_hx_messages.html") -#################### -# HTMX inline alerts -#################### - - -class HTMXResponse(HttpResponse): - tag = "primary" - message = "" - css_class = "" - - def __init__(self, *args, **kwargs): - tag = kwargs.pop("tag", self.tag) - message = args[0] if args else kwargs.pop("message", self.message) - css_class = kwargs.pop("css_class", self.css_class) - - alert_html = f"""<div class="text-{tag} border border-{tag} p-3 {css_class}"> - {message} - </div>""" - - super().__init__(alert_html, *args, **kwargs) - - -class HTMXPermissionsDenied(HTMXResponse): - tag = "danger" - message = "You do not have the required permissions." - - ############# # Main view ############# @@ -1562,7 +1536,13 @@ def contributor_info(request, contributor_id): return render(request, "scipost/contributor_info.html", context) -class ContributorDuplicateListView(PermissionsMixin, PaginationMixin, ListView): +def contributor_duplicates(request, group_by: str): + return render( + request, "scipost/contributor_duplicates.html", {"group_by": group_by} + ) + + +class ContributorDuplicateListView(PermissionsMixin, ListView): """ List Contributors with potential (not yet handled) duplicates. Two sources of duplicates are separately considered: @@ -1574,7 +1554,7 @@ class ContributorDuplicateListView(PermissionsMixin, PaginationMixin, ListView): permission_required = "scipost.can_vet_registration_requests" model = Contributor - template_name = "scipost/contributor_duplicate_list.html" + template_name = "scipost/_hx_contributor_duplicate_merger.html" def get_queryset(self): queryset = Contributor.objects.all() @@ -1588,19 +1568,25 @@ class ContributorDuplicateListView(PermissionsMixin, PaginationMixin, ListView): def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) + context["duplicate_contributors"] = context.pop("object_list") - if len(context["object_list"]) > 1: + if len(context["duplicate_contributors"]) > 1: initial = { - "to_merge": context["object_list"][0].id, - "to_merge_into": context["object_list"][1].id, + "to_merge": context["duplicate_contributors"][0].id, + "to_merge_into": context["duplicate_contributors"][1].id, } - context["merge_form"] = ContributorMergeForm(initial=initial) + context["form"] = ContributorMergeForm( + initial=initial, queryset=self.get_queryset() + ) return context @transaction.atomic -@permission_required("scipost.can_vet_registration_requests") -def contributor_merge(request): +@permission_required_htmx( + "scipost.can_vet_registration_requests", + "You do not have permission to vet registration requests.", +) +def _hx_contributor_comparison(request): """ Handles the merging of data from one Contributor instance to another, to solve one person - multiple registrations issues. @@ -1611,45 +1597,60 @@ def contributor_merge(request): If both Contributor instances were active, then the account owner is emailed with information about the merge. """ - merge_form = ContributorMergeForm(request.POST or None, initial=request.GET) - context = {"merge_form": merge_form} + + if request.method == "GET": + try: + context = { + "contributor_to_merge": get_object_or_404( + Contributor, pk=int(request.GET["to_merge"]) + ), + "contributor_to_merge_into": get_object_or_404( + Contributor, pk=int(request.GET["to_merge_into"]) + ), + } + except ValueError: + raise Http404 + + return render(request, "scipost/_hx_contributor_comparison.html", context) + + +@transaction.atomic +@permission_required_htmx( + "scipost.can_vet_registration_requests", + "You do not have permission to vet registration requests.", +) +def _hx_contributor_merge(request, to_merge: int, to_merge_into: int): + """ + Confirms the merging of data from one Contributor instance to another, + to solve one person - multiple registrations issues. + """ + + merge_form = ContributorMergeForm( + request.POST or None, + initial={ + "to_merge": to_merge, + "to_merge_into": to_merge_into, + }, + queryset=Contributor.objects.filter(id__in=[to_merge, to_merge_into]), + ) if request.method == "POST": if merge_form.is_valid(): contributor = merge_form.save() - messages.success(request, "Contributors merged") - return redirect(reverse("scipost:contributor_duplicates")) - else: - try: - context.update( - { - "contributor_to_merge": get_object_or_404( - Contributor, pk=merge_form.cleaned_data["to_merge"].id - ), - "contributor_to_merge_into": get_object_or_404( - Contributor, pk=merge_form.cleaned_data["to_merge_into"].id - ), - } - ) - except ValueError: - raise Http404 - - elif request.method == "GET": - try: - context.update( - { - "contributor_to_merge": get_object_or_404( - Contributor, pk=int(request.GET["to_merge"]) - ), - "contributor_to_merge_into": get_object_or_404( - Contributor, pk=int(request.GET["to_merge_into"]) - ), - } + messages.success(request, "Contributors merged successfully.") + return HTMXResponse( + f"Contributors {to_merge} and {to_merge_into} merged into {contributor.id}.", + ) + elif to_merge == to_merge_into: + return HTMXResponse( + "Cannot merge a Contributor into itself.", + tag="danger", ) - except ValueError: - raise Http404 - return render(request, "scipost/contributor_merge.html", context) + return HTMXResponse( + "Failed to merge contributors.", + tag="danger", + ) ####################