diff --git a/scipost_django/SciPost_v1/urls.py b/scipost_django/SciPost_v1/urls.py index 098312d1ae5c0dfd131355ba868fae1c93483c34..4a9672067d7f59c6549156d239555052594c8ce5 100644 --- a/scipost_django/SciPost_v1/urls.py +++ b/scipost_django/SciPost_v1/urls.py @@ -93,6 +93,7 @@ urlpatterns = [ path("commentaries/", include("commentaries.urls", namespace="commentaries")), path("commentary/", include("commentaries.urls", namespace="_commentaries")), path("comments/", include("comments.urls", namespace="comments")), + path("common/", include("common.urls", namespace="common")), path("edadmin/", include("edadmin.urls", namespace="edadmin")), path("ethics/", include("ethics.urls", namespace="ethics")), path("forums/", include("forums.urls", namespace="forums")), diff --git a/scipost_django/colleges/admin.py b/scipost_django/colleges/admin.py index bb36f032bfad8c11d499e87a33ec86e6798af193..26fb0ab1b581a4849be097575fa9d1b5061fbc33 100644 --- a/scipost_django/colleges/admin.py +++ b/scipost_django/colleges/admin.py @@ -101,11 +101,11 @@ class FellowshipNominationAdmin(admin.ModelAdmin): FellowshipNominationEventInline, FellowshipNominationCommentInline, FellowshipNominationVotingRoundInline, - FellowshipNominationDecisionInline, FellowshipInvitationInline, ] - list_display = ["college", "profile", "nominated_on"] - search_fields = ["college", "profile"] + list_filter = ["college__name"] + list_display = ["profile", "college", "nominated_on"] + search_fields = ["college__name", "profile__last_name", "profile__first_name"] autocomplete_fields = ["profile", "nominated_by", "fellowship"] @@ -116,16 +116,48 @@ class FellowshipNominationVoteInline(admin.TabularInline): model = FellowshipNominationVote extra = 0 + # Filter "fellow" field to only those who are eligible to vote + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "fellow": + kwargs["queryset"] = FellowshipNominationVotingRound.objects.get( + pk=request.resolver_match.kwargs["object_id"] + ).eligible_to_vote.all() + return super().formfield_for_foreignkey(db_field, request, **kwargs) + class FellowshipNominationVotingRoundAdmin(admin.ModelAdmin): model = FellowshipNominationVotingRound inlines = [ FellowshipNominationVoteInline, + FellowshipNominationDecisionInline, + ] + search_fields = [ + "nomination__profile__last_name", + "nomination__profile__first_name", + "nomination__college__name", + ] + list_display = [ + "nomination", + "voting_opens", + "voting_deadline", + "is_open_checkmark", + "decision__outcome", ] autocomplete_fields = [ "nomination", "eligible_to_vote", ] + list_filter = ("decision__outcome",) + + def decision__outcome(self, obj): + return obj.decision.get_outcome_display() + + @admin.display( + boolean=True, + description="Open", + ) + def is_open_checkmark(self, obj): + return obj.is_open admin.site.register( diff --git a/scipost_django/colleges/forms.py b/scipost_django/colleges/forms.py index 38e304c7182b9a8adfb2cd9b247f30e06792d466..e076868a1237e88b5377bac6744f555bd0556103 100644 --- a/scipost_django/colleges/forms.py +++ b/scipost_django/colleges/forms.py @@ -3,14 +3,19 @@ __license__ = "AGPL v3" import datetime +from typing import Any, Dict from django import forms -from django.db.models import Q +from django.contrib.sessions.backends.db import SessionStore +from django.db.models import Q, Max, OuterRef, Subquery from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Div, Field, ButtonHolder, Submit from crispy_bootstrap5.bootstrap5 import FloatingField from dal import autocomplete +from django.urls import reverse +from django.utils import timezone +from django.utils.timezone import timedelta from ontology.models import Specialty from proceedings.models import Proceedings @@ -19,6 +24,8 @@ from submissions.models import Submission from scipost.forms import RequestFormMixin from scipost.models import Contributor +from colleges.permissions import is_edadmin + from .models import ( College, Fellowship, @@ -27,6 +34,7 @@ from .models import ( FellowshipNomination, FellowshipNominationComment, FellowshipNominationDecision, + FellowshipNominationVotingRound, FellowshipInvitation, ) from .constants import ( @@ -411,7 +419,10 @@ class FellowshipNominationForm(forms.ModelForm): self.profile = kwargs.pop("profile") super().__init__(*args, **kwargs) self.fields["college"].queryset = College.objects.filter( - acad_field=self.profile.acad_field + acad_field=self.profile.acad_field, + # id__in=Fellowship.objects.active() + # .filter(contributor=self.fields["nominated_by"].initial) + # .values_list("college", flat=True), ) self.fields["college"].empty_label = None self.fields["nominator_comments"].label = False @@ -450,6 +461,16 @@ class FellowshipNominationForm(forms.ModelForm): self.add_error( "college", "Mismatch between college.acad_field and profile.acad_field." ) + if (not is_edadmin(data["nominated_by"].user)) and ( + data["college"].id + not in Fellowship.objects.active() + .filter(contributor=data["nominated_by"]) + .values_list("college", flat=True) + ): + self.add_error( + "college", + "You do not have an active Fellowship in the selected College.", + ) return data def save(self): @@ -459,52 +480,246 @@ class FellowshipNominationForm(forms.ModelForm): return nomination +# class FellowshipNominationSearchForm(forms.Form): +# """Filter a FellowshipNomination queryset using basic search fields.""" + +# college = forms.ModelChoiceField(queryset=College.objects.all(), required=False) +# specialty = forms.ModelChoiceField( +# queryset=Specialty.objects.all(), +# label="Specialty", +# required=False, +# ) +# name = forms.CharField(max_length=128, required=False) + +# def __init__(self, *args, **kwargs): +# super().__init__(*args, **kwargs) +# self.helper = FormHelper() +# self.helper.layout = Layout( +# Div( +# FloatingField("category"), +# css_class="row", +# ), +# Div( +# Div(FloatingField("college"), css_class="col-lg-6"), +# Div(FloatingField("specialty"), css_class="col-lg-6"), +# css_class="row", +# ), +# Div( +# Div(FloatingField("name", autocomplete="off"), css_class="col-lg-6"), +# css_class="row", +# ), +# ) + +# def search_results(self): +# if self.cleaned_data.get("name"): +# nominations = FellowshipNomination.objects.filter( +# Q(profile__last_name__icontains=self.cleaned_data.get("name")) +# | Q(profile__first_name__icontains=self.cleaned_data.get("name")) +# ) +# else: +# nominations = FellowshipNomination.objects.all() +# if self.cleaned_data.get("college"): +# nominations = nominations.filter(college=self.cleaned_data.get("college")) +# if self.cleaned_data.get("specialty"): +# nominations = nominations.filter( +# profile__specialties__in=[ +# self.cleaned_data.get("specialty"), +# ] +# ) +# return nominations + + class FellowshipNominationSearchForm(forms.Form): - """Filter a FellowshipNomination queryset using basic search fields.""" + all_nominations = FellowshipNomination.objects.all() + nomination_colleges = all_nominations.values_list("college", flat=True).distinct() - college = forms.ModelChoiceField(queryset=College.objects.all(), required=False) - specialty = forms.ModelChoiceField( - queryset=Specialty.objects.all(), - label="Specialty", + nominee = forms.CharField(max_length=100, required=False, label="Nominee") + + college = forms.MultipleChoiceField( + choices=College.objects.filter(id__in=nomination_colleges) + .order_by("name") + .values_list("id", "name"), + required=False, + ) + + decision = forms.ChoiceField( + choices=[("", "Any"), ("pending", "Pending")] + + FellowshipNominationDecision.OUTCOME_CHOICES, + required=False, + ) + + can_vote = forms.BooleanField( + label="I can vote", + required=False, + initial=True, + ) + voting_open = forms.BooleanField( + label="Voting open now", + required=False, + initial=True, + ) + has_rounds = forms.BooleanField( + label="Has voting rounds", + required=False, + initial=True, + ) + + orderby = forms.ChoiceField( + label="Order by", + choices=( + ("latest_round_deadline", "Deadline"), + ("latest_round_open", "Voting start"), + ("latest_round_decision_outcome", "Decision"), + ("profile__last_name", "Nominee"), + ("nominated_on", "Nominated date"), + ), + required=False, + ) + ordering = forms.ChoiceField( + label="Ordering", + choices=( + # FIXME: Emperically, the ordering appers to be reversed for dates? + ("-", "Ascending"), + ("+", "Descending"), + ), required=False, ) - name = forms.CharField(max_length=128, required=False) def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user") + self.session_key = kwargs.pop("session_key", None) super().__init__(*args, **kwargs) + + # Set the initial values of the form fields from the session data + if self.session_key: + session = SessionStore(session_key=self.session_key) + + for field in self.fields: + if field in session: + self.fields[field].initial = session[field] + self.helper = FormHelper() + + div_block_ordering = Div( + Div(FloatingField("orderby"), css_class="col-6 col-md-12 col-xl-6"), + Div(FloatingField("ordering"), css_class="col-6 col-md-12 col-xl-6"), + css_class="row mb-0", + ) + div_block_checkbox = Div( + Div(Field("can_vote"), css_class="col-auto col-lg-12 col-xl-auto"), + Div(Field("voting_open"), css_class="col-auto col-lg-12 col-xl-auto"), + Div(Field("has_rounds"), css_class="col-auto col-lg-12 col-xl-auto"), + css_class="row mb-0", + ) + self.helper.layout = Layout( Div( - FloatingField("category"), - css_class="row", - ), - Div( - Div(FloatingField("college"), css_class="col-lg-6"), - Div(FloatingField("specialty"), css_class="col-lg-6"), - css_class="row", - ), - Div( - Div(FloatingField("name", autocomplete="off"), css_class="col-lg-6"), - css_class="row", + Div( + Div( + Div(FloatingField("nominee"), css_class="col-8"), + Div(FloatingField("decision"), css_class="col-4"), + Div(div_block_ordering, css_class="col-12 col-md-6 col-xl-12"), + Div(div_block_checkbox, css_class="col-12 col-md-6 col-xl-12"), + css_class="row mb-0", + ), + css_class="col", + ), + Div( + Field("college", size=6), + css_class="col-12 col-md-6 col-lg-4", + ), + css_class="row mb-0", ), ) + def apply_filter_set(self, filters: Dict, none_on_empty: bool = False): + # Apply the filter set to the form + for key in self.fields: + if key in filters: + self.fields[key].initial = filters[key] + elif none_on_empty: + if isinstance(self.fields[key], forms.MultipleChoiceField): + self.fields[key].initial = [] + else: + self.fields[key].initial = None + def search_results(self): - if self.cleaned_data.get("name"): - nominations = FellowshipNomination.objects.filter( - Q(profile__last_name__icontains=self.cleaned_data.get("name")) - | Q(profile__first_name__icontains=self.cleaned_data.get("name")) + # Save the form data to the session + if self.session_key is not None: + session = SessionStore(session_key=self.session_key) + + for key in self.cleaned_data: + session[key] = self.cleaned_data.get(key) + + session.save() + + def latest_round_subquery(key): + return Subquery( + FellowshipNominationVotingRound.objects.filter( + nomination=OuterRef("pk") + ) + .order_by("-voting_deadline") + .values(key)[:1] ) - else: - nominations = FellowshipNomination.objects.all() - if self.cleaned_data.get("college"): - nominations = nominations.filter(college=self.cleaned_data.get("college")) - if self.cleaned_data.get("specialty"): + + nominations = ( + FellowshipNomination.objects.all() + .annotate( + latest_round_deadline=latest_round_subquery("voting_deadline"), + latest_round_open=latest_round_subquery("voting_opens"), + latest_round_decision_outcome=latest_round_subquery( + "decision__outcome" + ), + ) + .distinct() + ) + + if self.cleaned_data.get("can_vote"): + # or not self.user.has_perm("scipost.can_view_all_nomination_voting_rounds"): + # Restrict rounds to those the user can vote on + nominations = nominations.with_user_votable_rounds(self.user).distinct() + + if nominee := self.cleaned_data.get("nominee"): + nominations = nominations.filter( + Q(profile__first_name__icontains=nominee) + | Q(profile__last_name__icontains=nominee) + ) + if college := self.cleaned_data.get("college"): + nominations = nominations.filter(college__id__in=college) + if decision := self.cleaned_data.get("decision"): + if decision == "pending": + nominations = nominations.filter( + voting_rounds__decision__isnull=True, + ) + else: + nominations = nominations.filter( + voting_rounds__decision__outcome=decision, + ) + if self.cleaned_data.get("voting_open"): nominations = nominations.filter( - profile__specialties__in=[ - self.cleaned_data.get("specialty"), + Q(voting_rounds__voting_opens__lte=timezone.now()) + & Q(voting_rounds__voting_deadline__gte=timezone.now()) + ) + if self.cleaned_data.get("has_rounds"): + nominations = nominations.filter(voting_rounds__isnull=False) + + # Ordering of nominations + # Only order if both fields are set + if (orderby_value := self.cleaned_data.get("orderby")) and ( + ordering_value := self.cleaned_data.get("ordering") + ): + # Remove the + from the ordering value, causes a Django error + ordering_value = ordering_value.replace("+", "") + + # Ordering string is built by the ordering (+/-), and the field name + # from the orderby field split by "," and joined together + nominations = nominations.order_by( + *[ + ordering_value + order_part + for order_part in orderby_value.split(",") ] ) + return nominations @@ -534,11 +749,8 @@ class FellowshipNominationCommentForm(forms.ModelForm): placeholder="Add a comment (visible to EdAdmin and all Fellows)", rows=2, ), - css_class="col-lg-10", - ), - Div( - ButtonHolder(Submit("submit", "Add comment")), css_class="col-lg-2" ), + Div(ButtonHolder(Submit("submit", "Add comment"))), css_class="row", ), ) @@ -547,31 +759,289 @@ class FellowshipNominationCommentForm(forms.ModelForm): class FellowshipNominationDecisionForm(forms.ModelForm): class Meta: model = FellowshipNominationDecision - fields = [ - "nomination", + fields: list[str] = [ + "voting_round", "outcome", "fixed_on", "comments", ] + widgets = { + "comments": forms.Textarea(attrs={"rows": 4}), + } + def __init__(self, *args, **kwargs): + voting_round = kwargs.pop("voting_round", None) super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.layout = Layout( - Field("nomination", type="hidden"), + Field("voting_round", type="hidden"), Field("fixed_on", type="hidden"), Div( - Div(Field("comments"), css_class="col-8"), + Div(Field("comments"), css_class="col-12 col-lg-8"), Div( Field("outcome"), ButtonHolder(Submit("submit", "Submit")), - css_class="col-4", + css_class="col-12 col-lg-4", ), css_class="row", ), ) + if voting_round: + self.fields["voting_round"].initial = voting_round + self.fields["outcome"].initial = voting_round.vote_outcome + + +################# +# Voting Rounds # +################# + +class FellowshipNominationVotingRoundSearchForm(forms.Form): + all_rounds = FellowshipNominationVotingRound.objects.all() + nominee = forms.CharField(max_length=100, required=False, label="Nominee") + + college = forms.MultipleChoiceField( + choices=College.objects.all().order_by("name").values_list("id", "name"), + required=False, + ) + + decision = forms.ChoiceField( + choices=[("", "Any"), ("pending", "Pending")] + + FellowshipNominationDecision.OUTCOME_CHOICES, + required=False, + ) + + can_vote = forms.BooleanField( + label="I can vote", + required=False, + initial=True, + ) + voting_open = forms.BooleanField( + label="Voting open", + required=False, + initial=True, + ) + + orderby = forms.ChoiceField( + label="Order by", + choices=( + ("voting_deadline", "Deadline"), + ("voting_opens", "Voting start"), + ("decision__outcome", "Decision"), + ("nomination__profile__last_name", "Nominee"), + ), + required=False, + ) + ordering = forms.ChoiceField( + label="Ordering", + choices=( + # FIXME: Emperically, the ordering appers to be reversed for dates? + ("-", "Ascending"), + ("+", "Descending"), + ), + required=False, + ) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user") + self.session_key = kwargs.pop("session_key", None) + super().__init__(*args, **kwargs) + + # Set the initial values of the form fields from the session data + if self.session_key: + session = SessionStore(session_key=self.session_key) + + for field in self.fields: + if field in session: + self.fields[field].initial = session[field] + + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Div( + Div( + Div(FloatingField("nominee"), css_class="col-6 col-lg-6"), + Div(FloatingField("decision"), css_class="col-3 col-lg-4"), + Div( + Div( + Div(Field("can_vote"), css_class="col-12"), + Div(Field("voting_open"), css_class="col-12"), + css_class="row mb-0", + ), + css_class="col-3 col-lg-2", + ), + Div(FloatingField("orderby"), css_class="col-6"), + Div(FloatingField("ordering"), css_class="col-6"), + css_class="row mb-0", + ), + css_class="col", + ), + Div( + Field("college", size=5), + css_class="col-12 col-md-6 col-lg-4", + ), + css_class="row mb-0", + ), + ) + + def apply_filter_set(self, filters: Dict, none_on_empty: bool = False): + # Apply the filter set to the form + for key in self.fields: + if key in filters: + self.fields[key].initial = filters[key] + elif none_on_empty: + if isinstance(self.fields[key], forms.MultipleChoiceField): + self.fields[key].initial = [] + else: + self.fields[key].initial = None + + def search_results(self): + # Save the form data to the session + if self.session_key is not None: + session = SessionStore(session_key=self.session_key) + + for key in self.cleaned_data: + session[key] = self.cleaned_data.get(key) + + session.save() + + rounds = FellowshipNominationVotingRound.objects.all() + + if self.cleaned_data.get("can_vote"): + # or not self.user.has_perm("scipost.can_view_all_nomination_voting_rounds"): + # Restrict rounds to those the user can vote on + rounds = rounds.where_user_can_vote(self.user) + + if nominee := self.cleaned_data.get("nominee"): + rounds = rounds.filter( + Q(nomination__profile__first_name__icontains=nominee) + | Q(nomination__profile__last_name__icontains=nominee) + ) + if college := self.cleaned_data.get("college"): + rounds = rounds.filter(nomination__college__id__in=college) + if decision := self.cleaned_data.get("decision"): + if decision == "pending": + rounds = rounds.filter(decision__isnull=True) + else: + rounds = rounds.filter(decision__outcome=decision) + if self.cleaned_data.get("voting_open"): + rounds = rounds.filter( + Q(voting_opens__lte=timezone.now()) + & Q(voting_deadline__gte=timezone.now()) + ) + + # Ordering of voting rounds + # Only order if both fields are set + if (orderby_value := self.cleaned_data.get("orderby")) and ( + ordering_value := self.cleaned_data.get("ordering") + ): + # Remove the + from the ordering value, causes a Django error + ordering_value = ordering_value.replace("+", "") + + # Ordering string is built by the ordering (+/-), and the field name + # from the orderby field split by "," and joined together + rounds = rounds.order_by( + *[ + ordering_value + order_part + for order_part in orderby_value.split(",") + ] + ) + + return rounds + + +from datetime import date + + +class FellowshipNominationVotingRoundStartForm(forms.ModelForm): + class Meta: + model = FellowshipNominationVotingRound + fields = ["voting_opens", "voting_deadline"] + + widgets = { + "voting_opens": forms.DateInput(attrs={"type": "date"}), + "voting_deadline": forms.DateInput(attrs={"type": "date"}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + today = date.today() + self.fields["voting_opens"].widget.attrs.update( + { + "min": today.strftime("%Y-%m-%d"), + "value": today.strftime("%Y-%m-%d"), + } + ) + + in_two_weeks = today + timedelta(days=14) + self.fields["voting_deadline"].widget.attrs.update( + { + "min": today.strftime("%Y-%m-%d"), + "value": in_two_weeks.strftime("%Y-%m-%d"), + } + ) + + self.helper = FormHelper() + self.helper.attrs = { + "hx-target": f"#nomination-{self.instance.nomination.id}-round-tab-holder", + "hx-swap": "outerHTML", + "hx-post": reverse( + "colleges:_hx_nomination_voting_rounds_tab", + kwargs={ + "nomination_id": self.instance.nomination.id, + "round_id": self.instance.id, + }, + ), + } + self.helper.layout = Layout( + Div( + Div(Field("voting_opens"), css_class="col"), + Div(Field("voting_deadline"), css_class="col"), + Div( + ButtonHolder(Submit("submit", "Start")), + css_class="col-auto align-self-end mb-3", + ), + css_class="row mb-0", + ) + ) + + def clean(self): + open_date = self.cleaned_data.get("voting_opens", None) + deadline_date = self.cleaned_data.get("voting_deadline", None) + + if open_date is None or deadline_date is None: + self.add_error( + None, + "Both the voting opens and voting deadline must be set.", + ) + + # Check that the voting deadline is after the voting opens + if deadline_date <= open_date: + self.add_error( + "voting_deadline", + "The voting deadline must be after the voting opens.", + ) + + # Check that the voting opens after today + if open_date.date() < date.today(): + self.add_error( + "voting_opens", "The voting opening date may not be in the past." + ) + + if self.instance.eligible_to_vote.count() == 0: + self.add_error( + None, + "There must be at least one eligible voter to start the round. " + "Please add voters to the round before setting the dates.", + ) + + +############### +# Invitations # +############### class FellowshipInvitationResponseForm(forms.ModelForm): class Meta: model = FellowshipInvitation @@ -592,20 +1062,63 @@ class FellowshipInvitationResponseForm(forms.ModelForm): self.helper.layout = Layout( Field("nomination", type="hidden"), Div( - Div(Field("response"), css_class="col-lg-5"), - Div(Field("postpone_start_to"), css_class="col-lg-5"), - css_class="row", - ), - Div( + Div( + Div( + Div(Field("response"), css_class="col-12"), + Div(Field("postpone_start_to"), css_class="col-12"), + css_class="row mb-0", + ), + css_class="col-12 col-md-5", + ), Div( Field( "comments", placeholder="Add a comment (visible to EdAdmin)", - rows=2, + rows=4, ), - css_class="col-lg-10", + css_class="col-12 col-md-7", ), - Div(ButtonHolder(Submit("submit", "Submit")), css_class="col-lg-2"), - css_class="row mt-0", + Div(ButtonHolder(Submit("submit", "Update")), css_class="col-auto"), + css_class="row mb-0", ), ) + + def clean(self): + has_contributor = hasattr( + self.cleaned_data["nomination"].profile, "contributor" + ) + invitation_accepted = self.cleaned_data["response"] == ( + FellowshipInvitation.RESPONSE_ACCEPTED + ) + invitation_postponed = self.cleaned_data["response"] == ( + FellowshipInvitation.RESPONSE_POSTPONED + ) + postponed_date = self.cleaned_data["postpone_start_to"] + + if (invitation_accepted or invitation_postponed) and not has_contributor: + self.add_error( + "response", + "This profile does not have a Contributor account to create a Fellowship with. Please create one before updating the invitation response to a positive answer.", + ) + + if postponed_date and (timezone.now().date() > postponed_date): + self.add_error( + "postpone_start_to", + "You cannot set a postponed start date in the past.", + ) + + if ( + invitation_accepted + and (postponed_date is not None) + and (postponed_date != timezone.now().date()) + ): + self.add_error( + "postpone_start_to", + "If the invitation is accepted for immediate start, you cannot postpone its start date.", + ) + + if invitation_postponed and not postponed_date: + self.add_error( + "postpone_start_to", + "If the invitation is postponed, you must set a start date in the future.", + ) diff --git a/scipost_django/colleges/management/commands/create_nomination_voting_rounds.py b/scipost_django/colleges/management/commands/create_nomination_voting_rounds.py index 1021ad0c02850e1cad013964c79deca1ae367ecb..04b93e92a903f2b6c954dbda4aac8d4716ca3856 100644 --- a/scipost_django/colleges/management/commands/create_nomination_voting_rounds.py +++ b/scipost_django/colleges/management/commands/create_nomination_voting_rounds.py @@ -43,3 +43,31 @@ class Command(BaseCommand): .senior() .filter(college=nomination.college) ) + voting_round.save() + + if voting_round.eligible_to_vote.count() <= 5: + self.stdout.write( + self.style.ERROR( + "Only {nr_eligible_voters} eligible voters for {first_name} {last_name}, cannot create round.".format( + first_name=nomination.profile.first_name, + last_name=nomination.profile.last_name, + nr_eligible_voters=voting_round.eligible_to_vote.count(), + ) + ) + ) + voting_round.delete() + else: + self.stdout.write( + self.style.SUCCESS( + "Created voting round for {first_name} {last_name} with {nr_eligible_voters} eligible voters.".format( + first_name=nomination.profile.first_name, + last_name=nomination.profile.last_name, + nr_eligible_voters=voting_round.eligible_to_vote.count(), + ) + ) + ) + + if len(nominations) == 0: + self.stdout.write( + self.style.ERROR(f"No nominations found needing handling.") + ) diff --git a/scipost_django/colleges/managers.py b/scipost_django/colleges/managers.py index a7f0ef26c79c536883fc3fb6062109afafb82e2e..6bf0611656c7f027007ceac6f6a74cd694e8804a 100644 --- a/scipost_django/colleges/managers.py +++ b/scipost_django/colleges/managers.py @@ -3,7 +3,7 @@ __license__ = "AGPL v3" from django.db import models -from django.db.models import Q +from django.db.models import Q, Prefetch from django.utils import timezone from .constants import POTENTIAL_FELLOWSHIP_ELECTION_VOTE_ONGOING @@ -95,6 +95,20 @@ class FellowQuerySet(models.QuerySet): except AttributeError: return [] + def no_competing_interests_with(self, profile): + """ + Returns all Fellowships whose profiles have no competing interests with the specified profile. + """ + from ethics.models import CompetingInterest + + profile_CI, related_CI = CompetingInterest.objects.filter( + Q(profile=profile) | Q(related_profile=profile) + ).values_list("profile", "related_profile") + + return self.exclude( + contributor__profile__pk__in=profile_CI + related_CI, + ) + class PotentialFellowshipQuerySet(models.QuerySet): def vote_needed(self, contributor): @@ -127,7 +141,15 @@ class PotentialFellowshipQuerySet(models.QuerySet): class FellowshipNominationQuerySet(models.QuerySet): def needing_handling(self): - return self.exclude(decision__isnull=False).exclude(voting_rounds__isnull=False) + return self.exclude(voting_rounds__isnull=False).exclude( + voting_rounds__decision__isnull=False + ) + + def with_user_votable_rounds(self, user): + # votable_rounds = self.voting_rounds.where_user_can_vote(user) + return self.filter( + Q(voting_rounds__eligible_to_vote__in=user.contributor.fellowships.active()) + ) class FellowshipNominationVotingRoundQuerySet(models.QuerySet): @@ -139,6 +161,10 @@ class FellowshipNominationVotingRoundQuerySet(models.QuerySet): now = timezone.now() return self.filter(voting_deadline__lte=now) + def where_user_can_vote(self, user): + user_fellowships = user.contributor.fellowships.active() + return self.filter(eligible_to_vote__in=user_fellowships) + class FellowshipNominationVoteQuerySet(models.QuerySet): def agree(self): @@ -149,3 +175,6 @@ class FellowshipNominationVoteQuerySet(models.QuerySet): def disagree(self): return self.filter(vote=self.model.VOTE_DISAGREE) + + def veto(self): + return self.filter(vote=self.model.VOTE_VETO) diff --git a/scipost_django/colleges/migrations/0040_auto_20230719_2108.py b/scipost_django/colleges/migrations/0040_auto_20230719_2108.py new file mode 100644 index 0000000000000000000000000000000000000000..ffa1982e87caadc7d5b161bac4178124dc981432 --- /dev/null +++ b/scipost_django/colleges/migrations/0040_auto_20230719_2108.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2.18 on 2023-07-19 19:08 + +from django.db import migrations, models +import django.db.models.deletion + + +def copy_decision_from_nomination_to_voting_round(apps, schema_editor): + FellowshipNominationDecision = apps.get_model( + "colleges", "FellowshipNominationDecision" + ) + for decision in FellowshipNominationDecision.objects.all(): + decision.voting_round = decision.nomination.voting_rounds.first() + decision.save() + + +def copy_decision_from_voting_round_to_nomination(apps, schema_editor): + FellowshipNominationDecision = apps.get_model( + "colleges", "FellowshipNominationDecision" + ) + for decision in FellowshipNominationDecision.objects.all(): + decision.nomination = decision.voting_round.nomination + decision.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("colleges", "0039_nomination_add_events"), + ] + + operations = [ + migrations.AlterModelOptions( + name="fellowshipnominationdecision", + options={ + "ordering": ["voting_round"], + "verbose_name_plural": "Fellowship Nomination Decisions", + }, + ), + migrations.AddField( + model_name="fellowshipnominationdecision", + name="voting_round", + field=models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="decision", + to="colleges.fellowshipnominationvotinground", + ), + ), + migrations.RunPython( + copy_decision_from_nomination_to_voting_round, + copy_decision_from_voting_round_to_nomination, + ), + migrations.RemoveField( + model_name="fellowshipnominationdecision", + name="nomination", + ), + ] diff --git a/scipost_django/colleges/migrations/0041_auto_20230720_1608.py b/scipost_django/colleges/migrations/0041_auto_20230720_1608.py new file mode 100644 index 0000000000000000000000000000000000000000..408b17115b3745f399be850eb626ce29a36ab6c7 --- /dev/null +++ b/scipost_django/colleges/migrations/0041_auto_20230720_1608.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.18 on 2023-07-20 14:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("colleges", "0040_auto_20230719_2108"), + ] + + operations = [ + migrations.AlterField( + model_name="fellowshipnominationdecision", + name="voting_round", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="decision", + to="colleges.fellowshipnominationvotinground", + ), + ), + migrations.AlterField( + model_name="fellowshipnominationvote", + name="vote", + field=models.CharField( + choices=[ + ("agree", "Agree"), + ("abstain", "Abstain"), + ("disagree", "Disagree"), + ("veto", "Veto"), + ], + max_length=16, + ), + ), + ] diff --git a/scipost_django/colleges/migrations/0042_auto_20230914_1057.py b/scipost_django/colleges/migrations/0042_auto_20230914_1057.py new file mode 100644 index 0000000000000000000000000000000000000000..8d541a2576032e89376c65fd43bfa3ffe535317b --- /dev/null +++ b/scipost_django/colleges/migrations/0042_auto_20230914_1057.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.18 on 2023-09-14 08:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('colleges', '0041_auto_20230720_1608'), + ] + + operations = [ + migrations.AlterField( + model_name='fellowshipnominationvotinground', + name='voting_deadline', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='fellowshipnominationvotinground', + name='voting_opens', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/scipost_django/colleges/migrations/0043_alter_fellowshipnominationevent_options.py b/scipost_django/colleges/migrations/0043_alter_fellowshipnominationevent_options.py new file mode 100644 index 0000000000000000000000000000000000000000..8d5b931b52308ffc4f77a0d26ea3d7a8bc2d0728 --- /dev/null +++ b/scipost_django/colleges/migrations/0043_alter_fellowshipnominationevent_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.18 on 2023-10-02 15:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('colleges', '0042_auto_20230914_1057'), + ] + + operations = [ + migrations.AlterModelOptions( + name='fellowshipnominationevent', + options={'get_latest_by': 'on', 'ordering': ['nomination', '-on'], 'verbose_name_plural': 'Fellowhip Nomination Events'}, + ), + ] diff --git a/scipost_django/colleges/models/nomination.py b/scipost_django/colleges/models/nomination.py index d908ea8f785e7165b5d06024527275af79b5dce0..95482e2251ab9b14afa55f61d0805ad91a32fe7b 100644 --- a/scipost_django/colleges/models/nomination.py +++ b/scipost_django/colleges/models/nomination.py @@ -4,6 +4,9 @@ __license__ = "AGPL v3" from django.db import models from django.utils import timezone +from django.utils.functional import cached_property + +from colleges.permissions import is_edadmin from ..managers import ( FellowshipNominationQuerySet, @@ -11,6 +14,8 @@ from ..managers import ( FellowshipNominationVoteQuerySet, ) +from colleges.models import Fellowship + from scipost.models import get_sentinel_user @@ -81,13 +86,17 @@ class FellowshipNomination(models.Model): def latest_voting_round(self): return self.voting_rounds.first() + @property + def decision(self): + """The singular non-deprecated decision for this nomination.""" + return self.latest_voting_round.decision + @property def decision_blocks(self): """ List of blocking facts (if any) preventing fixing a decision. """ - latest_round = self.voting_rounds.first() - if latest_round: + if latest_round := self.latest_voting_round: eligible_count = latest_round.eligible_to_vote.count() if eligible_count < 3: return "Fewer than 3 eligible voters (insufficient)." @@ -100,6 +109,20 @@ class FellowshipNomination(models.Model): return "Latest voting round is ongoing, and not everybody has voted." return "No voting round found." + # FIX: This is wrong semantically... + @property + def get_eligible_voters(self): + specialties_slug_list = [s.slug for s in self.profile.specialties.all()] + + eligible_voters = ( + Fellowship.objects.active() + .senior() + .specialties_overlap(specialties_slug_list) + .distinct() + ) + + return eligible_voters + class FellowshipNominationEvent(models.Model): nomination = models.ForeignKey( @@ -117,6 +140,7 @@ class FellowshipNominationEvent(models.Model): class Meta: ordering = ["nomination", "-on"] verbose_name_plural = "Fellowhip Nomination Events" + get_latest_by = "on" def __str__(self): return ( @@ -167,9 +191,9 @@ class FellowshipNominationVotingRound(models.Model): blank=True, ) - voting_opens = models.DateTimeField() + voting_opens = models.DateTimeField(blank=True, null=True) - voting_deadline = models.DateTimeField() + voting_deadline = models.DateTimeField(blank=True, null=True) objects = FellowshipNominationVotingRoundQuerySet.as_manager() @@ -181,6 +205,8 @@ class FellowshipNominationVotingRound(models.Model): verbose_name_plural = "Fellowship Nomination Voting Rounds" def __str__(self): + if self.voting_deadline is None or self.voting_opens is None: + return f"Unscheduled voting round for {self.nomination}" return ( f'Voting round ({self.voting_opens.strftime("%Y-%m-%d")} -' f' {self.voting_deadline.strftime("%Y-%m-%d")}) for {self.nomination}' @@ -192,16 +218,82 @@ class FellowshipNominationVotingRound(models.Model): return vote.vote return None + def add_voter(self, fellow): + self.eligible_to_vote.add(fellow) + self.save() + + @property + def is_open(self): + if (self.voting_deadline is None) or (self.voting_opens is None): + return False + return self.voting_opens <= timezone.now() <= self.voting_deadline + + @property + def is_scheduled(self): + return (self.voting_opens is not None) and (self.voting_opens > timezone.now()) + + @property + def is_unscheduled(self): + return (self.voting_opens is None) or (self.voting_deadline is None) + + @property + def is_closed(self): + return (self.voting_deadline is not None) and ( + self.voting_deadline < timezone.now() + ) + + @property + def vote_outcome(self): + """The outcome as determined by the votes.""" + if self.votes.veto(): + return FellowshipNominationDecision.OUTCOME_NOT_ELECTED + + nr_votes_agree = self.votes.agree().count() + nr_votes_disagree = self.votes.disagree().count() + nr_non_abstaining_votes = nr_votes_agree + nr_votes_disagree + + # Guard division by zero + if nr_non_abstaining_votes == 0: + return FellowshipNominationDecision.OUTCOME_NOT_ELECTED + + # By-laws 1.3.4 grand fellowship if there is a majority of non-abstaining votes. + # Agree is counted as +1, disagree as -1 + agree_ratio = (nr_votes_agree - nr_votes_disagree) / nr_non_abstaining_votes + if agree_ratio >= 0.5: + return FellowshipNominationDecision.OUTCOME_ELECTED + else: + return FellowshipNominationDecision.OUTCOME_NOT_ELECTED + + def can_view(self, user) -> bool: + """Return whether the user can view this voting round. + They must be authenticated and have voting eligibility or be edadmin.""" + + eligibility_per_fellowship = [ + fellowship in self.eligible_to_vote.all() + for fellowship in user.contributor.fellowships.all() + ] + eligible_to_vote = any(eligibility_per_fellowship) + + return user.is_authenticated and (eligible_to_vote or is_edadmin(user)) + class FellowshipNominationVote(models.Model): VOTE_AGREE = "agree" VOTE_ABSTAIN = "abstain" VOTE_DISAGREE = "disagree" + VOTE_VETO = "veto" VOTE_CHOICES = ( (VOTE_AGREE, "Agree"), (VOTE_ABSTAIN, "Abstain"), (VOTE_DISAGREE, "Disagree"), + (VOTE_VETO, "Veto"), ) + VOTE_BS_CLASSES = { + VOTE_AGREE: "success", + VOTE_ABSTAIN: "warning", + VOTE_DISAGREE: "danger", + VOTE_VETO: "black", + } voting_round = models.ForeignKey( "colleges.FellowshipNominationVotingRound", @@ -221,6 +313,10 @@ class FellowshipNominationVote(models.Model): objects = FellowshipNominationVoteQuerySet.as_manager() + @property + def get_vote_bs_class(self): + return self.VOTE_BS_CLASSES[self.vote] + class Meta: constraints = [ models.UniqueConstraint( @@ -235,18 +331,20 @@ class FellowshipNominationVote(models.Model): class FellowshipNominationDecision(models.Model): - nomination = models.OneToOneField( - "colleges.FellowshipNomination", + voting_round = models.OneToOneField( + "colleges.FellowshipNominationVotingRound", on_delete=models.CASCADE, related_name="decision", + null=True, + blank=True, ) OUTCOME_ELECTED = "elected" OUTCOME_NOT_ELECTED = "notelected" - OUTCOME_CHOICES = ( + OUTCOME_CHOICES = [ (OUTCOME_ELECTED, "Elected"), (OUTCOME_NOT_ELECTED, "Not elected"), - ) + ] outcome = models.CharField(max_length=16, choices=OUTCOME_CHOICES) fixed_on = models.DateTimeField(default=timezone.now) @@ -261,12 +359,12 @@ class FellowshipNominationDecision(models.Model): class Meta: ordering = [ - "nomination", + "voting_round", ] verbose_name_plural = "Fellowship Nomination Decisions" def __str__(self): - return f"Decision for {self.nomination}: {self.get_outcome_display()}" + return f"Decision for {self.voting_round}: {self.get_outcome_display()}" @property def elected(self): @@ -324,3 +422,14 @@ class FellowshipInvitation(models.Model): @property def declined(self): return self.response == self.RESPONSE_DECLINED + + @property + def get_response_color(self): + if self.response in [self.RESPONSE_ACCEPTED, self.RESPONSE_POSTPONED]: + return "success" + elif self.response in [self.RESPONSE_DECLINED, self.RESPONSE_NOT_YET_INVITED]: + return "danger" + elif self.response in [self.RESPONSE_UNRESPONSIVE]: + return "warning" + else: + return "primary" diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_comments.html b/scipost_django/colleges/templates/colleges/_hx_nomination_comments.html index 4534351a8f395c00177f9297a9fe34bba552517d..010888e146f9839c0c870a05a095c35b0629a363 100644 --- a/scipost_django/colleges/templates/colleges/_hx_nomination_comments.html +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_comments.html @@ -1,20 +1,18 @@ {% load crispy_forms_tags %} {% load automarkup %} + {% for comment in nomination.comments.all %} <details class="m-2 border" open> <summary class="bg-light p-2">{{ comment.by }} on {{ comment.on }}</summary> - <div class="m-2"> - {% automarkup comment.text %} - </div> + <div class="m-2">{% automarkup comment.text %}</div> </details> {% empty %} - <p>No comments have been received</p> + <p class="m-2">No comments have been received.</p> {% endfor %} <div class="m-2 mt-4"> <form hx-post="{% url 'colleges:_hx_nomination_comments' nomination_id=nomination.id %}" - hx-target="#nomination-{{ nomination.id }}-comments" - > + hx-target="#nomination-{{ nomination.id }}-comments"> {% crispy form %} </form> </div> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_decision.html b/scipost_django/colleges/templates/colleges/_hx_nomination_decision.html deleted file mode 100644 index 8444d39ab90954534d8af36e5958e0bf801ba3e0..0000000000000000000000000000000000000000 --- a/scipost_django/colleges/templates/colleges/_hx_nomination_decision.html +++ /dev/null @@ -1,22 +0,0 @@ -{% load crispy_forms_tags %} -{% load automarkup %} -{% if nomination.decision %} - {{ nomination.decision }} - <p>Fixed on: {{ nomination.decision.fixed_on }}</p> - {% if nomination.decision.comments %} - <p>Comments:</p> - {% automarkup nomination.decision.comments %} - {% endif %} -{% else %} - {% with blocks=nomination.decision_blocks %} - {% if blocks %} - <p>The decision cannot be fixed at this moment: {{ blocks }}</p> - {% else %} - <form hx-post="{% url 'colleges:_hx_nomination_decision' nomination_id=nomination.id %}" - hx-target="#nomination-{{ nomination.id }}-decision" - > - {% crispy decision_form %} - </form> - {% endif %} - {% endwith %} -{% endif %} diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_decision_form.html b/scipost_django/colleges/templates/colleges/_hx_nomination_decision_form.html new file mode 100644 index 0000000000000000000000000000000000000000..4435e60cace56a8f110945b8044a8e08aa6e022d --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_decision_form.html @@ -0,0 +1,17 @@ +{% load crispy_forms_tags %} +{% load automarkup %} + +<div id="nomination-{{ voting_round.id }}-decision"> + {% with blocks=voting_round.decision_blocks %} + + {% if blocks %} + <p>The decision cannot be fixed at this moment: {{ blocks }}</p> + {% else %} + <form hx-post="{% url 'colleges:_hx_nomination_decision_form' round_id=voting_round.id %}" + hx-target="#nomination-{{ voting_round.id }}-decision"> + {% crispy decision_form %} + </form> + {% endif %} + + {% endwith %} +</div> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_details.html b/scipost_django/colleges/templates/colleges/_hx_nomination_details.html new file mode 100644 index 0000000000000000000000000000000000000000..fa25047e52674eea60def9fdd4b2b296d9f303d1 --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_details.html @@ -0,0 +1,10 @@ +<details id="nomination-{{ nomination.id }}-details" + class="border border-2 mx-3 p-2 bg-primary bg-opacity-10"> + <summary class="list-none">{% include "colleges/_hx_nomination_summary.html" with nomination=nomination %}</summary> + + <div id="nomination-{{ nomination.id }}-details-contents" + class="p-2 mt-2 bg-white" + hx-get="{% url 'colleges:_hx_nomination_details_contents' nomination_id=nomination.id %}" + hx-trigger="toggle once from:#nomination-{{ nomination.id }}-details"></div> + +</details> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_details_contents.html b/scipost_django/colleges/templates/colleges/_hx_nomination_details_contents.html new file mode 100644 index 0000000000000000000000000000000000000000..27a967d38b5f08b246d935d98bea4feb95b3e87e --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_details_contents.html @@ -0,0 +1,183 @@ +{% load user_groups %} +{% is_ed_admin request.user as is_ed_admin %} + +<div class="p-2"> + <div class="row mb-0"> + + <div id="profile-{{ nomination.profile.id }}-specialties" + class="border border-danger mb-4 d-none-empty"></div> + + <div class="col-12 col-md mb-3"> + <div class="card"> + <div class="card-header">Details</div> + <div class="card-body p-0"> + <table class="table mb-0"> + <tr> + <td>Field</td> + <td>{{ nomination.profile.acad_field }}</td> + </tr> + <tr> + <td>Specialties</td> + <td id="profile-{{ nomination.profile.id }}-specialties-code-display"> + + {% include "profiles/_hx_profile_specialty_codes_edit.html" with profile=nomination.profile %} + + </td> + </tr> + <tr> + <td>ORCID ID</td> + <td> + + {% if nomination.profile.orcid_id %} + <a href="//orcid.org/{{ nomination.profile.orcid_id }}" + target="_blank" + rel="noopener">{{ nomination.profile.orcid_id }}</a> + {% else %} + unknown + {% endif %} + + </td> + </tr> + <tr> + <td>Webpage</td> + <td> + + {% if nomination.profile.webpage %} + <a href="{{ nomination.profile.webpage }}" + target="_blank" + rel="noopener">{{ nomination.profile.webpage }}</a> + {% else %} + unknown + {% endif %} + + </td> + </tr> + </table> + </div> + </div> + </div> + <div class="col-12 col-md mb-3"> + <div class="card"> + <div class="card-header">Publications in SciPost Journals</div> + <div class="card-body"> + <ul> + + {% for pub in nomination.profile.publications.all %} + <li> + <a href="{{ pub.get_absolute_url }}">{{ pub.citation }}</a> + </li> + {% empty %} + <li>No Publication found</li> + {% endfor %} + + </ul> + </div> + </div> + </div> + </div> + + <div class="row"> + <div class="col"> + <div class="card"> + <div class="card-header">Affiliations</div> + <div class="card-body p-0"> + {% include 'profiles/_affiliations_table.html' with profile=nomination.profile actions=False %} + </div> + </div> + </div> + </div> + + <div class="row mb-0"> + + {% comment %} or "active_senior_fellow" in user_roles {% endcomment %} + + {% if is_ed_admin %} + <div class="col-12 col-md mb-3"> + <details class="card"> + <summary class="card-header d-flex flex-row justify-content-between list-triangle"> + <span>Events</span> + <span>({{ nomination.events.all.count }})</span> + </summary> + <div class="card-body">{% include 'colleges/_nomination_events_table.html' with nomination=nomination %}</div> + </details> + </div> + {% endif %} + + <div class="col-12 col-md mb-3"> + <details class="card"> + <summary class="card-header d-flex flex-row justify-content-between list-triangle"> + <span>Comments</span> + <span>({{ nomination.comments.all.count }})</span> + </summary> + <div class="card-body"> + <div class="p-3"> + + {% if nomination.nominator_comments %} + <div class="row"> + <div class="fs-6">Nominator comments:</div> + <div> + <em>{{ nomination.nominator_comments }}</em> + </div> + </div> + <hr class="text-muted" /> + {% endif %} + + <div id="nomination-{{ nomination.id }}-comments" + hx-get="{% url 'colleges:_hx_nomination_comments' nomination_id=nomination.id %}" + hx-trigger="intersect once"></div> + </div> + </div> + </details> + + </div> + </div> + + {% if nomination.voting_rounds.exists or perms.scipost.can_manage_college_composition %} + <details + {% if not nomination.invitation or 'edadmin' not in user_roles %}open{% endif %} + class="card mb-3"> + <summary class="card-header list-triangle">Voting Rounds</summary> + <div class="card-body"> + <div hx-get="{% url 'colleges:_hx_nomination_voting_rounds_tab' nomination_id=nomination.id round_id=nomination.latest_voting_round.id|default:0 %}" + hx-trigger="intersect once"></div> + </div> + </details> + {% endif %} + + {% if is_ed_admin and nomination.decision.outcome == 'elected' %} + <details + {% if nomination.invitation %}open{% endif %} + class="card"> + <summary class="card-header d-flex flex-row justify-content-between list-triangle"> + <div>Invitation</div> + <div>{{ nomination.invitation.get_response_display }}</div> + </summary> + <div class="card-body"> + <div class="row mb-0"> + <div class="col-auto d-flex flex-column justify-content-between"> + <div> + <h3>Checklist</h3> + {% include "colleges/_nominations_invitation_checklist.html" with invitation=nomination.invitation %} + </div> + + <div> + <h3>Invitation status</h3> + <div class="fs-6 badge bg-{{ nomination.invitation.get_response_color }}"> + {{ nomination.invitation.get_response_display }} + </div> + </div> + </div> + + <div class="col-12 col-md p-2"> + <h4>Update the response to this invitation:</h4> + <div id="invitation-{{ nomination.invitation.id }}-update-response" + hx-get="{% url 'colleges:_hx_fellowship_invitation_update_response' invitation_id=nomination.invitation.id %}" + hx-trigger="intersect once"></div> + </div> + + </div> + </div> + </details> + {% endif %} + +</div> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_eligible_voters_table.html b/scipost_django/colleges/templates/colleges/_hx_nomination_eligible_voters_table.html new file mode 100644 index 0000000000000000000000000000000000000000..113fff51a6498b3671e87cf1bf6a72c50b2d9528 --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_eligible_voters_table.html @@ -0,0 +1,47 @@ +{% if round.eligible_to_vote.all %} + <table class="table"> + <thead class="table-light"> + <tr> + <th>Fellow</th> + <th>College</th> + <th>Specialties</th> + <th>Type</th> + <th></th> + </tr> + </thead> + + <tbody> + + {% for voter in round.eligible_to_vote.all %} + <tr class="align-middle"> + <td>{{ voter.contributor }}</td> + <td>{{ voter.college.name }}</td> + <td> + + {% for specialty in voter.contributor.profile.specialties.all %} + <div class="single d-inline + {% if specialty in nominee_specialties %}text-success{% endif %} + " data-specialty="{{ specialty.slug }}" data-bs-placement="bottom" title="{{ specialty }}"> + {{ specialty.code }} + </div> + {% endfor %} + + + </td> + <td>{{ voter.get_status_display }}</td> + {% comment %} Actions {% endcomment %} + <td class="text-end"> + <a class="btn btn-sm btn-danger" + role="button" + hx-get="{% url 'colleges:_hx_nomination_round_remove_voter' round_id=round.id voter_id=voter.id %}" + hx-target="closest tr"><small>{% include 'bi/trash-fill.html' %}</small></a> + </td> + </tr> + {% endfor %} + + + </tbody> + </table> +{% else %} + <p class="text-danger">No eligible voters found.</p> +{% endif %} diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_form.html b/scipost_django/colleges/templates/colleges/_hx_nomination_form.html index 5543d18466f0bd4b21a88f127f0d9cd311ccfc9d..3b12c708bd5c53abd570c467de7b17362ad64d0f 100644 --- a/scipost_django/colleges/templates/colleges/_hx_nomination_form.html +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_form.html @@ -1,11 +1,10 @@ {% load crispy_forms_tags %} -<div class="m-2 p-4 border border-warning"> - <h3>Nomination to Fellowship: <span class="bg-success bg-opacity-25 p-2"><em>{{ profile }}</em></span></h3> - <form - hx-post="{% url 'colleges:_hx_nomination_form' profile_id=profile.pk %}" - hx-target="#nomination_form_response" - hx-indicator="#nomination_form_response-indicator" - > - {% crispy nomination_form %} - </form> -</div> + +<h3> + Nomination to Fellowship: <span class="bg-success bg-opacity-25 p-2"><em>{{ profile }}</em></span> +</h3> +<form hx-post="{% url 'colleges:_hx_nomination_form' profile_id=profile.pk %}" + hx-target="#new-nomination-container" + hx-indicator="#nomination_form_response-indicator"> + {% crispy nomination_form %} +</form> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_invitation_update_response.html b/scipost_django/colleges/templates/colleges/_hx_nomination_invitation_update_response.html index 93e3b3a96fdf875d148449139ba2a9e0ac558463..8969da1766f0485a125de5e4c3052cf658992c95 100644 --- a/scipost_django/colleges/templates/colleges/_hx_nomination_invitation_update_response.html +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_invitation_update_response.html @@ -1,8 +1,7 @@ {% load crispy_forms_tags %} <div class="m-2 mt-4"> <form hx-post="{% url 'colleges:_hx_fellowship_invitation_update_response' invitation_id=invitation.id %}" - hx-target="#invitations_tablist" - > + hx-target="#invitation-{{ invitation.id }}-update-response"> {% crispy form %} </form> </div> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_li.html b/scipost_django/colleges/templates/colleges/_hx_nomination_li.html deleted file mode 100644 index 66b4bb52a306265d35e95366d4b8a352fefee7a6..0000000000000000000000000000000000000000 --- a/scipost_django/colleges/templates/colleges/_hx_nomination_li.html +++ /dev/null @@ -1,27 +0,0 @@ -<div class="border border-dark"> - <details id="nomination-{{ nomination.id }}-li-details"> - <summary class="bg-light p-2"> - {{ nomination.profile }} - <span class="float-end"> - {{ nomination.college }} -  {{ nomination.nominated_on|date:"Y-m-d" }} - <span class="ms-4">Outcome:</span> - {% if nomination.decision %} - {{ nomination.decision.get_outcome_display }} - {% else %} - pending - {% endif %} - {% if nomination.fellowship and nomination.fellowship.is_active %} -  <span class="p-2 bg-success text-white">Fellow</span> - {% endif %} - </span> - </summary> - - <div id="nomination-{{ nomination.id }}-li-contents" - hx-get="{% url 'colleges:_hx_nomination_li_contents' nomination_id=nomination.id %}" - hx-trigger="toggle from:#nomination-{{ nomination.id }}-li-details" - hx-target="this" - > - </div> - </details> -</div> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_li_contents.html b/scipost_django/colleges/templates/colleges/_hx_nomination_li_contents.html deleted file mode 100644 index 09273f0b9831677ab1402711e5befb8e3b13fcd9..0000000000000000000000000000000000000000 --- a/scipost_django/colleges/templates/colleges/_hx_nomination_li_contents.html +++ /dev/null @@ -1,223 +0,0 @@ -<div class="p-2"> - <p>Nominated by {{ nomination.nominated_by }} on {{ nomination.nominated_on|date:"Y-m-d" }}</p> - <div class="row"> - <div class="col"> - <div class="card m-2 mt-4"> - <div class="card-header"> - Details - </div> - <div class="card-body"> - <table class="table"> - <tr> - <td>Field</td><td>{{ nomination.profile.acad_field }}</td> - </tr> - <tr> - <td>Specialties</td> - <td> - {% for specialty in nomination.profile.specialties.all %} - <div class="single d-inline" data-specialty="{{ specialty }}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ specialty }}">{{ specialty.code }}</div> - {% empty %} - undefined - {% endfor %} - </td> - </tr> - <tr> - <td>ORCID ID</td> - <td> - {% if nomination.profile.orcid_id %} - <a href="//orcid.org/{{ nomination.profile.orcid_id }}" target="_blank" rel="noopener">{{ nomination.profile.orcid_id }}</a> - {% else %} - unknown - {% endif %} - </td> - </tr> - <tr><td>Webpage</td> - <td> - {% if nomination.profile.webpage %} - <a href="{{ nomination.profile.webpage }}" target="_blank" rel="noopener">{{ nomination.profile.webpage }}</a> - {% else %} - unknown - {% endif %} - </td> - </tr> - </table> - </div> - </div> - </div> - <div class="col"> - <div class="card m-2 mt-4"> - <div class="card-header"> - Publications in SciPost Journals - </div> - <div class="card-body"> - <ul> - {% for pub in nomination.profile.publications.all %} - <li><a href="{{ pub.get_absolute_url }}">{{ pub.citation }}</a></li> - {% empty %} - <li>No Publication found</li> - {% endfor %} - </ul> - </div> - </div> - </div> - </div> - <table class="table"> - <tr> - <td>Affiliations</td> - <td> - {% include 'profiles/_affiliations_table.html' with profile=nomination.profile actions=False %}</td> - </tr> - </table> - - <hr> - - {% if "edadmin" in user_roles or "active_senior_fellow" in user_roles %} - <details class="m-2 mt-4 border border-danger"> - <summary class="p-2 bg-light">Events</summary> - {% include 'colleges/_nomination_events_table.html' with nomination=nomination %} - </details> - {% endif %} - - <div class="card m-2 mt-4"> - <div class="card-header"> - Comments - </div> - <div class="card-body"> - {% if nomination.nominator_comments %} - <div class="row"> - <div class="col-lg-2"> - Nominator comments: - </div> - <div class="col-lg-10"> - <em>{{ nomination.nominator_comments }}</em> - </div> - </div> - {% endif %} - - <div id="nomination-{{ nomination.id }}-comments" - hx-get="{% url 'colleges:_hx_nomination_comments' nomination_id=nomination.id %}" - hx-trigger="revealed" - hx-target="this" - > - </div> - </div> - </div> - - {% with ongoing_round=nomination.ongoing_voting_round latest_round=nomination.voting_rounds.first %} - {% if ongoing_round %} - <div class="card m-2 mt-4"> - <div class="card-header"> - Ongoing voting round <em>(voting deadline: {{ ongoing_round.voting_deadline }})</em> - </div> - <div class="card-body"> - {% if session_fellowship and session_fellowship in ongoing_round.eligible_to_vote.all %} - <p>You are eligible to vote {{ include_vote_buttons }}</p> - <p><strong>Please go up to the main Vote dropdown to cast your vote</strong></p> - {% else %} - <p>You are not called upon to vote in this round.</p> - {% endif %} - </div> - </div> - {% elif latest_round %} - <div class="card m-2 mt-4"> - <div class="card-header"> - {{ latest_round }} - </div> - <div class="card-body"> - {% if session_fellowship and session_fellowship in latest_round.eligible_to_vote.all %} - {% include "colleges/_voting_results_box.html" with voting_round=latest_round %} - {% else %} - <p>You were not called upon to vote in this round.</p> - {% endif %} - </div> - </div> - {% endif %} - {% endwith %} - - {% if "edadmin" in user_roles %} - <div class="border border-danger m-2 p-2"> - <strong class="text-danger">Editorial Administration</strong> - <h3>Voting rounds</h3> - {% for round in nomination.voting_rounds.all %} - <div class="card m-2 mt-4"> - <div class="card-header"> - {{ round }} - </div> - <div class="card-body"> - <div class="row"> - <div class="col-lg-6"> - <h4>Eligible to vote</h4> - <ul> - {% for voter in round.eligible_to_vote.all %} - <li>{{ voter }}</li> - {% empty %} - <li>None</li> - {% endfor %} - </ul> - </div> - <div class="col-lg-6"> - <h4>Results</h4> - {% include "colleges/_voting_results_box.html" with voting_round=round %} - <table class="table m-2"> - <tr> - <th>Agree</th> - <td> - <ul class="list-unstyled"> - {% for vote in round.votes.agree %} - <li>{{ vote.fellow }}</li> - {% empty %} - <li>None</li> - {% endfor %} - </ul> - </td> - </tr> - <tr> - <th>Abstain</th> - <td> - <ul class="list-unstyled"> - {% for vote in round.votes.abstain %} - <li>{{ vote.fellow }}</li> - {% empty %} - <li>None</li> - {% endfor %} - </ul> - </td> - </tr> - <tr> - <th>Disagree</th> - <td> - <ul class="list-unstyled"> - {% for vote in round.votes.disagree %} - <li>{{ vote.fellow }}</li> - {% empty %} - <li>None</li> - {% endfor %} - </ul> - </td> - </tr> - </table> - </div> - </div> - </div> - </div> - {% empty %} - <div>No voting round</div> - {% endfor %} - - <div class="card m-2 mt-4"> - <div class="card-header"> - Decision - </div> - <div class="card-body"> - <div id="nomination-{{ nomination.id }}-decision" - hx-get="{% url 'colleges:_hx_nomination_decision' nomination_id=nomination.id %}" - hx-trigger="revealed" - hx-target="this" - </div> - </div> - </div> - - </div> - {% endif %} - -</div> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_new.html b/scipost_django/colleges/templates/colleges/_hx_nomination_new.html new file mode 100644 index 0000000000000000000000000000000000000000..c7cfd7ed2d876ce2c146d45dead9ce271b4c661d --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_new.html @@ -0,0 +1,74 @@ +{% load crispy_forms_tags %} + +<div class="p-3 border border-success rounded rounded-lg"> + <h2 class="mb-4">Nominate a new potential Fellow</h2> + <div class="row"> + + <div class="col-lg-6"> + <h3>Procedure</h3> + <ul> + <li>Type your search query in the search form</li> + <li> + When the name you're looking for appears in the + <em>Matching profiles</em> list, double-click on it + </li> + <li>The nomination form will appear below</li> + <li>Non-eligibility flags (if any) will appear</li> + <li>If eligible, fill the form in (comments are optional)</li> + <li>Submit! (the vote will be arranged by EdAdmin)</li> + </ul> + <div class="row mb-0"> + + <div class="col-12"> + <form hx-post="{% url 'profiles:_hx_profile_dynsel_list' %}" + hx-trigger="keyup delay:200ms, change" + hx-target="#profile_dynsel_results" + hx-indicator="#profile_dynsel_results-indicator"> + <div id="profile_dynsel_form">{% crispy profile_dynsel_form %}</div> + </form> + </div> + + </div> + </div> + + <div class="col-lg-6"> + <h3>Matching profiles</h3> + <div id="profile_dynsel_results" class="border border-light m-2 p-1"></div> + + <div class="row mb-0"> + + <div class="col-auto"> + <div id="profile_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 class="col-auto"> + <div id="nomination_form_response-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> + </div> + + </div> + </div> + + </div> + + <div id="nomination_form_response"> + <h3 class="mb-2">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> + </div> + +</div> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_summary.html b/scipost_django/colleges/templates/colleges/_hx_nomination_summary.html new file mode 100644 index 0000000000000000000000000000000000000000..aac1bdbf96fb6bd17437cc88c7bae761b9a5c7ec --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_summary.html @@ -0,0 +1,76 @@ +<div class="row mb-0"> + <div class="col-12 col-md"> + <div class="row mb-0"> + + <div class="col"> + <div class="row mb-0"> + + <div class="col-12 col-lg-4"> + <div class="row mb-0 h-100 align-content-between"> + <div class="col col-lg-12 fs-5">{{ nomination.profile }}</div> + <div class="col-auto"> + <div class="text-muted">nominated by</div> + <div>{{ nomination.nominated_by.profile.full_name }}</div> + </div> + </div> + </div> + + <div class="col-12 col-md"> + <div class="row mb-2"> + <div class="col-auto text-nowrap"> + <small class="text-muted">Editorial college</small> + <br /> + {{ nomination.college.name }} + </div> + <div class="col-auto text-nowrap"> + <small class="text-muted">Specialties</small> + <br /> + + {% for specialty in nomination.profile.specialties.all %} + <span title="{{ specialty.name }}">{{ specialty.code }}</span> + {% empty %} + None + {% endfor %} + + </div> + + <div class="col text-nowrap "> + <small class="text-muted">Last event</small> + <br /> + {{ nomination.events.latest.on|date:'Y-m-d' }} + - + <span class="">{{ nomination.events.latest.description }}</span> + </div> + + </div> + <div class="row mb-2 justify-content-between"> + + {% if nomination.invitation %} + <small class="col-auto text-muted text-nowrap">Invitation status</small> + <div class="col"> + <span class="badge bg-{{ nomination.invitation.get_response_color }}">{{ nomination.invitation.get_response_display }}</span> + </div> + {% else %} + <small class="col text-muted text-nowrap">Publications</small> + <div class="col-auto">{{ nomination.profile.publications.all.count }}</div> + <small class="col text-muted text-nowrap">Total rounds</small> + <div class="col-auto">{{ nomination.voting_rounds.all.count }}</div> + {% endif %} + + </div> + </div> + </div> + </div> + + <div class="col-12 col-md-5"> + + {% if nomination.latest_voting_round %} + {% include "colleges/_hx_voting_round_summary.html" with round=nomination.latest_voting_round %} + {% else %} + <div class="h-100 d-flex align-items-center justify-content-end"> + <div class="badge bg-danger fs-6">No rounds created yet</div> + </div> + {% endif %} + + </div> + </div> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_vote.html b/scipost_django/colleges/templates/colleges/_hx_nomination_vote.html index afe76b979cc66d23608b4e77d6e88974c6b30a37..a4425b79c7734737abcfde96d39f527648f9fc11 100644 --- a/scipost_django/colleges/templates/colleges/_hx_nomination_vote.html +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_vote.html @@ -1,44 +1,19 @@ -<div class="row"> - <div class="col"> - {% if vote_object %} - <p>You have previously voted: {{ vote_object.get_vote_display }}.</p> - {% else %} - <p>You have not yet voted on this Nomination.</p> - {% endif %} - <ul class="list-inline m-2"> - <li class="list-inline-item"> - Cast your vote: - </li> - <li class="list-inline-item"> - <form hx-post="{% url 'colleges:_hx_nomination_vote' voting_round_id=voting_round.id %}" - hx-target="#nomination-{{ voting_round.nomination.id }}-vote" - > - {% csrf_token %} - <input type="hidden" name="vote" value="agree"> - <button class="btn btn-sm btn-success text-white">Agree</button> - </form> - </li> - <li class="list-inline-item"> - <form hx-post="{% url 'colleges:_hx_nomination_vote' voting_round_id=voting_round.id %}" - hx-target="#nomination-{{ voting_round.nomination.id }}-vote" - > - {% csrf_token %} - <input type="hidden" name="vote" value="abstain"> - <button class="btn btn-sm btn-warning text-white">Abstain</button> - </form> - </li> - <li class="list-inline-item"> - <form hx-post="{% url 'colleges:_hx_nomination_vote' voting_round_id=voting_round.id %}" - hx-target="#nomination-{{ voting_round.nomination.id }}-vote" - > - {% csrf_token %} - <input type="hidden" name="vote" value="disagree"> - <button class="btn btn-sm btn-danger text-white">Disagree</button> - </form> - </li> - </ul> - </div> - <div class="col"> - {% include "colleges/_voting_results_box.html" with voting_round=voting_round %} - </div> -</div> +<h3>Cast your vote:</h3> + +{% for vote_option, color in VOTE_BS_CLASSES.items %} + + <form hx-post="{% url 'colleges:_hx_nomination_vote' round_id=voting_round.id %}" + hx-target="#nomination-{{ voting_round.nomination.id }}-vote"> + {% csrf_token %} + <input type="hidden" name="vote" value="{{ vote_option }}" /> + <button class="btn d-flex justify-content-between align-items-center w-100 mb-2 bg-{{ color }} text-white"> + <div>{{ vote_option|title }}</div> + + {% if vote_object.vote == vote_option %} + {% include 'bi/check-square-fill.html' %} + {% endif %} + + </button> + </form> + +{% endfor %} diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_voter_table.html b/scipost_django/colleges/templates/colleges/_hx_nomination_voter_table.html new file mode 100644 index 0000000000000000000000000000000000000000..edb03507be12b4179a322a48feb76218026ead4c --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_voter_table.html @@ -0,0 +1,75 @@ +{% if voters %} + <table class="table mb-0 border"> + <thead class="table-light"> + <tr> + <th>Fellow</th> + <th>Specialties</th> + + {% if "edadmin" in user_roles %} + + {% if not round.is_unscheduled %} + <th>Vote</th> + <th>Voted on</th> + {% endif %} + + <th>Actions</th> + {% endif %} + + </tr> + </thead> + + <tbody> + + {% for voter in voters %} + <tr> + <td>{{ voter }}</td> + + <td> + + {% for specialty in voter.contributor.profile.specialties.all %} + <div class="single d-inline + {% if specialty in nominee_specialties %}text-primary{% endif %} + " data-specialty="{{ specialty.slug }}" data-bs-placement="bottom" title="{{ specialty }}"> + {{ specialty.code }} + </div> + {% endfor %} + + + </td> + + {% if "edadmin" in user_roles %} + + {% if not round.is_unscheduled %} + + {% if voter.vote %} + <td class="text-{{ voter.vote.get_vote_bs_class }}">{{ voter.vote.get_vote_display }}</td> + {% else %} + <td class="text-muted">No vote</td> + {% endif %} + + <td>{{ voter.vote.on }}</td> + {% endif %} + + <td> + + {% if not round.is_closed %} + <button class="btn btn-sm btn-danger px-1 py-0 ms-auto" + hx-get="{% url "colleges:_hx_nomination_round_eligible_voter_action" round_id=round.id fellowship_id=voter.id action='remove' %}" + hx-target="#nomination-{{ round.nomination.id }}-round-{{ round.id }}-voters"> + {% include "bi/trash-fill.html" %} + </button> + {% endif %} + + </td> + {% endif %} + + </tr> + {% endfor %} + + + + </tbody> + </table> +{% else %} + <p class="text-danger">No eligible voters found.</p> +{% endif %} diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_voting_rounds_tab.html b/scipost_django/colleges/templates/colleges/_hx_nomination_voting_rounds_tab.html new file mode 100644 index 0000000000000000000000000000000000000000..11fa485d7be94366c082acc79ae8157c3bdaa31c --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_voting_rounds_tab.html @@ -0,0 +1,66 @@ +<div id="nomination-{{ nomination.id }}-round-tab-holder"> + + <nav class="nav nav-pills m-2 overflow-scroll"> + + {% if should_show_new_round_tab_btn %} + <div id="nomination-{{ nomination.id }}-new-round-btn" + type="button" + class="me-2 px-2 nav-link border border-success" + hx-get="{% url 'colleges:_hx_nomination_voting_rounds_create' nomination_id=nomination.id %}" + hx-target="#nomination-{{ nomination.id }}-round-tab-holder" + hx-swap="outerHTML"> + <span class="fs-1 align-items-center text-success">+</span> + </div> + {% endif %} + + {% for voting_round in voting_rounds %} + <div id="nomination-{{ nomination.id }}-round-{{ voting_round.id }}-tab-btn" type="button" class="me-2 nav-link + {% if selected_round and selected_round.id == voting_round.id %}active{% endif %} + + {% if voting_round.id in inaccessible_round_ids %}disabled opacity-50{% endif %} + " hx-get="{% url 'colleges:_hx_nomination_voting_rounds_tab' nomination_id=nomination.id round_id=voting_round.id %}" hx-target="#nomination-{{ nomination.id }}-round-tab-holder" hx-swap="outerHTML"> + <span class="d-block text-nowrap"> + + {% if voting_round.voting_opens and voting_round.voting_deadline %} + <small>{{ voting_round.voting_opens|date:"d M Y" }} - {{ voting_round.voting_deadline|date:"d M Y" }}</small> + {% else %} + <span class="badge bg-warning">Unscheduled</span> + {% endif %} + + <span class="d-flex justify-content-between align-items-center"> + <span>Round #{{ forloop.revcounter }}</span> + + {% if voting_round.is_scheduled %} + <span class="badge bg-primary">Scheduled</span> + {% elif voting_round.is_open %} + <span class="badge bg-success">Open</span> + {% endif %} + + </span> + </span> + </div> + {% endfor %} + + + + <div id="indicator-nomination-{{ nomination.id }}-details-contents" + class="htmx-indicator p-2 ms-auto"> + <button class="btn 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> + + </nav> + + {% if selected_round %} + <div id="nomination-{{ nomination.id }}-round-{{ selected_round.id }}-tab-content-holder" + hx-get="{% url 'colleges:_hx_voting_round_details' round_id=selected_round.id %}" + hx-trigger="intersect once" + class="p-3"></div> + {% endif %} + +</div> diff --git a/scipost_django/colleges/templates/colleges/_hx_nominations.html b/scipost_django/colleges/templates/colleges/_hx_nominations.html deleted file mode 100644 index 6ba5b622f4669c21b423326e4ffcc27911415ecd..0000000000000000000000000000000000000000 --- a/scipost_django/colleges/templates/colleges/_hx_nominations.html +++ /dev/null @@ -1,22 +0,0 @@ -{% for nomination in page_obj %} - <li class="p-2 mb-2" id="nomination_{{ nomination.id }}"> - {% include 'colleges/_hx_nomination_li.html' with nomination=nomination %} - </li> -{% empty %} - <li>No Nomination could be found</li> -{% endfor %} -{% if page_obj.has_next %} - <li hx-post="{% url 'colleges:_hx_nominations' %}?page={{ page_obj.next_page_number }}" - hx-include="#search-nominations-form" - hx-trigger="revealed" - hx-swap="afterend" - hx-indicator="#indicator-search-page-{{ page_obj.number }}" - > - <div id="indicator-search-page-{{ page_obj.number }}" class="htmx-indicator p-2"> - <button class="btn btn-warning" type="button" disabled> - <strong>Loading page {{ page_obj.next_page_number }} out of {{ page_obj.paginator.num_pages }}</strong> - <div class="spinner-grow spinner-grow-sm ms-2" role="status" aria-hidden="true"></div> - </button> - </div> - </li> -{% endif %} diff --git a/scipost_django/colleges/templates/colleges/_hx_nominations_invitations.html b/scipost_django/colleges/templates/colleges/_hx_nominations_invitations.html deleted file mode 100644 index 0b9771d42358f9ae2dfc18ef4e4841716a0b4a90..0000000000000000000000000000000000000000 --- a/scipost_django/colleges/templates/colleges/_hx_nominations_invitations.html +++ /dev/null @@ -1,43 +0,0 @@ -{% include 'colleges/_hx_nominations_invitations_tablist.html' with selected=selected %} - -{% for invitation in invitations.all %} - <details id="invitation-{{ invitation.id }}-details" - class="m-2 mt-4 border border-2" - > - <summary class="bg-light p-2">{{ invitation }}</summary> - <details class="m-2 mt-4 border"> - <summary class="p-2 bg-light">Events for this nomination</summary> - {% include 'colleges/_nomination_events_table.html' with nomination=invitation.nomination %} - </details> - <div class="p-2"> - <h4>Checklist</h4> - <ul> - {% if not invitation.nomination.profile.contributor %} - <li class="text-danger">N.B.: this nominee is not yet registered as a Contributor</li> - {% else %} - <li><span class="text-success">{% include 'bi/check-square-fill.html' %}</span> This nominee has a Contributor account</li> - {% endif %} - {% if selected == 'notyetinvited' %} - <li> - For named or elected, but not yet invited: - <a class="btn btn-primary" href="{% url 'colleges:fellowship_invitation_email_initial' pk=invitation.id %}">prepare and send initial email</a> - </li> - {% elif selected == 'accepted' %} - <li>Accepted to serve as Fellow but not currently active in a College? <a href="{% url 'colleges:fellowship_create' contributor_id=invitation.nomination.profile.contributor.id %}" target="_blank">Set up a Fellowship</a></li> - {% endif %} - </ul> - - <hr> - - <h4>Update the response to this invitation:</h4> - <div id="invitation-{{ invitation.id }}-update-response" - hx-get="{% url 'colleges:_hx_fellowship_invitation_update_response' invitation_id=invitation.id %}" - hx-trigger="toggle from:#invitation-{{ invitation.id }}-details" - hx-target="this" - > - </div> - </div> - </details> -{% empty %} - <p class="p-2">No invitations of this kind</p> -{% endfor %} diff --git a/scipost_django/colleges/templates/colleges/_hx_nominations_invitations_tablist.html b/scipost_django/colleges/templates/colleges/_hx_nominations_invitations_tablist.html deleted file mode 100644 index fe8cb52436a3a2d41003443a4abb075a604001e3..0000000000000000000000000000000000000000 --- a/scipost_django/colleges/templates/colleges/_hx_nominations_invitations_tablist.html +++ /dev/null @@ -1,7 +0,0 @@ -<div class="tablist"> - {% for choice in response_choices %} - <a hx-get="{% url 'colleges:_hx_nominations_invitations' %}?response={{ choice.0 }}" - {% if selected == choice.0 %}class="selected"{% endif %} - >{{ choice.1 }}</a> - {% endfor %} -</div> diff --git a/scipost_django/colleges/templates/colleges/_hx_nominations_list.html b/scipost_django/colleges/templates/colleges/_hx_nominations_list.html new file mode 100644 index 0000000000000000000000000000000000000000..d859d14af2e2824b18c554fd9c71f1956c4d4732 --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nominations_list.html @@ -0,0 +1,24 @@ +{% for nomination in page_obj %} + <div class="ms-1 mt-2">{% include 'colleges/_hx_nomination_details.html' with nomination=nomination %}</div> +{% empty %} + <strong>No Nominations could be found</strong> +{% endfor %} + +{% if page_obj.has_next %} + <div hx-post="{% url 'colleges:_hx_nominations_list' %}?page={{ page_obj.next_page_number }}" + hx-include="#search-nominations-form" + hx-trigger="revealed" + hx-swap="afterend" + hx-indicator="#indicator-nominations-search-page"></div> + <div id="indicator-nominations-search-page" + hx-swap-oob="true" + class="htmx-indicator p-2"> + <button class="btn btn-warning" type="button" disabled> + <strong>Loading page {{ page_obj.next_page_number }} out of {{ page_obj.paginator.num_pages }}</strong> + + <div class="spinner-grow spinner-grow-sm ms-2" + role="status" + aria-hidden="true"></div> + </button> + </div> +{% endif %} diff --git a/scipost_django/colleges/templates/colleges/_hx_nominations_needing_specialties.html b/scipost_django/colleges/templates/colleges/_hx_nominations_needing_specialties.html deleted file mode 100644 index 50d4c6abf771e0ad1cd8728ebe3f38401f3a038a..0000000000000000000000000000000000000000 --- a/scipost_django/colleges/templates/colleges/_hx_nominations_needing_specialties.html +++ /dev/null @@ -1,20 +0,0 @@ -{% for nomination in nominations_needing_specialties %} - <details id="nomination-{{ nomination.id }}-specialties" - class="border border-2 mt-4" - > - <summary class="p-2 bg-light">{{ nomination }}</summary> - <div id="profile-{{ nomination.profile.id }}-specialties" - class="p-2 mt-2" - hx-get="{% url 'profiles:_hx_profile_specialties' profile_id=nomination.profile.id %}" - hx-trigger="toggle from:#nomination-{{ nomination.id }}-specialties" - > - </div> - <button class="btn btn-success text-white m-2" - hx-get="{% url 'colleges:_hx_nominations_needing_specialties' %}" - hx-target="#nominations_needing_specialties"> - Done - </button> - </details> -{% empty %} - <p>All nomination profiles have at least one specialty.</p> -{% endfor %} diff --git a/scipost_django/colleges/templates/colleges/_hx_nominations_search_form.html b/scipost_django/colleges/templates/colleges/_hx_nominations_search_form.html new file mode 100644 index 0000000000000000000000000000000000000000..6c95f95a9462805b6093f3feda5751a88908d405 --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nominations_search_form.html @@ -0,0 +1,10 @@ +{% load crispy_forms_tags %} + +<form hx-post="{% url 'colleges:_hx_nominations_list' %}" + hx-trigger="load, keyup delay:500ms, change delay:500ms, click from:#refresh-button" + hx-sync="#search-nominations-form:replace" + hx-target="#search-nominations-results" + hx-indicator="#indicator-search-nominations"> + + <div id="search-nominations-form">{% crispy form %}</div> +</form> diff --git a/scipost_django/colleges/templates/colleges/_hx_voting_round_details.html b/scipost_django/colleges/templates/colleges/_hx_voting_round_details.html new file mode 100644 index 0000000000000000000000000000000000000000..1fab54c60cd3d7aed3b40dba734f480920404e6c --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_voting_round_details.html @@ -0,0 +1,93 @@ +{% load crispy_forms_tags %} +{% load user_groups %} +{% is_ed_admin request.user as is_ed_admin %} + +<div class="row mb-0"> + <div class="col mb-3"> + <h3>Eligible voters</h3> + <div id="nomination-{{ round.nomination.id }}-round-{{ round.id }}-voters" + hx-get="{% url 'colleges:_hx_nomination_voter_table' round_id=round.id %}" + hx-trigger="intersect once"></div> + </div> + + <div class="col-12 col-md-auto d-flex flex-column justify-content-between"> + + {% if not round.is_closed %} + + {% if is_ed_admin %} + + <div> + <h4>Add new voter</h4> + <form hx-post="{% url 'colleges:_hx_fellowship_dynsel_list' %}" + hx-trigger="keyup delay:200ms, change" + hx-target="#nomination-{{ round.nomination.id }}_round-{{ round.id }}_add_voter_results"> + <div id="nomination-{{ round.nomination.id }}_round-{{ round.id }}_add_voter_form">{% crispy voter_add_form %}</div> + </form> + <div id="nomination-{{ round.nomination.id }}_round-{{ round.id }}_add_voter_results"></div> + </div> + + <div> + <h5>Add senior fellows</h5> + <button type="button" + class="mb-2 btn btn-primary btn-sm" + hx-get="{% url 'colleges:_hx_nomination_round_add_eligible_voter_set' round_id=round.id voter_set_name='with_specialty_overlap' %}" + hx-target="#nomination-{{ round.nomination.id }}-round-{{ round.id }}-voters"> + With specialty overlap + </button> + <button type="button" + class="mb-2 btn btn-warning text-white btn-sm" + hx-get="{% url 'colleges:_hx_nomination_round_add_eligible_voter_set' round_id=round.id voter_set_name='all_seniors' %}" + hx-target="#nomination-{{ round.nomination.id }}-round-{{ round.id }}-voters">ALL seniors</button> + </div> + + {% comment %} If round is open and the viewer can vote, show the voting form {% endcomment %} + {% elif session_fellowship and session_fellowship in round.eligible_to_vote.all and round.is_open %} + + <div id="nomination-{{ round.nomination.id }}-vote" + hx-get="{% url 'colleges:_hx_nomination_vote' round_id=round.id %}" + hx-trigger="intersect once"></div> + + {% endif %} + + {% comment %} If round is closed show results if they exist {% endcomment %} + {% else %} + <div>{% include "colleges/_voting_results_box.html" with voting_round=voting_round %}</div> + + + {% if round.decision %} + <div> + <h3>Decision</h3> + + {% if round.decision.outcome == 'elected' %} + <div class="badge fs-5 mb-2 bg-success">{{ round.decision.get_outcome_display }}</div> + {% elif round.decision.outcome == 'notelected' %} + <div class="badge fs-5 mb-2 bg-danger">{{ round.decision.get_outcome_display }}</div> + {% endif %} + + {% if round.decision.comments %} + <h4 class="mt-2">Decision comments</h4> + <span>{{ round.decision.comments }}</span> + {% endif %} + {% endif %} + + </div> + {% endif %} + + </div> +</div> + +{% if is_ed_admin %} + + {% if not round.is_closed %} + + <div hx-get="{% url 'colleges:_hx_voting_round_start_form' round_id=round.id %}" + hx-trigger="intersect once"></div> + + {% elif round.is_closed and not round.decision %} + + <div hx-get="{% url 'colleges:_hx_nomination_decision_form' round_id=round.id %}" + hx-trigger="intersect once"></div> + + {% endif %} + +{% endif %} diff --git a/scipost_django/colleges/templates/colleges/_hx_voting_round_search_form.html b/scipost_django/colleges/templates/colleges/_hx_voting_round_search_form.html new file mode 100644 index 0000000000000000000000000000000000000000..6c95f95a9462805b6093f3feda5751a88908d405 --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_voting_round_search_form.html @@ -0,0 +1,10 @@ +{% load crispy_forms_tags %} + +<form hx-post="{% url 'colleges:_hx_nominations_list' %}" + hx-trigger="load, keyup delay:500ms, change delay:500ms, click from:#refresh-button" + hx-sync="#search-nominations-form:replace" + hx-target="#search-nominations-results" + hx-indicator="#indicator-search-nominations"> + + <div id="search-nominations-form">{% crispy form %}</div> +</form> diff --git a/scipost_django/colleges/templates/colleges/_hx_voting_round_start_form.html b/scipost_django/colleges/templates/colleges/_hx_voting_round_start_form.html new file mode 100644 index 0000000000000000000000000000000000000000..2fffd05b3e0d9880349e9ab2d8412eb9e2877c9c --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_voting_round_start_form.html @@ -0,0 +1,7 @@ +{% load crispy_forms_tags %} + +<form id="voting-round-{{ round.id }}-start-round-form" + hx-post="{% url 'colleges:_hx_voting_round_start_form' round_id=round.id %}" + hx-target="#voting-round-{{ round.id }}-start-round-form"> + {% crispy form %} +</form> diff --git a/scipost_django/colleges/templates/colleges/_hx_voting_round_summary.html b/scipost_django/colleges/templates/colleges/_hx_voting_round_summary.html new file mode 100644 index 0000000000000000000000000000000000000000..119a04577893d38d18035df8513aabcc14c448de --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_voting_round_summary.html @@ -0,0 +1,96 @@ +<div class="row mb-0"> + + {% if not round.is_unscheduled %} + <div class="order-1 col-12 col-sm-6 col-md-12 col-xl-7"> + <div class="row justify-content-between"> + <small class="col text-muted text-nowrap">Voting opens</small> + + <div class="col-auto"> + + {% if round.is_open %} + {{ round.voting_opens|timesince }} ago + {% else %} + {{ round.voting_opens|date:"Y-m-d" }} + {% endif %} + + </div> + </div> + </div> + <div class="order-2 order-sm-3 order-md-2 order-xl-3 col-12 col-sm-6 col-md-12 col-xl-7"> + <div class="row justify-content-between"> + <small class="col text-muted text-nowrap">Voting deadline</small> + + <div class="col-auto"> + + {% if round.is_open %} + In {{ round.voting_deadline|timeuntil }} + {% else %} + {{ round.voting_deadline|date:"Y-m-d" }} + {% endif %} + + </div> + + </div> + </div> + {% else %} + <div class="order-1 col-12 col-sm-6 col-md-12 col-xl-7"></div> + <div class="order-2 order-sm-3 order-md-2 order-xl-3 col-12 col-sm-6 col-md-12 col-xl-7"></div> + {% endif %} + + <div class="order-3 order-sm-2 order-md-3 order-xl-2 col-12 col-sm-6 col-md-12 col-xl-5"> + <div class="row justify-content-between"> + <small class="col text-muted text-nowrap">Voting status</small> + + <div class="col-auto"> + + {% if round.is_unscheduled %} + <span class="badge bg-warning">Unscheduled</span> + {% elif round.is_open %} + <span class="badge bg-success">Open</span> + {% elif round.is_scheduled %} + <span class="badge bg-primary">Scheduled</span> + {% elif round.is_closed %} + <span class="badge bg-primary">Closed</span> + {% endif %} + + </div> + + </div> + </div> + + <div class="order-4 col-12 col-sm-6 col-md-12 col-xl-5"> + <div class="row justify-content-between"> + + {% if round.is_closed %} + <small class="col text-muted text-nowrap">Decision</small> + + <div class="col-auto"> + + {% if round.decision.outcome == "elected" %} + <span class="badge bg-success">{{ round.decision.get_outcome_display }}</span> + {% elif round.decision.outcome == "notelected" %} + <span class="badge bg-danger">{{ round.decision.get_outcome_display }}</span> + {% else %} + <span class="badge bg-warning">Pending</span> + {% endif %} + + </div> + {% else %} + <small class="col text-muted text-nowrap">Voted / Total</small> + + <div class="col-auto"> + + {% if round.eligible_to_vote.count > 0 %} + {{ round.votes.count }} / {{ round.eligible_to_vote.count }} + {% else %} + <span class="badge bg-danger">None</span> + {% endif %} + + </div> + {% endif %} + + + </div> + </div> + +</div> diff --git a/scipost_django/colleges/templates/colleges/_hx_voting_rounds.html b/scipost_django/colleges/templates/colleges/_hx_voting_rounds.html deleted file mode 100644 index a472a42bf9fc834d2e7b3a69595384e3549ca23f..0000000000000000000000000000000000000000 --- a/scipost_django/colleges/templates/colleges/_hx_voting_rounds.html +++ /dev/null @@ -1,19 +0,0 @@ -{% include 'colleges/_hx_voting_rounds_tablist.html' with selected=selected %} - -{% for round in voting_rounds %} - <div class="mt-4 p-2 border border-2" id="voting_round_{{ round.id }}"> - - {% include 'colleges/_hx_nomination_li.html' with nomination=round.nomination %} - <h3 class="mt-4">Voting deadline: {{ round.voting_deadline }}</h3> - {% if session_fellowship and session_fellowship in round.eligible_to_vote.all %} - <div id="nomination-{{ round.nomination.id }}-vote" - hx-get="{% url 'colleges:_hx_nomination_vote' voting_round_id=round.id %}" - hx-trigger="revealed" - hx-target="this" - > - </div> - {% endif %} - </div> -{% empty %} - <div class="p-2">No voting round found</div> -{% endfor %} diff --git a/scipost_django/colleges/templates/colleges/_hx_voting_rounds_tablist.html b/scipost_django/colleges/templates/colleges/_hx_voting_rounds_tablist.html deleted file mode 100644 index 0758404ac3999f8e579bd4e18bd0294a70a2d97e..0000000000000000000000000000000000000000 --- a/scipost_django/colleges/templates/colleges/_hx_voting_rounds_tablist.html +++ /dev/null @@ -1,7 +0,0 @@ -<div class="tablist"> - {% for tab_choice in tab_choices %} - <a hx-get="{% url 'colleges:_hx_voting_rounds' %}?tab={{ tab_choice.0 }}" - {% if selected == tab_choice.0 %}class="selected"{% endif %} - >{{ tab_choice.1 }}</a> - {% endfor %} -</div> diff --git a/scipost_django/colleges/templates/colleges/_nomination_events_table.html b/scipost_django/colleges/templates/colleges/_nomination_events_table.html index 366616e9443ed813565fade618cdd051417e05c7..33e0307e93caf1df6b46abf6b61fbacfc5625608 100644 --- a/scipost_django/colleges/templates/colleges/_nomination_events_table.html +++ b/scipost_django/colleges/templates/colleges/_nomination_events_table.html @@ -1,18 +1,20 @@ -<table class="table m-2"> +<table class="table mb-0"> <thead> <tr> - <th>Date and time</th> + <th>Date</th> <th>Description</th> <th>By</th> </tr> </thead> <tbody> + {% for event in nomination.events.all %} <tr> - <td>{{ event.on }}</td> - <td>{{ event.description }}</td> - <td>{{ event.by }}</td> + <td>{{ event.on }}</td> + <td>{{ event.description }}</td> + <td>{{ event.by }}</td> </tr> {% endfor %} + </tbody> </table> diff --git a/scipost_django/colleges/templates/colleges/_nominations_invitation_checklist.html b/scipost_django/colleges/templates/colleges/_nominations_invitation_checklist.html new file mode 100644 index 0000000000000000000000000000000000000000..84d0a2f937959aab8ec43ecb2b142787d77eb36a --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_nominations_invitation_checklist.html @@ -0,0 +1,49 @@ +<div class="p-2"> + <ul class="mb-0 list-group list-group-flush"> + + <li class="list-group-item p-2"> + + {% if not invitation.nomination.profile.contributor %} + <span class="text-danger">{% include 'bi/x-square-fill.html' %}</span> + <span> This nominee is not yet registered as a Contributor.</span> + {% else %} + <span class="text-success">{% include 'bi/check-square-fill.html' %}</span> + <span> This nominee has a Contributor account.</span> + {% endif %} + + </li> + + <li class="list-group-item p-2"> + + {% if invitation.response == 'notyetinvited' %} + <span class="text-danger">{% include 'bi/x-square-fill.html' %}</span> + <span> This nominee is elected, but not yet invited.</span> + <a class="btn btn-sm btn-primary" + href="{% url 'colleges:fellowship_invitation_email_initial' pk=invitation.id %}">Invite</a> + {% else %} + <span class="text-success">{% include 'bi/check-square-fill.html' %}</span> + <span> This nominee has been invited to serve as a Fellow.</span> + {% endif %} + + </li> + + + {% if invitation.response == 'accepted' or invitation.response == 'postponed' %} + <li class="list-group-item p-2"> + + {% if invitation.nomination.fellowship %} + <span class="text-danger">{% include 'bi/x-square-fill.html' %}</span> + <span> This nominee has no associated Fellowship with this college.</span> + <a href="{% url 'colleges:fellowship_create' contributor_id=invitation.nomination.profile.contributor.id %}" + target="_blank">Set up a Fellowship</a> + {% else %} + <span class="text-success">{% include 'bi/check-square-fill.html' %}</span> + <span> A fellowship has been created from this nomination.</span> + {% endif %} + + + </li> + {% endif %} + + </ul> +</div> diff --git a/scipost_django/colleges/templates/colleges/_voting_results_box.html b/scipost_django/colleges/templates/colleges/_voting_results_box.html index 577d2c8c461aafa56bb037b96feeb5df9e0f21cd..11592a769aaf5d6b96d315a0d24e5f81bf95b4c3 100644 --- a/scipost_django/colleges/templates/colleges/_voting_results_box.html +++ b/scipost_django/colleges/templates/colleges/_voting_results_box.html @@ -1,9 +1,8 @@ -<div class="border border-2 p-2"> - <h3>Voting results summary</h3> - <ul class="list-inline m-2"> - <li class="list-inline-item p-2">Eligible: {{ voting_round.eligible_to_vote.count }}</li> - <li class="list-inline-item p-2 text-success">Agree: {{ voting_round.votes.agree.count }}</li> - <li class="list-inline-item p-2 text-warning">Abstain: {{ voting_round.votes.abstain.count }}</li> - <li class="list-inline-item p-2 text-danger">Disagree: {{ voting_round.votes.disagree.count }}</li> - </ul> -</div> +<h3>Summary</h3> +<ul class="list-group list-group-flush m-2"> + <li class="list-group-item p-2 text-muted">Eligible: {{ voting_round.eligible_to_vote.count }}</li> + <li class="list-group-item p-2 text-success">Agree: {{ voting_round.votes.agree.count }}</li> + <li class="list-group-item p-2 text-warning">Abstain: {{ voting_round.votes.abstain.count }}</li> + <li class="list-group-item p-2 text-danger">Disagree: {{ voting_round.votes.disagree.count }}</li> + <li class="list-group-item p-2 text-black">Veto: {{ voting_round.votes.veto.count }}</li> +</ul> diff --git a/scipost_django/colleges/templates/colleges/nominations.html b/scipost_django/colleges/templates/colleges/nominations.html index 47dc3a937191affd4c2fa88cdee24449a18346a0..388feb729bf8b238b4bfe1727a962757d8d5053d 100644 --- a/scipost_django/colleges/templates/colleges/nominations.html +++ b/scipost_django/colleges/templates/colleges/nominations.html @@ -1,5 +1,4 @@ {% extends 'colleges/base.html' %} - {% load user_groups %} {% load crispy_forms_tags %} @@ -9,16 +8,19 @@ <span class="breadcrumb-item">Nominations</span> {% endblock %} -{% block meta_description %}{{ block.super }} Nominations{% endblock meta_description %} -{% block pagetitle %}: Nominations{% endblock pagetitle %} +{% block meta_description %} + {{ block.super }} Nominations +{% endblock meta_description %} -{% block content %} +{% block pagetitle %} + : Nominations +{% endblock pagetitle %} +{% block content %} {% is_ed_admin request.user as is_ed_admin %} - <h1 class="highlight">Fellowship Nominations</h1> - - <p>Consult the + <p> + Consult the <a href="{% url 'submissions:monitor' %}" target="_blank">Submissions Monitor</a> page. Any <span class="text-danger">red-highlighted</span> specialty is in need of more Fellows @@ -26,151 +28,60 @@ <strong>Help out by nominating candidates!</strong> </p> - <details class="border border-warning border-2 mt-4"> - <summary class="bg-warning bg-opacity-10 p-2"> - <h2 class="ms-2">Nominate</h2> - </summary> - <div class="p-2"> - <div class="row"> - <div class="col-lg-6"> - <h3>Procedure</h3> - <ul> - <li>Type your search query in the search form</li> - <li>When the name you're looking for appears in the - <em>Matching profiles</em> list, double-click on it</li> - <li>The nomination form will appear below</li> - <li>Non-eligibility flags (if any) will appear</li> - <li>If eligible, fill the form in (comments are optional)</li> - <li>Submit! (the vote will be arranged by EdAdmin)</li> - </ul> - <div class="row"> - <div class="col-8"> - <form - hx-post="{% url 'profiles:_hx_profile_dynsel_list' %}" - hx-trigger="keyup delay:200ms, change" - hx-target="#profile_dynsel_results" - hx-indicator="#profile_dynsel_results-indicator" - > - <div id="profile_dynsel_form">{% crispy profile_dynsel_form %}</div> - </form> - </div> - <div class="col-2"> - <div id="nomination_form_response-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> - </div> - <div class="col-2"> - <div id="profile_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> - <h3 class="mb-2">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> - </div> - <div class="col-lg-6"> - <h3>Matching profiles</h3> - <div id="profile_dynsel_results" class="border border-light m-2 p-1"></div> - </div> - </div> - <div id="nomination_form_response"></div> - </div> - </details> - - {% if "edadmin" in user_roles or "active_senior_fellow" in user_roles %} - <details id="ensure-specialties-details" - class="border border-danger border-2 mt-4" - > - <summary class="bg-danger bg-opacity-10 p-2"> - <h2 class="ms-2"> - <strong class="text-danger">EdAdmin/Senior Fellows</strong>: - ensure specialties in each nominee's profile</h2> - </summary> - <div class="p-2 mt-2"> - <div id="nominations_needing_specialties" - hx-get="{% url 'colleges:_hx_nominations_needing_specialties' %}" - hx-trigger="toggle from:#ensure-specialties-details" - > - </div> - </div> - </details> - {% endif %} + <div id="new-nomination-container"></div> - <details id="voting-details" - class="border border-primary border-2 mt-4" - > - <summary class="bg-primary bg-opacity-10 p-2"> - <h2 class="ms-2 text-primary">Vote{% if 'edadmin' in user_roles %} <span class="text-danger">(EdAdmin: manage voting)</span>{% endif %}</h2> - </summary> - <div class="p-2 mt-2"> - <div id="voting_tablist" - hx-get="{% url 'colleges:_hx_voting_rounds' %}?tab={% if 'edadmin' in user_roles %}ongoing{% else %}ongoing-vote_required{% endif %}" - hx-trigger="toggle from:#voting-details" - hx-target="this" - hx-swap="innerHTML" - > - </div> - </div> - </details> + <details id="nominations-filter-details" class="card my-4"> + <summary class="card-header d-flex flex-row align-items-center justify-content-between list-triangle"> + <div class="fs-3">Search / Filter</div> + <div class="d-none d-md-flex align-items-center"> + + <div id="indicator-search-nominations" class="htmx-indicator"> + <button class="btn btn-warning text-white d-none d-md-block me-2" + type="button" + disabled> + <strong>Loading...</strong> + + <div class="spinner-grow spinner-grow-sm ms-2" + role="status" + aria-hidden="true"></div> + </button> + </div> - {% if "edadmin" in user_roles %} - <details id="invitations-details" - class="border border-success border-2 mt-4" - > - <summary class="bg-success bg-opacity-10 p-2"> - <h2 class="ms-2"> - <strong class="text-success">EdAdmin</strong>: - (for elected) invitations</h2> - </summary> - <div class="p-2 mt-2"> - <div id="invitations_tablist" - hx-get="{% url 'colleges:_hx_nominations_invitations' %}?response=notyetinvited" - hx-trigger="toggle from:#invitations-details" - hx-target="this" - hx-swap="innerHTML" - > - </div> + <button class="btn btn-outline-secondary me-2" + type="button" + hx-get="{% url 'colleges:_hx_nominations_search_form' filter_set="empty" %}" + hx-target="#nominations-search-form-container">Clear Filters</button> + + <button class="btn btn-success me-2 text-white" + type="button" + hx-get="{% url 'colleges:_hx_nomination_new' %}" + hx-trigger="click" + hx-target="#new-nomination-container"> + {% include "bi/plus-square.html" %} + Add New + </button> + + <a id="refresh-button" class="me-2 btn btn-primary"> + {% include "bi/arrow-clockwise.html" %} + Refresh</a> </div> - </details> - {% endif %} - <details id="list-details" - class="border border-2 mt-4" - > - <summary class="bg-light p-2"> - <h2 class="ms-2">List / filter</h2> </summary> - <div class="p-2 mt-2"> - <form - hx-post="{% url 'colleges:_hx_nominations' %}" - hx-trigger="toggle from:#list-details, keyup delay:500ms, change" - hx-target="#search-nominations-results" - hx-indicator="#indicator-search" - > - <div id="search-nominations-form">{% crispy search_nominations_form %}</div> - </form> - - <div class="row"> - <div class="col"> - <h3>Nominations list</h3> - </div> - <div class="col"> - <div id="indicator-search-nominations" 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> - <ul id="search-nominations-results" class="list-unstyled mt-2"></ul> + <div class="card-body"> + <div id="nominations-search-form-container" + hx-get="{% url 'colleges:_hx_nominations_search_form' filter_set='default' %}" + hx-trigger="load, intersect once"></div> </div> </details> + <div id="search-nominations-results" class="mt-2"></div> + <div id="indicator-nominations-search-page" class="htmx-indicator p-2"> + <button class="btn 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> + {% endblock content %} diff --git a/scipost_django/colleges/urls.py b/scipost_django/colleges/urls.py index 8743084fab237623ce66c3762927c22d209cfcaa..21299fad2d69c5c2cb3b92289645d517d31434ab 100644 --- a/scipost_django/colleges/urls.py +++ b/scipost_django/colleges/urls.py @@ -2,7 +2,7 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" -from django.urls import path +from django.urls import include, path from . import views @@ -161,48 +161,120 @@ urlpatterns = [ views.PotentialFellowshipListView.as_view(), name="potential_fellowships", ), - # Nominations - path("nominations", views.nominations, name="nominations"), - path( - "_hx_nomination_form/<int:profile_id>", - views._hx_nomination_form, - name="_hx_nomination_form", - ), - path("_hx_nominations", views._hx_nominations, name="_hx_nominations"), - path( - "_hx_nomination_li_contents/<int:nomination_id>", - views._hx_nomination_li_contents, - name="_hx_nomination_li_contents", - ), - path( - "_hx_nomination_comments/<int:nomination_id>", - views._hx_nomination_comments, - name="_hx_nomination_comments", - ), - path( - "_hx_nominations_needing_specialties", - views._hx_nominations_needing_specialties, - name="_hx_nominations_needing_specialties", - ), - path( - "_hx_voting_rounds", - views._hx_voting_rounds, - name="_hx_voting_rounds", - ), - path( - "_hx_nomination_vote/<int:voting_round_id>", - views._hx_nomination_vote, - name="_hx_nomination_vote", - ), - path( - "_hx_nomination_decision/<int:nomination_id>", - views._hx_nomination_decision, - name="_hx_nomination_decision", - ), - path( - "_hx_nominations_invitations", - views._hx_nominations_invitations, - name="_hx_nominations_invitations", + ########################## + # Nominations and Voting # + ########################## + path( + "nominations/", + include( + [ + path("", views.nominations, name="nominations"), + path("_hx_new", views._hx_nomination_new, name="_hx_nomination_new"), + path( + "_hx_new_form/<int:profile_id>", + views._hx_nomination_form, + name="_hx_nomination_form", + ), + path( + "search", + include( + [ + path( + "_hx_form/<str:filter_set>", + views._hx_nominations_search_form, + name="_hx_nominations_search_form", + ), + path( + "_hx_list", + views._hx_nominations_list, + name="_hx_nominations_list", + ), + ] + ), + ), + path( + "<int:nomination_id>/", + include( + [ + path( + "_hx_round_tab/<int:round_id>", + views._hx_nomination_voting_rounds_tab, + name="_hx_nomination_voting_rounds_tab", + ), + path( + "_hx_details_contents", + views._hx_nomination_details_contents, + name="_hx_nomination_details_contents", + ), + path( + "_hx_create_voting_round", + views._hx_nomination_voting_rounds_create, + name="_hx_nomination_voting_rounds_create", + ), + path( + "_hx_comments", + views._hx_nomination_comments, + name="_hx_nomination_comments", + ), + ] + ), + ), + ] + ), + ), + # Nomination Rounds + path( + "nomination_voting_round/<int:round_id>/", + include( + [ + path("_hx_vote", views._hx_nomination_vote, name="_hx_nomination_vote"), + path( + "_hx_details", + views._hx_voting_round_details, + name="_hx_voting_round_details", + ), + path( + "_hx_voter_table", + views._hx_nomination_voter_table, + name="_hx_nomination_voter_table", + ), + path( + "forms/", + include( + [ + path( + "start_round", + views._hx_voting_round_start_form, + name="_hx_voting_round_start_form", + ), + path( + "decision", + views._hx_nomination_decision_form, + name="_hx_nomination_decision_form", + ), + ] + ), + ), + # Manage voters of a nomination round + path( + "voters/", + include( + [ + path( + "<int:fellowship_id>/action/<str:action>", + views._hx_nomination_round_eligible_voter_action, + name="_hx_nomination_round_eligible_voter_action", + ), + path( + "add_set/<str:voter_set_name>", + views._hx_nomination_round_add_eligible_voter_set, + name="_hx_nomination_round_add_eligible_voter_set", + ), + ] + ), + ), + ], + ), ), path( "fellowship_invitation/<int:pk>/email_initial", diff --git a/scipost_django/colleges/utils.py b/scipost_django/colleges/utils.py index 06095bf586a5f5ffa0b4fbd648df41cd4a8ff43c..cf61ef5c5ddff1ecc7d9c7df4b8a2575b0344968 100644 --- a/scipost_django/colleges/utils.py +++ b/scipost_django/colleges/utils.py @@ -3,6 +3,8 @@ __license__ = "AGPL v3" from .models import College, Fellowship, FellowshipNomination +import datetime +from django.utils import timezone def check_profile_eligibility_for_fellowship(profile): diff --git a/scipost_django/colleges/views.py b/scipost_django/colleges/views.py index 4489a90a019b4c8ef28c7253eefa3f758921a41d..1e78d148a5d7668819fd58885ccf1eeb10b5b222 100644 --- a/scipost_django/colleges/views.py +++ b/scipost_django/colleges/views.py @@ -28,6 +28,7 @@ from colleges.permissions import ( is_edadmin_or_advisory_or_active_regular_or_senior_fellow, ) from colleges.utils import check_profile_eligibility_for_fellowship +from scipost.permissions import HTMXResponse from submissions.models import Submission from .constants import ( @@ -41,6 +42,8 @@ from .constants import ( ) from .forms import ( CollegeChoiceForm, + FellowshipNominationSearchForm, + FellowshipNominationVotingRoundStartForm, FellowshipSearchForm, FellowshipDynSelForm, FellowshipForm, @@ -695,18 +698,7 @@ def nominations(request): """ List Nominations. """ - profile_dynsel_form = ProfileDynSelForm( - initial={ - "action_url_name": "colleges:_hx_nomination_form", - "action_url_base_kwargs": {}, - "action_target_element_id": "nomination_form_response", - "action_target_swap": "innerHTML", - } - ) - context = { - "profile_dynsel_form": profile_dynsel_form, - "search_nominations_form": FellowshipNominationSearchForm(), - } + context = {} return render(request, "colleges/nominations.html", context) @@ -734,9 +726,9 @@ def _hx_nomination_form(request, profile_id): by=request.user.contributor, ) event.save() - return HttpResponse( - f'<div class="bg-success text-white p-2 ">{nomination.profile} ' - f"successfully nominated to {nomination.college}.</div>" + return HTMXResponse( + f"{nomination.profile} successfully nominated to {nomination.college}.", + tag="success", ) nomination_form.fields["nominated_by"].initial = request.user.contributor context = { @@ -746,26 +738,52 @@ def _hx_nomination_form(request, profile_id): return render(request, "colleges/_hx_nomination_form.html", context) +def _hx_nomination_round_remove_voter(request, round_id, voter_id): + """Remove a voter from a nomination's voting round.""" + round = get_object_or_404(FellowshipNominationVotingRound, pk=round_id) + + voter = get_object_or_404(Fellowship, pk=voter_id) + if voter in round.eligible_to_vote.all(): + round.eligible_to_vote.remove(voter) + round.save() + messages.success( + request, f"Removed {voter} from the voters list of this round." + ) + else: + messages.error(request, f"{voter} was not in the voters list of this round.") + return HttpResponse("") + + @login_required -@user_passes_test(is_edadmin_or_senior_fellow) -def _hx_nominations_needing_specialties(request): - nominations_needing_specialties = FellowshipNomination.objects.filter( - profile__specialties__isnull=True, - ) +@user_passes_test(is_edadmin_or_advisory_or_active_regular_or_senior_fellow) +def _hx_nomination_details_contents(request, nomination_id): + """For (re)loading the details if modified.""" + nomination = get_object_or_404(FellowshipNomination, pk=nomination_id) context = { - "nominations_needing_specialties": nominations_needing_specialties, + "nomination": nomination, } - return render( - request, - "colleges/_hx_nominations_needing_specialties.html", - context, + return render(request, "colleges/_hx_nomination_details_contents.html", context) + + +def _hx_nominations_search_form(request, filter_set: str): + form = FellowshipNominationSearchForm( + user=request.user, + session_key=request.session.session_key, ) + if filter_set == "empty": + form.apply_filter_set({}, none_on_empty=True) -@login_required -@user_passes_test(is_edadmin_or_advisory_or_active_regular_or_senior_fellow) -def _hx_nominations(request): - form = FellowshipNominationSearchForm(request.POST or None) + context = { + "form": form, + } + return render(request, "colleges/_hx_nominations_search_form.html", context) + + +def _hx_nominations_list(request): + form = FellowshipNominationSearchForm( + request.POST or None, user=request.user, session_key=request.session.session_key + ) if form.is_valid(): nominations = form.search_results() else: @@ -773,19 +791,63 @@ def _hx_nominations(request): paginator = Paginator(nominations, 16) page_nr = request.GET.get("page") page_obj = paginator.get_page(page_nr) - context = {"page_obj": page_obj} - return render(request, "colleges/_hx_nominations.html", context) + count = paginator.count + start_index = page_obj.start_index + context = { + "count": count, + "page_obj": page_obj, + "start_index": start_index, + } + return render(request, "colleges/_hx_nominations_list.html", context) @login_required @user_passes_test(is_edadmin_or_advisory_or_active_regular_or_senior_fellow) -def _hx_nomination_li_contents(request, nomination_id): - """For (re)loading the details if modified.""" +def _hx_nomination_voting_rounds_tab(request, nomination_id, round_id): + """Render the selected voting round contents and display the others as tabs.""" nomination = get_object_or_404(FellowshipNomination, pk=nomination_id) + voting_rounds = nomination.voting_rounds.all().order_by("-voting_opens") + + inaccessible_round_ids = [ + round.id for round in voting_rounds if not round.can_view(request.user) + ] + + should_show_new_round_tab_btn = request.user.contributor.is_ed_admin and ( + nomination.voting_rounds.count() == 0 + or ( + nomination.latest_voting_round.is_closed + and (decision := getattr(nomination.latest_voting_round, "decision", None)) + and not decision.outcome == FellowshipNominationDecision.OUTCOME_ELECTED + ) + ) + context = { "nomination": nomination, + "voting_rounds": voting_rounds, + "inaccessible_round_ids": inaccessible_round_ids, + "should_show_new_round_tab_btn": should_show_new_round_tab_btn, } - return render(request, "colleges/_hx_nomination_li_contents.html", context) + + if round_id != 0: + selected_round = voting_rounds.get(id=round_id) + context["selected_round"] = selected_round + + return render(request, "colleges/_hx_nomination_voting_rounds_tab.html", context) + + +@login_required +@user_passes_test(is_edadmin) +def _hx_nomination_voting_rounds_create(request, nomination_id): + nomination = get_object_or_404(FellowshipNomination, pk=nomination_id) + new_round = FellowshipNominationVotingRound( + nomination=nomination, voting_opens=None, voting_deadline=None + ) + new_round.save() + _ = _hx_nomination_round_add_eligible_voter_set( + request, new_round.id, "with_specialty_overlap" + ) + + return _hx_nomination_voting_rounds_tab(request, nomination_id, new_round.id) @login_required @@ -810,63 +872,20 @@ def _hx_nomination_comments(request, nomination_id): @login_required @user_passes_test(is_edadmin_or_advisory_or_active_regular_or_senior_fellow) -def _hx_voting_rounds(request): - selected = request.GET.get("tab", "ongoing") - tab_choices = [] - if request.user.contributor.is_ed_admin: - tab_choices += [ - ("ongoing", "Ongoing"), - ("closed-pending", "Closed"), - ("closed-elected", "Closed (elected)"), - ("closed-notelected", "Closed (not elected)"), - ] - elif request.user.contributor.is_active_fellow: - tab_choices += [ - ("ongoing-vote_required", "Cast your vote (election ongoing)"), - ("ongoing-voted", "Votes you have cast (election ongoing)"), - ("closed-voted", "Votes you have cast (election closed)"), - ] - fellowship = request.user.contributor.session_fellowship(request) - voting_rounds = FellowshipNominationVotingRound.objects.all() - if "ongoing" in selected: - voting_rounds = voting_rounds.ongoing() - if "closed" in selected: - voting_rounds = voting_rounds.closed() - if "-pending" in selected: - voting_rounds = voting_rounds.filter(nomination__decision__isnull=True) - if "-elected" in selected: - voting_rounds = voting_rounds.filter( - nomination__decision__outcome=FellowshipNominationDecision.OUTCOME_ELECTED - ) - if "-notelected" in selected: - voting_rounds = voting_rounds.filter( - nomination__decision__outcome=FellowshipNominationDecision.OUTCOME_NOT_ELECTED - ) - if "vote_required" in selected: - # show all voting rounds to edadmin; for Fellow, filter - if not request.user.contributor.is_ed_admin: - voting_rounds = voting_rounds.filter(eligible_to_vote=fellowship).exclude( - votes__fellow=fellowship - ) - if "voted" in selected: - voting_rounds = voting_rounds.filter(votes__fellow=fellowship) - context = { - "tab_choices": tab_choices, - "selected": selected, - "voting_rounds": voting_rounds, - } - return render(request, "colleges/_hx_voting_rounds.html", context) - - -@login_required -@user_passes_test(is_edadmin_or_advisory_or_active_regular_or_senior_fellow) -def _hx_nomination_vote(request, voting_round_id): +def _hx_nomination_vote(request, round_id): fellowship = request.user.contributor.session_fellowship(request) voting_round = get_object_or_404( FellowshipNominationVotingRound, - pk=voting_round_id, + pk=round_id, eligible_to_vote=fellowship, ) + + # Check if the voting round is still open + if not voting_round.is_open: + return HTMXResponse( + """You cannot vote in non-open rounds.""", + tag="danger", + ) if request.method == "POST": vote_object, created = FellowshipNominationVote.objects.update_or_create( voting_round=voting_round, @@ -894,15 +913,42 @@ def _hx_nomination_vote(request, voting_round_id): context = { "voting_round": voting_round, "vote_object": vote_object, + "VOTE_BS_CLASSES": FellowshipNominationVote.VOTE_BS_CLASSES, } return render(request, "colleges/_hx_nomination_vote.html", context) @login_required @user_passes_test(is_edadmin) -def _hx_nomination_decision(request, nomination_id): - nomination = get_object_or_404(FellowshipNomination, pk=nomination_id) - decision_form = FellowshipNominationDecisionForm(request.POST or None) +def _hx_voting_round_start_form(request, round_id): + round = get_object_or_404(FellowshipNominationVotingRound, pk=round_id) + form = FellowshipNominationVotingRoundStartForm( + request.POST or None, + instance=round, + ) + if form.is_valid(): + form.save() + messages.success( + request, + f"Voting round for {round.nomination.profile} started " + f"from {round.voting_opens} until {round.voting_deadline}.", + ) + + return render( + request, + "colleges/_hx_voting_round_start_form.html", + {"form": form, "round": round}, + ) + + +@login_required +@user_passes_test(is_edadmin) +def _hx_nomination_decision_form(request, round_id): + voting_round = get_object_or_404(FellowshipNominationVotingRound, pk=round_id) + nomination = voting_round.nomination + decision_form = FellowshipNominationDecisionForm( + request.POST or None, voting_round=voting_round + ) if decision_form.is_valid(): decision = decision_form.save() nomination.add_event(description="Decision fixed", by=request.user.contributor) @@ -915,29 +961,29 @@ def _hx_nomination_decision(request, nomination_id): nomination.add_event( description="Invitation created", by=request.user.contributor ) - else: - decision_form.fields["nomination"].initial = nomination context = { - "nomination": nomination, + "voting_round": voting_round, "decision_form": decision_form, } - return render(request, "colleges/_hx_nomination_decision.html", context) + return render(request, "colleges/_hx_nomination_decision_form.html", context) -@login_required -@user_passes_test(is_edadmin) -def _hx_nominations_invitations(request): - selected = request.GET.get("response", "notyetinvited") - invitations = FellowshipInvitation.objects.filter( - nomination__fellowship__isnull=True, - response=selected, +# Check permission to create a new nomination +def _hx_nomination_new(request): + """Render the contents of the new nomination form.""" + profile_dynsel_form = ProfileDynSelForm( + initial={ + "action_url_name": "colleges:_hx_nomination_form", + "action_url_base_kwargs": {}, + "action_target_element_id": "nomination_form_response", + "action_target_swap": "innerHTML", + } ) context = { - "response_choices": FellowshipInvitation.RESPONSE_CHOICES, - "selected": selected, - "invitations": invitations, + "profile_dynsel_form": profile_dynsel_form, } - return render(request, "colleges/_hx_nominations_invitations.html", context) + + return render(request, "colleges/_hx_nomination_new.html", context) class FellowshipInvitationEmailInitialView(PermissionsMixin, MailView): @@ -974,12 +1020,69 @@ def _hx_fellowship_invitation_update_response(request, invitation_id): description=f"Response updated to: {invitation.get_response_display()}", by=request.user.contributor, ) - return redirect( - "%s?response=%s" - % ( - reverse("colleges:_hx_nominations_invitations"), - form.cleaned_data["response"], + + nonexpired_fellowship = ( + Fellowship.objects.exclude( + until_date__lte=timezone.now().date(), ) + .filter( + college=invitation.nomination.college, + contributor=invitation.nomination.profile.contributor, + ) + .order_by("-start_date") + .first() + ) + + # If the invitation is accepted or postponed, create a Fellowship + if invitation.response in [ + FellowshipInvitation.RESPONSE_ACCEPTED, + FellowshipInvitation.RESPONSE_POSTPONED, + ]: + # Create a new Fellowship if no object exists + if not nonexpired_fellowship: + fellowship = Fellowship.objects.create( + college=invitation.nomination.college, + contributor=invitation.nomination.profile.contributor, + start_date=timezone.now() + if invitation.response == FellowshipInvitation.RESPONSE_ACCEPTED + else invitation.postpone_start_to, + until_date=None, + ) + + invitation.nomination.add_event( + description=f"Fellowship created (start: {fellowship.start_date.strftime('%Y-%m-%d')})", + by=request.user.contributor, + ) + else: + # Update the start date of the Fellowship if an object already exists + nonexpired_fellowship.start_date = ( + timezone.now() + if invitation.response == FellowshipInvitation.RESPONSE_ACCEPTED + else invitation.postpone_start_to + ) + nonexpired_fellowship.until_date = None + invitation.nomination.add_event( + description=f"Fellowship start date updated (start: {nonexpired_fellowship.start_date.strftime('%Y-%m-%d')})", + by=request.user.contributor, + ) + nonexpired_fellowship.save() + # Terminate the Fellowship if the invitation is declined + elif invitation.response == FellowshipInvitation.RESPONSE_DECLINED: + if nonexpired_fellowship: + nonexpired_fellowship.until_date = ( + timezone.now().date() + if nonexpired_fellowship.is_active() + else nonexpired_fellowship.start_date + ) + invitation.nomination.add_event( + description=f"Fellowship ended (end: {nonexpired_fellowship.until_date.strftime('%Y-%m-%d')})", + by=request.user.contributor, + ) + nonexpired_fellowship.save() + + return HTMXResponse( + f"Response updated to: {invitation.get_response_display()}", + tag="success", ) context = { "invitation": invitation, @@ -990,3 +1093,99 @@ def _hx_fellowship_invitation_update_response(request, invitation_id): "colleges/_hx_nomination_invitation_update_response.html", context, ) + + +@login_required +@user_passes_test(is_edadmin_or_senior_fellow) +def _hx_nomination_voter_table(request, round_id): + round = get_object_or_404(FellowshipNominationVotingRound, pk=round_id) + voters = round.eligible_to_vote.all() + nominee_specialties = round.nomination.profile.specialties.all() + + for voter in voters: + voter.vote = round.votes.filter(fellow=voter).first() + + context = { + "voters": voters, + "round": round, + "nominee_specialties": nominee_specialties, + } + return render(request, "colleges/_hx_nomination_voter_table.html", context) + + +@login_required +@user_passes_test(is_edadmin) +def _hx_nomination_round_eligible_voter_action( + request, round_id, fellowship_id, action +): + round = get_object_or_404(FellowshipNominationVotingRound, pk=round_id) + fellowship = get_object_or_404(Fellowship, pk=fellowship_id) + + if action == "add": + print(round.nomination.profile) + if round.nomination.profile.has_competing_interest_with( + fellowship.contributor.profile + ): + messages.error( + request, + f"{fellowship} has a competing interest with the nominee and cannot be added to the voters list.", + ) + else: + round.eligible_to_vote.add(fellowship) + if action == "remove": + round.eligible_to_vote.remove(fellowship) + return redirect( + reverse("colleges:_hx_nomination_voter_table", kwargs={"round_id": round.id}) + ) + + +@login_required +@user_passes_test(is_edadmin) +def _hx_nomination_round_add_eligible_voter_set(request, round_id, voter_set_name): + round = get_object_or_404(FellowshipNominationVotingRound, pk=round_id) + + voter_set = Fellowship.objects.none() + + senior_active_fellows = ( + Fellowship.objects.active() + .no_competing_interests_with(round.nomination.profile) + .senior() + ) + + if voter_set_name == "with_specialty_overlap": + specialties_slug_list = [ + s.slug for s in round.nomination.profile.specialties.all() + ] + voter_set = senior_active_fellows.specialties_overlap(specialties_slug_list) + elif voter_set_name == "all_seniors": + voter_set = senior_active_fellows.filter(college=round.nomination.college) + + round.eligible_to_vote.add(*voter_set.distinct()) + return redirect( + reverse("colleges:_hx_nomination_voter_table", kwargs={"round_id": round.id}) + ) + + +@login_required +@user_passes_test(is_edadmin_or_senior_fellow) +def _hx_voting_round_details(request, round_id): + round = get_object_or_404(FellowshipNominationVotingRound, pk=round_id) + context = { + "round": round, + } + + if not round.can_view(request.user): + return HTMXResponse("You are not allowed to view this round.", tag="danger") + + if not round.is_closed: + voter_add_form = FellowshipDynSelForm( + initial={ + "action_url_name": "colleges:_hx_nomination_round_eligible_voter_action", + "action_url_base_kwargs": {"round_id": round_id, "action": "add"}, + "action_target_element_id": f"nomination-{round.nomination.id}-round-{round_id}-voters", + "action_target_swap": "innerHTML", + } + ) + context["voter_add_form"] = voter_add_form + + return render(request, "colleges/_hx_voting_round_details.html", context) diff --git a/scipost_django/common/urls.py b/scipost_django/common/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..62d8ae697bfac4596f6bd845824e3c664b30e5c9 --- /dev/null +++ b/scipost_django/common/urls.py @@ -0,0 +1,17 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.urls import path + +from . import views + +app_name = "common" + +urlpatterns = [ + path( + "empty", + views.empty, + name="empty", + ) +] diff --git a/scipost_django/common/views.py b/scipost_django/common/views.py new file mode 100644 index 0000000000000000000000000000000000000000..006780c0cef27c3a698ec9292262332e99b601fa --- /dev/null +++ b/scipost_django/common/views.py @@ -0,0 +1,8 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + +from django.http import HttpResponse + + +def empty(request): + return HttpResponse("") diff --git a/scipost_django/profiles/managers.py b/scipost_django/profiles/managers.py index c185168a5b463de5377e0b2c0f17244399278245..887cc5307efb340fbebe6886890840cbe11a1339 100644 --- a/scipost_django/profiles/managers.py +++ b/scipost_django/profiles/managers.py @@ -72,6 +72,18 @@ class ProfileQuerySet(models.QuerySet): """ return self.filter(specialties__slug__in=specialties_slug_list) + def no_competing_interests_with(self, profile): + """ + Returns all Profiles which have no competing interests with the specified profile. + """ + from ethics.models import CompetingInterest + + profile_CI, related_CI = CompetingInterest.objects.filter( + Q(profile=profile) | Q(related_profile=profile) + ).values_list("profile", "related_profile") + + return self.exclude(id__in=profile_CI + related_CI) + class AffiliationQuerySet(models.QuerySet): def current(self): diff --git a/scipost_django/profiles/models.py b/scipost_django/profiles/models.py index 16fde268cd75b01aad22e84e860083ecb330b100..fe35187eb900a18ee85d82986ab40189cabc43ef 100644 --- a/scipost_django/profiles/models.py +++ b/scipost_django/profiles/models.py @@ -3,6 +3,7 @@ __license__ = "AGPL v3" import datetime +from django.db.models import Q from django.urls import reverse from django.db import models @@ -164,6 +165,17 @@ class Profile(models.Model): "fulfilled": invitations.filter(fulfilled=True).count(), } + def has_competing_interest_with(self, profile): + """ + Returns True if this Profile has a CompetingInterest with the given Profile. + """ + from ethics.models import CompetingInterest + + return CompetingInterest.objects.filter( + Q(profile=self, related_profile=profile) + | Q(related_profile=self, profile=profile) + ).exists() + class ProfileEmail(models.Model): """Any email related to a Profile instance.""" diff --git a/scipost_django/profiles/templates/profiles/_affiliations_table.html b/scipost_django/profiles/templates/profiles/_affiliations_table.html index dc981e2ebf9693e2be638a51921f6aa721a3be19..2900c0bb3487a03a08dd4e671575ada8427268de 100644 --- a/scipost_django/profiles/templates/profiles/_affiliations_table.html +++ b/scipost_django/profiles/templates/profiles/_affiliations_table.html @@ -1,20 +1,24 @@ -<table class="table"> - <thead class="table-light"> +<table class="table mb-0"> + <thead> <tr> <th>Organization</th> <th>Category</th> <th>From</th> <th>Until</th> - {% if actions %} - <td>Actions</td> - {% endif %} + + {% if actions %}<th>Actions</th>{% endif %} + </tr> </thead> <tbody> + {% for aff in profile.affiliations.all %} {% include 'profiles/_affiliations_table_row.html' with affiliation=aff actions=actions %} {% empty %} - <tr><td colspan="4">No Affiliation has been defined</td></tr> + <tr> + <td colspan="4">No Affiliation has been defined</td> + </tr> {% endfor %} + </tbody> </table> diff --git a/scipost_django/profiles/templates/profiles/_hx_profile_specialties.html b/scipost_django/profiles/templates/profiles/_hx_profile_specialties.html index e695a1b549269a0fdcd43a6449f0040005c8c554..1168855e42c3eb14c8a92a05050f0b41ef43dd21 100644 --- a/scipost_django/profiles/templates/profiles/_hx_profile_specialties.html +++ b/scipost_django/profiles/templates/profiles/_hx_profile_specialties.html @@ -2,49 +2,63 @@ <div class="col"> <table class="table"> <caption style="caption-side: top;">Specialties (current)</caption> + {% for spec in profile.specialties.all %} - <tr> - <td>{{ spec }}</td> - <td> - <form hx-post="{% url 'profiles:_hx_profile_specialties' profile_id=profile.id %}" - hx-target="#profile-{{ profile.id }}-specialties" - > - {% csrf_token %} - <input type="hidden" name="action" value="remove"> - <input type="hidden" name="spec_slug" value="{{ spec.slug }}"> - <button class="btn btn-sm btn-danger">Remove</button> - </form> - </td> - </tr> + <tr> + <td>{{ spec }}</td> + <td> + <form hx-post="{% url 'profiles:_hx_profile_specialties' profile_id=profile.id %}" + hx-target="#profile-{{ profile.id }}-specialties"> + {% csrf_token %} + <input type="hidden" name="action" value="remove" /> + <input type="hidden" name="spec_slug" value="{{ spec.slug }}" /> + <button class="btn btn-sm btn-danger">Remove</button> + </form> + </td> + </tr> {% empty %} - <tr> - <td colspan="2">None defined</td> - </tr> + <tr> + <td colspan="2">None defined</td> + </tr> {% endfor %} + </table> </div> <div class="col"> <table class="table"> <caption style="caption-side: top;">Other specialties</caption> + {% for spec in other_specialties.all %} - <tr> - <td>{{ spec }}</td> - <td> - <form hx-post="{% url 'profiles:_hx_profile_specialties' profile_id=profile.id %}" - hx-target="#profile-{{ profile.id }}-specialties" - > - {% csrf_token %} - <input type="hidden" name="action" value="add"> - <input type="hidden" name="spec_slug" value="{{ spec.slug }}"> - <button class="btn btn-sm btn-primary">Add</button> - </form> - </td> - </tr> + <tr> + <td>{{ spec }}</td> + <td> + <form hx-post="{% url 'profiles:_hx_profile_specialties' profile_id=profile.id %}" + hx-target="#profile-{{ profile.id }}-specialties"> + {% csrf_token %} + <input type="hidden" name="action" value="add" /> + <input type="hidden" name="spec_slug" value="{{ spec.slug }}" /> + <button class="btn btn-sm btn-primary">Add</button> + </form> + </td> + </tr> {% empty %} - <tr> - <td colspan="2">None defined</td> - </tr> + <tr> + <td colspan="2">None defined</td> + </tr> {% endfor %} + </table> </div> </div> + +<button class="btn btn-success mb-2 text-white" + hx-get="{% url "common:empty" %}" + hx-swap="innerHTML" + hx-target="#profile-{{ profile.id }}-specialties">Done</button> + + +<div hx-swap-oob="innerHTML:#profile-{{ profile.id }}-specialties-code-display"> + + {% include "profiles/_hx_profile_specialty_codes_edit.html" with profile=profile %} + +</div> diff --git a/scipost_django/profiles/templates/profiles/_hx_profile_specialty_codes_edit.html b/scipost_django/profiles/templates/profiles/_hx_profile_specialty_codes_edit.html new file mode 100644 index 0000000000000000000000000000000000000000..ce66b675f0ddc601652c03ef09d7f84cbf56836d --- /dev/null +++ b/scipost_django/profiles/templates/profiles/_hx_profile_specialty_codes_edit.html @@ -0,0 +1,13 @@ +{% for specialty in profile.specialties.all %} + <div class="single d-inline" + data-specialty="{{ specialty }}" + data-bs-toggle="tooltip" + data-bs-placement="bottom" + title="{{ specialty }}">{{ specialty.code }}</div> +{% empty %} + <span class="badge bg-danger">unknown</span> +{% endfor %} + +<a class="p-2 mt-2" + hx-get="{% url 'profiles:_hx_profile_specialties' profile_id=profile.id %}" + hx-target="#profile-{{ profile.id }}-specialties">edit</a> diff --git a/scipost_django/scipost/management/commands/add_groups_and_permissions.py b/scipost_django/scipost/management/commands/add_groups_and_permissions.py index 4c3fdeffed1baec0d3db1555355e4b85c77a7694..e57c7baca207ba325135c8803b0b4797534c49bb 100644 --- a/scipost_django/scipost/management/commands/add_groups_and_permissions.py +++ b/scipost_django/scipost/management/commands/add_groups_and_permissions.py @@ -405,6 +405,16 @@ class Command(BaseCommand): content_type=content_type, ) + # Fellowship Nominations + ( + can_view_all_nomination_voting_rounds, + created, + ) = Permission.objects.get_or_create( + codename="can_view_all_nomination_voting_rounds", + name="Can view all voting rounds for Fellowship nominations", + content_type=content_type, + ) + # Assign permissions to groups SciPostAdmin.permissions.set( [ @@ -438,6 +448,7 @@ class Command(BaseCommand): can_view_potentialfellowship_list, can_add_potentialfellowship, can_preview_new_features, + can_view_all_nomination_voting_rounds, ] ) @@ -497,6 +508,7 @@ class Command(BaseCommand): can_view_potentialfellowship_list, can_add_potentialfellowship, can_preview_new_features, + can_view_all_nomination_voting_rounds, ] ) diff --git a/scipost_django/scipost/static/scipost/assets/config/preconfig.scss b/scipost_django/scipost/static/scipost/assets/config/preconfig.scss index b47abe90de8a04b4e4464510922d8bba421bae13..ed517ef68361c1ac061ebda19dbb3fee55d79781 100644 --- a/scipost_django/scipost/static/scipost/assets/config/preconfig.scss +++ b/scipost_django/scipost/static/scipost/assets/config/preconfig.scss @@ -223,39 +223,44 @@ $theme-colors-rgb: map-loop($theme-colors, to-rgb, "$value"); height: 1.5em !important; } -summary { - // Remove triangle for webkit browsers causing problems with flexbox - &::-webkit-details-marker { - display: none; - } - - // Remove all list styles - &.list-none { - list-style: none; - } - - // List triangle for summary element (necessary with display: flex) - &.list-triangle { - position: relative; - padding-left: 2em !important; - - // Styling the equilateral triangle - &::before { - content: "â–¶"; - position: absolute; - left: 0.75em; - top: 50%; - transform: translateY(-50%); +details { + summary { + // Remove triangle for webkit browsers causing problems with flexbox + &::-webkit-details-marker { + display: none; + } + + // Remove all list styles + &.list-none { + list-style: none; + } + + // // List triangle for summary element (necessary with display: flex) + &.list-triangle { + position: relative; + padding-left: 2em !important; + + // Styling the equilateral triangle + &::before { + content: "â–¶"; + position: absolute; + left: 0.75em; + top: 50%; + transform: translateY(-50%); + transition: transform 0.05s linear; + } + + @at-root { + // Rotate the equilateral triangle when summary is open + details[open] > summary.list-triangle::before { + content: "â–¶"; + transform: translateY(-50%) translateX(-25%) rotate(90deg); + } + } + } } - } } -// Rotate the equilateral triangle when summary is open -details[open] summary.list-triangle::before { - content: "â–¼"; -} - - // Utilities for details element // Hide details preview when open details[open] { @@ -263,7 +268,7 @@ details[open] { display: none; } } -details[closed] { +details:not([open]) { .details-preview { display: unset; }