diff --git a/scipost_django/colleges/admin.py b/scipost_django/colleges/admin.py index 00d684cb3131be55695c0e98d5ee838e6a3b7028..8ea8e1e36c7abb595d86ca6889a7f5bb5ec5d3ff 100644 --- a/scipost_django/colleges/admin.py +++ b/scipost_django/colleges/admin.py @@ -4,7 +4,13 @@ __license__ = "AGPL v3" from django.contrib import admin -from .models import College, Fellowship, PotentialFellowship, PotentialFellowshipEvent +from .models import ( + College, Fellowship, + PotentialFellowship, PotentialFellowshipEvent, + FellowshipNomination, FellowshipNominationEvent, + FellowshipNominationVotingRound, FellowshipNominationVote, + FellowshipNominationDecision, FellowshipInvitation +) admin.site.register(College) @@ -57,3 +63,64 @@ class PotentialFellowshipAdmin(admin.ModelAdmin): ] admin.site.register(PotentialFellowship, PotentialFellowshipAdmin) + + +class FellowshipNominationEventInline(admin.TabularInline): + model = FellowshipNominationEvent + extra = 0 + +class FellowshipNominationVotingRoundInline(admin.TabularInline): + model = FellowshipNominationVotingRound + extra = 0 + + +class FellowshipNominationDecisionInline(admin.TabularInline): + model = FellowshipNominationDecision + extra = 0 + + +class FellowshipInvitationInline(admin.TabularInline): + model = FellowshipInvitation + extra = 0 + + +class FellowshipNominationAdmin(admin.ModelAdmin): + inlines = [ + FellowshipNominationEventInline, + FellowshipNominationVotingRoundInline, + FellowshipNominationDecisionInline, + FellowshipInvitationInline, + ] + list_display = [ + 'college', + 'profile', + 'nominated_on' + ] + search_fields = [ + 'college', + 'profile' + ] + autocomplete_fields = [ + 'profile', + 'nominated_by', + 'fellowship' + ] + +admin.site.register(FellowshipNomination, FellowshipNominationAdmin) + + +class FellowshipNominationVoteInline(admin.TabularInline): + model = FellowshipNominationVote + extra = 0 + +class FellowshipNominationVotingRoundAdmin(admin.ModelAdmin): + model = FellowshipNominationVotingRound + inlines = [ + FellowshipNominationVoteInline, + ] + autocomplete_fields = [ + 'nomination', + 'eligible_to_vote', + ] + +admin.site.register(FellowshipNominationVotingRound, FellowshipNominationVotingRoundAdmin) diff --git a/scipost_django/colleges/forms.py b/scipost_django/colleges/forms.py index 06f13cf2ba413f44503d15aa3dd1b410952e46ac..a55fd7cf9ac44c072c88cefbe79607d3b6cec6d7 100644 --- a/scipost_django/colleges/forms.py +++ b/scipost_django/colleges/forms.py @@ -8,19 +8,27 @@ from django import forms from django.db.models import Q from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout, Field +from crispy_forms.layout import Layout, Div, Field, Hidden, ButtonHolder, Submit from crispy_bootstrap5.bootstrap5 import FloatingField from dal import autocomplete +from ontology.models import Specialty from proceedings.models import Proceedings from profiles.models import Profile from submissions.models import Submission from scipost.forms import RequestFormMixin from scipost.models import Contributor -from .models import Fellowship, PotentialFellowship, PotentialFellowshipEvent -from .constants import POTENTIAL_FELLOWSHIP_IDENTIFIED, POTENTIAL_FELLOWSHIP_NOMINATED,\ +from .models import ( + College, Fellowship, + PotentialFellowship, PotentialFellowshipEvent, + FellowshipNomination, +) +from .constants import ( + POTENTIAL_FELLOWSHIP_IDENTIFIED, POTENTIAL_FELLOWSHIP_NOMINATED, POTENTIAL_FELLOWSHIP_EVENT_DEFINED, POTENTIAL_FELLOWSHIP_EVENT_NOMINATED +) +from .utils import check_profile_eligibility_for_fellowship class FellowshipSelectForm(forms.Form): @@ -34,7 +42,8 @@ class FellowshipSelectForm(forms.Form): class FellowshipDynSelForm(forms.Form): q = forms.CharField(max_length=32, label='Search (by name)') action_url_name = forms.CharField() - action_url_base_kwargs = forms.JSONField() + action_url_base_kwargs = forms.JSONField(required=False) + action_target_element_id = forms.CharField() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -43,6 +52,7 @@ class FellowshipDynSelForm(forms.Form): FloatingField('q', autocomplete='off'), Field('action_url_name', type='hidden'), Field('action_url_base_kwargs', type='hidden'), + Field('action_target_element_id', type='hidden'), ) def search_results(self): @@ -270,3 +280,113 @@ class PotentialFellowshipEventForm(forms.ModelForm): self.fields['comments'].widget.attrs.update({ 'placeholder': 'NOTA BENE: careful, will be visible to all who have voting rights' }) + + +############### +# Nominations # +############### + +class FellowshipNominationForm(forms.ModelForm): + + class Meta: + model = FellowshipNomination + fields = [ + 'nominated_by', # hidden + 'college', 'nominator_comments' # visible + ] + + def __init__(self, *args, **kwargs): + self.profile = kwargs.pop('profile') + super().__init__(*args, **kwargs) + self.fields['college'].queryset = College.objects.filter( + acad_field=self.profile.acad_field) + self.fields['college'].empty_label = None + self.fields['nominator_comments'].label = False + self.fields['nominator_comments'].widget.attrs['rows'] = 4 + self.fields['nominator_comments'].widget.attrs[ + 'placeholder'] = 'Optional comments and/or recommendations' + self.helper = FormHelper() + self.helper.layout = Layout( + Field('profile_id', type='hidden'), + Field('nominated_by', type='hidden'), + Div( + Div(Field('nominator_comments'), css_class='col-lg-8'), + Div( + FloatingField('college'), + ButtonHolder(Submit('submit', 'Nominate', css_class='btn btn-success float-end')), + css_class="col-lg-4" + ), + css_class='row pt-1' + ), + ) + + def clean(self): + data = super().clean() + failed_eligibility_criteria = check_profile_eligibility_for_fellowship(self.profile) + if failed_eligibility_criteria: + for critetion in failed_eligibility_criteria: + self.add_error(None, criterion) + if data['college'].acad_field != self.profile.acad_field: + self.add_error( + 'college', + 'Mismatch between college.acad_field and profile.acad_field.' + ) + return data + + def save(self): + nomination = super().save(commit=False) + nomination.profile = self.profile + nomination.save() + 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(), + # widget=autocomplete.ModelSelect2( + # url='/ontology/specialty-autocomplete', + # attrs={'data-html': True} + # ), + 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( + 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 diff --git a/scipost_django/colleges/managers.py b/scipost_django/colleges/managers.py index 752aa1df188960b1677ad724c89010757788a876..dd0a5582005728b1bbfb47384a2f47f0aa6812c3 100644 --- a/scipost_django/colleges/managers.py +++ b/scipost_django/colleges/managers.py @@ -98,3 +98,14 @@ class PotentialFellowshipQuerySet(models.QuerySet): Q(in_agreement__in=[contributor]) | Q(in_abstain__in=[contributor]) | Q(in_disagreement__in=[contributor])) + + +class FellowshipNominationVotingRoundQuerySet(models.QuerySet): + + def ongoing(self): + now = timezone.now() + return self.filter(voting_opens__lte=now, voting_deadline__gte=now) + + def closed(self): + now = timezone.now() + return self.filter(voting_deadline__lte=now) diff --git a/scipost_django/colleges/migrations/0031_fellowshipinvitation_fellowshipnomination_fellowshipnominationdecision_fellowshipnominationevent_fel.py b/scipost_django/colleges/migrations/0031_fellowshipinvitation_fellowshipnomination_fellowshipnominationdecision_fellowshipnominationevent_fel.py new file mode 100644 index 0000000000000000000000000000000000000000..2e2f529a463f6f708d3fac331bc0a238af31d7e0 --- /dev/null +++ b/scipost_django/colleges/migrations/0031_fellowshipinvitation_fellowshipnomination_fellowshipnominationdecision_fellowshipnominationevent_fel.py @@ -0,0 +1,101 @@ +# Generated by Django 3.2.5 on 2022-01-28 15:35 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0035_alter_profile_title'), + ('scipost', '0040_auto_20210310_2026'), + ('colleges', '0030_auto_20210326_1502'), + ] + + operations = [ + migrations.CreateModel( + name='FellowshipNomination', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nominated_on', models.DateTimeField(default=django.utils.timezone.now)), + ('college', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='nominations', to='colleges.college')), + ('fellowship', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='nomination', to='colleges.fellowship')), + ('nominated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fellowship_nominations_initiated', to='scipost.contributor')), + ('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fellowship_nominations', to='profiles.profile')), + ], + options={ + 'verbose_name_plural': 'Fellowship Nominations', + 'ordering': ['profile', 'college'], + }, + ), + migrations.CreateModel( + name='FellowshipNominationVotingRound', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('voting_opens', models.DateTimeField()), + ('voting_deadline', models.DateTimeField()), + ('eligible_to_vote', models.ManyToManyField(blank=True, related_name='voting_rounds_eligible_to_vote_in', to='colleges.Fellowship')), + ('nomination', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='voting_rounds', to='colleges.fellowshipnomination')), + ], + options={ + 'verbose_name_plural': 'Fellowship Nomination Voting Rounds', + 'ordering': ['nomination__profile__last_name'], + }, + ), + migrations.CreateModel( + name='FellowshipNominationVote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('vote', models.CharField(choices=[('agree', 'Agree'), ('abstain', 'Abstain'), ('disagree', 'Disagree')], max_length=16)), + ('on', models.DateTimeField(blank=True, null=True)), + ('comments', models.TextField(blank=True)), + ('fellow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fellowship_nomination_votes', to='colleges.fellowship')), + ('voting_round', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='colleges.fellowshipnominationvotinground')), + ], + options={ + 'verbose_name_plural': 'Fellowship Nomination Votes', + 'ordering': ['voting_round'], + }, + ), + migrations.CreateModel( + name='FellowshipNominationEvent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField()), + ('on', models.DateTimeField(default=django.utils.timezone.now)), + ('nomination', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='colleges.fellowshipnomination')), + ], + options={ + 'verbose_name_plural': 'Fellowhips Nomination Events', + 'ordering': ['-on'], + }, + ), + migrations.CreateModel( + name='FellowshipNominationDecision', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('outcome', models.CharField(choices=[('elected', 'Elected'), ('notelected', 'Not elected')], max_length=16)), + ('fixed_on', models.DateTimeField(default=django.utils.timezone.now)), + ('nomination', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='decision', to='colleges.fellowshipnomination')), + ], + options={ + 'verbose_name_plural': 'Fellowship Nomination Decisions', + 'ordering': ['nomination'], + }, + ), + migrations.CreateModel( + name='FellowshipInvitation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('invited_on', models.DateTimeField(blank=True, null=True)), + ('response', models.CharField(blank=True, choices=[('notyetinvited', 'Not yet invited'), ('invited', 'Invited'), ('reinvited', 'Reinvited'), ('multireinvited', 'Multiply reinvited'), ('unresponsive', 'Unresponsive'), ('accepted', 'Accepted, for immediate start'), ('postponed', 'Accepted, but start date postponed'), ('declined', 'Declined')], max_length=16)), + ('postpone_start_to', models.DateField(blank=True)), + ('nomination', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='invitation', to='colleges.fellowshipnomination')), + ], + options={ + 'verbose_name_plural': 'Fellowship Invitations', + 'ordering': ['nomination'], + }, + ), + ] diff --git a/scipost_django/colleges/migrations/0032_auto_20220129_0837.py b/scipost_django/colleges/migrations/0032_auto_20220129_0837.py new file mode 100644 index 0000000000000000000000000000000000000000..b4fc8bfc203a406b882ad89958f945ecbe88403f --- /dev/null +++ b/scipost_django/colleges/migrations/0032_auto_20220129_0837.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.5 on 2022-01-29 07:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('colleges', '0031_fellowshipinvitation_fellowshipnomination_fellowshipnominationdecision_fellowshipnominationevent_fel'), + ] + + operations = [ + migrations.AlterModelOptions( + name='fellowshipnominationevent', + options={'ordering': ['-on'], 'verbose_name_plural': 'Fellowhip Nomination Events'}, + ), + migrations.AddField( + model_name='fellowshipinvitation', + name='comments', + field=models.TextField(blank=True, help_text='You can use plain text, Markdown or reStructuredText; see our <a href="/markup/help/" target="_blank">markup help</a> pages.'), + ), + migrations.AddField( + model_name='fellowshipnomination', + name='nominator_comments', + field=models.TextField(blank=True, help_text='You can use plain text, Markdown or reStructuredText; see our <a href="/markup/help/" target="_blank">markup help</a> pages.'), + ), + migrations.AddField( + model_name='fellowshipnominationdecision', + name='comments', + field=models.TextField(blank=True, help_text='You can use plain text, Markdown or reStructuredText; see our <a href="/markup/help/" target="_blank">markup help</a> pages.'), + ), + migrations.AlterField( + model_name='fellowshipnominationvote', + name='comments', + field=models.TextField(blank=True, help_text='You can use plain text, Markdown or reStructuredText; see our <a href="/markup/help/" target="_blank">markup help</a> pages.'), + ), + ] diff --git a/scipost_django/colleges/models/__init__.py b/scipost_django/colleges/models/__init__.py index 5e7aeb5facfaeb700d6dfb74b11fab5240d3a597..f2b237461d5d0c32b1432da730f9cafa90641307 100644 --- a/scipost_django/colleges/models/__init__.py +++ b/scipost_django/colleges/models/__init__.py @@ -6,4 +6,10 @@ from .college import College from .fellowship import Fellowship +from .nomination import ( + FellowshipNomination, FellowshipNominationEvent, + FellowshipNominationVotingRound, FellowshipNominationVote, + FellowshipNominationDecision, FellowshipInvitation +) + from .potential_fellowship import PotentialFellowship, PotentialFellowshipEvent diff --git a/scipost_django/colleges/models/nomination.py b/scipost_django/colleges/models/nomination.py new file mode 100644 index 0000000000000000000000000000000000000000..c40687151cfb9688aa64c1ba358cf7c4ac97fb07 --- /dev/null +++ b/scipost_django/colleges/models/nomination.py @@ -0,0 +1,242 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models +from django.utils import timezone + +from ..managers import FellowshipNominationVotingRoundQuerySet + + +class FellowshipNomination(models.Model): + + college = models.ForeignKey( + 'colleges.College', + on_delete=models.PROTECT, + related_name='nominations' + ) + + profile = models.ForeignKey( + 'profiles.Profile', + on_delete=models.CASCADE, + related_name='fellowship_nominations' + ) + + nominated_by = models.ForeignKey( + 'scipost.Contributor', + on_delete=models.CASCADE, + related_name='fellowship_nominations_initiated' + ) + + nominated_on = models.DateTimeField(default=timezone.now) + + nominator_comments = models.TextField( + help_text=('You can use plain text, Markdown or reStructuredText; see our ' + '<a href="/markup/help/" target="_blank">markup help</a> pages.'), + blank=True + ) + + fellowship = models.OneToOneField( + 'colleges.Fellowship', + on_delete=models.CASCADE, + related_name='nomination', + blank=True, null=True + ) + + class Meta: + ordering = [ + 'profile', + 'college', + ] + verbose_name_plural = 'Fellowship Nominations' + + def __str__(self): + return (f'{self.profile} to {self.college} ' + f'on {self.nominated_on.strftime("%Y-%m-%d")}') + + +class FellowshipNominationEvent(models.Model): + + nomination = models.ForeignKey( + 'colleges.FellowshipNomination', + on_delete=models.CASCADE, + related_name='events' + ) + + description = models.TextField() + + on = models.DateTimeField(default=timezone.now) + + class Meta: + ordering = [ + '-on' + ] + verbose_name_plural = 'Fellowhip Nomination Events' + + def __str__(self): + return f'Event for {self.nomination}' + + +class FellowshipNominationVotingRound(models.Model): + + nomination = models.ForeignKey( + 'colleges.FellowshipNomination', + on_delete=models.CASCADE, + related_name='voting_rounds' + ) + + eligible_to_vote = models.ManyToManyField( + 'colleges.Fellowship', + related_name='voting_rounds_eligible_to_vote_in', + blank=True + ) + + voting_opens = models.DateTimeField() + + voting_deadline = models.DateTimeField() + + objects = FellowshipNominationVotingRoundQuerySet.as_manager() + + class Meta: + ordering = [ + 'nomination__profile__last_name' + ] + verbose_name_plural = 'Fellowship Nomination Voting Rounds' + + def __str__(self): + return (f'Voting round ({self.voting_opens.strftime("%Y-%m-%d")} -' + f' {self.voting_deadline.strftime("%Y-%m-%d")}) for {self.nomination}') + + +class FellowshipNominationVote(models.Model): + + VOTE_AGREE = 'agree' + VOTE_ABSTAIN = 'abstain' + VOTE_DISAGREE = 'disagree' + VOTE_CHOICES = ( + (VOTE_AGREE, 'Agree'), + (VOTE_ABSTAIN, 'Abstain'), + (VOTE_DISAGREE, 'Disagree') + ) + + voting_round = models.ForeignKey( + 'colleges.FellowshipNominationVotingRound', + on_delete=models.CASCADE, + related_name='votes' + ) + + fellow = models.ForeignKey( + 'colleges.Fellowship', + on_delete=models.CASCADE, + related_name='fellowship_nomination_votes' + ) + + vote = models.CharField( + max_length=16, + choices=VOTE_CHOICES + ) + + on = models.DateTimeField(blank=True, null=True) + + comments = models.TextField( + help_text=('You can use plain text, Markdown or reStructuredText; see our ' + '<a href="/markup/help/" target="_blank">markup help</a> pages.'), + blank=True + ) + + class Meta: + ordering = ['voting_round',] + verbose_name_plural = 'Fellowship Nomination Votes' + + +class FellowshipNominationDecision(models.Model): + + nomination = models.OneToOneField( + 'colleges.FellowshipNomination', + on_delete=models.CASCADE, + related_name='decision' + ) + + OUTCOME_ELECTED = 'elected' + OUTCOME_NOT_ELECTED = 'notelected' + 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) + + comments = models.TextField( + help_text=('You can use plain text, Markdown or reStructuredText; see our ' + '<a href="/markup/help/" target="_blank">markup help</a> pages.'), + blank=True + ) + + class Meta: + ordering = ['nomination',] + verbose_name_plural = 'Fellowship Nomination Decisions' + + def __str__(self): + return f'Decision for {self.nomination}: {self.get_outcome_display()}' + + @property + def elected(self): + return self.outcome == self.OUTCOME_ELECTED + + +class FellowshipInvitation(models.Model): + + nomination = models.OneToOneField( + 'colleges.FellowshipNomination', + on_delete=models.CASCADE, + related_name='invitation' + ) + + invited_on = models.DateTimeField(blank=True, null=True) + + RESPONSE_NOT_YET_INVITED = 'notyetinvited' + RESPONSE_INVITED = 'invited' + RESPONSE_REINVITED = 'reinvited' + RESPONSE_MULTIPLY_REINVITED = 'multireinvited' + RESPONSE_UNRESPONSIVE = 'unresponsive' + RESPONSE_ACCEPTED = 'accepted' + RESPONSE_POSTPONED = 'postponed' + RESPONSE_DECLINED = 'declined' + RESPONSE_CHOICES = ( + (RESPONSE_NOT_YET_INVITED, 'Not yet invited'), + (RESPONSE_INVITED, 'Invited'), + (RESPONSE_REINVITED, 'Reinvited'), + (RESPONSE_MULTIPLY_REINVITED, 'Multiply reinvited'), + (RESPONSE_UNRESPONSIVE, 'Unresponsive'), + (RESPONSE_ACCEPTED, 'Accepted, for immediate start'), + (RESPONSE_POSTPONED, 'Accepted, but start date postponed'), + (RESPONSE_DECLINED, 'Declined') + ) + response = models.CharField( + max_length=16, + choices=RESPONSE_CHOICES, + blank=True + ) + + postpone_start_to = models.DateField(blank=True) + + comments = models.TextField( + help_text=('You can use plain text, Markdown or reStructuredText; see our ' + '<a href="/markup/help/" target="_blank">markup help</a> pages.'), + blank=True + ) + + class Meta: + ordering = ['nomination',] + verbose_name_plural = 'Fellowship Invitations' + + def __str__(self): + return f'Invitation for {self.nomination}' + + @property + def declined(self): + return self.response == self.RESPONSE_DECLINED diff --git a/scipost_django/colleges/permissions.py b/scipost_django/colleges/permissions.py index f27cf1a4fc52643f759feb5e43aff2dd6c30224f..394b35c342659cbc54e3cec5661b9ec8656f8a89 100644 --- a/scipost_django/colleges/permissions.py +++ b/scipost_django/colleges/permissions.py @@ -6,6 +6,7 @@ from django.contrib.auth.decorators import user_passes_test from django.core.exceptions import PermissionDenied from scipost.permissions import is_in_group +from colleges.models import Fellowship def fellowship_required(): @@ -20,7 +21,7 @@ def fellowship_required(): def fellowship_or_admin_required(): - """Require user to have any Fellowship or Administrational permissions.""" + """Require user to have any Fellowship or Administrative permissions.""" def test(u): if u.is_authenticated: if hasattr(u, 'contributor') and u.contributor.fellowships.exists(): @@ -32,3 +33,22 @@ def fellowship_or_admin_required(): return True raise PermissionDenied return user_passes_test(test) + + +def is_edadmin_or_advisory_or_active_regular_or_senior_fellow(user): + return (user.groups.filter(name='Editorial Administrators').exists() or + user.groups.filter(name='Advisory Board').exists() or + Fellowship.objects.active().regular_or_senior().filter( + contributor__user=user).exists()) + + +def is_edadmin_or_active_regular_or_senior_fellow(user): + return (user.groups.filter(name='Editorial Administrators').exists() or + Fellowship.objects.active().regular_or_senior().filter( + contributor__user=user).exists()) + + +def is_edadmin_or_senior_fellow(user): + return (user.groups.filter(name='Editorial Administrators').exists() or + Fellowship.objects.active().senior().filter( + contributor__user=user).exists()) diff --git a/scipost_django/colleges/templates/colleges/_hx_failed_eligibility_criteria.html b/scipost_django/colleges/templates/colleges/_hx_failed_eligibility_criteria.html new file mode 100644 index 0000000000000000000000000000000000000000..185718efd63c41d4d39ea91157c4e5e95edcb075 --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_failed_eligibility_criteria.html @@ -0,0 +1,8 @@ +<div class="border border-danger text-danger bg-danger bg-opacity-10 mb-2 p-2"> + <p><strong>{{ profile }}</strong> cannot be nominated at this time:</p> + <ul> + {% for criterion in failed_eligibility_criteria %} + <li class="text-danger">{{ criterion }}</li> + {% endfor %} + </ul> +</div> diff --git a/scipost_django/colleges/templates/colleges/_hx_fellowship_dynsel_list.html b/scipost_django/colleges/templates/colleges/_hx_fellowship_dynsel_list.html index 7077f095c04ce46d2160797ba104196447be9d69..85ef7102fd0fb76acce34d3c27835118b39cf5b0 100644 --- a/scipost_django/colleges/templates/colleges/_hx_fellowship_dynsel_list.html +++ b/scipost_django/colleges/templates/colleges/_hx_fellowship_dynsel_list.html @@ -8,7 +8,7 @@ <li class="m-1"> <a hx-get="{% fellowship_dynsel_action_url fellowship %}" - hx-target="#fellowships" + hx-target="#{{ action_target_element_id }}" > {{ fellowship }} </a> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_form.html b/scipost_django/colleges/templates/colleges/_hx_nomination_form.html new file mode 100644 index 0000000000000000000000000000000000000000..876a069a9f2c2ae4277baabce60cc2fc8aeb3f7a --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_form.html @@ -0,0 +1,12 @@ +{% 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" + > + {% csrf_token %} + {% crispy nomination_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 new file mode 100644 index 0000000000000000000000000000000000000000..473ba6d789171a8218599a18cdfd195dd767ab6f --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_li.html @@ -0,0 +1,97 @@ +<div class="border border-dark"> + <details> + <summary class="bg-light p-2"> + {{ nomination.profile }} + <span class="float-end"> + {{ nomination.college }} + <span class="ms-4">Outcome:</span> + {% if nomination.decision %} + {{ nomination.decision.get_outcome_display }} + {% else %} + pending + {% endif %} + </span> + </summary> + <div class="p-2"> + <p>Nominated by {{ nomination.nominated_by }} on {{ nomination.nominated_on|date:"Y-m-d" }}</p> + {% 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 class="row"> + <div class="col"> + <div class="card"> + <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 profile.webpage %} + <a href="{{ profile.webpage }}" target="_blank" rel="noopener">{{ profile.webpage }}</a> + {% else %} + unknown + {% endif %} + </td> + </tr> + </table> + </div> + </div> + </div> + <div class="col"> + <div class="card"> + <div class="card-header"> + Publications in SciPost Journals + </div> + <div class="card-body"> + <ul> + {% for pub in profile.publications.all|slice:":10" %} + <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> + </div> + </details> +</div> diff --git a/scipost_django/colleges/templates/colleges/_hx_nomination_voting_rounds.html b/scipost_django/colleges/templates/colleges/_hx_nomination_voting_rounds.html new file mode 100644 index 0000000000000000000000000000000000000000..a8657af4b1c2d6372c8a5966280c3757ec541f2f --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nomination_voting_rounds.html @@ -0,0 +1,9 @@ +<ul> + {% for round in voting_rounds %} + <li class="p-2 mb-2" id="voting_round_{{ round.id }}"> + {{ round }} + </li> + {% empty %} + <li>No voting round found</li> + {% endfor %} +</ul> diff --git a/scipost_django/colleges/templates/colleges/_hx_nominations.html b/scipost_django/colleges/templates/colleges/_hx_nominations.html new file mode 100644 index 0000000000000000000000000000000000000000..6ba5b622f4669c21b423326e4ffcc27911415ecd --- /dev/null +++ b/scipost_django/colleges/templates/colleges/_hx_nominations.html @@ -0,0 +1,22 @@ +{% 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/nominations.html b/scipost_django/colleges/templates/colleges/nominations.html new file mode 100644 index 0000000000000000000000000000000000000000..893d52f4fe9f806b5e4cbf5db7feb54317ac819e --- /dev/null +++ b/scipost_django/colleges/templates/colleges/nominations.html @@ -0,0 +1,158 @@ +{% extends 'colleges/base.html' %} + +{% load user_groups %} +{% load crispy_forms_tags %} + +{% block breadcrumb_items %} + {{ block.super }} + <a href="{% url 'colleges:colleges' %}" class="breadcrumb-item">Colleges</a> + <span class="breadcrumb-item">Nominations</span> +{% endblock %} + +{% block meta_description %}{{ block.super }} Nominations{% endblock meta_description %} +{% block pagetitle %}: Nominations{% endblock pagetitle %} + +{% block content %} + + {% is_ed_admin request.user as is_ed_admin %} + + <h1 class="highlight">Fellowship Nominations<span class="text-danger ms-4">{% include 'bi/cone-striped.html' %}<em>in construction</em> {% include 'bi/cone-striped.html' %}</span></h1> + + <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 + {% include 'bi/arrow-right.html' %} + <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> + + <details class="border border-success border-2 mt-4"> + <summary class="bg-success bg-opacity-10 p-2"> + <h2 class="ms-2">Vote</h2> + </summary> + <div class="p-2 mt-2"> + {% if is_ed_admin %} + <h3>Ongoing elections</h3> + <div id="voting_rounds_ongoing" + hx-get="{% url 'colleges:_hx_nomination_voting_rounds' %}?filters=ongoing" + hx-trigger="revealed" + > + </div> + <h3>Closed elections</h3> + <div id="voting_rounds_closed" + hx-get="{% url 'colleges:_hx_nomination_voting_rounds' %}?filters=closed" + hx-trigger="revealed" + > + </div> + {% else %} + <h3>Cast your vote (election ongoing)</h3> + <div id="voting_rounds_ongoing_vote_required" + hx-get="{% url 'colleges:_hx_nomination_voting_rounds' %}?filters=ongoing,vote_required" + hx-trigger="revealed" + > + </div> + <h3>Votes you have cast (election ongoing)</h3> + <div id="voting_rounds_ongoing_voted" + hx-get="{% url 'colleges:_hx_nomination_voting_rounds' %}?filters=ongoing,voted" + hx-trigger="revealed" + > + </div> + <h3>Votes you have cast (election closed)</h3> + <div id="voting_rounds_closed_voted" + hx-get="{% url 'colleges:_hx_nomination_voting_rounds' %}?filters=closed,voted" + hx-trigger="revealed" + > + </div> + {% endif %} + </div> + </details> + + <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="load, 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> + </details> + +{% endblock content %} diff --git a/scipost_django/colleges/urls.py b/scipost_django/colleges/urls.py index a5bee2f2fff627df6ead9f9d38e116b17e9b2ee6..62d830942991b69632b38f0af8f9fcc03993f194 100644 --- a/scipost_django/colleges/urls.py +++ b/scipost_django/colleges/urls.py @@ -158,4 +158,26 @@ 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_voting_rounds', + views._hx_nomination_voting_rounds, + name='_hx_nomination_voting_rounds' + ), ] diff --git a/scipost_django/colleges/utils.py b/scipost_django/colleges/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..f686180eeec13c8dad6b25233d492052e872e45d --- /dev/null +++ b/scipost_django/colleges/utils.py @@ -0,0 +1,49 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from .models import College, Fellowship, FellowshipNomination + + +def check_profile_eligibility_for_fellowship(profile): + """ + Returns a list of failed eligibility criteria (if any). + + Requirements: + + - Profile has a known acad_field + - There is an active College in the Profile's acad_field + - no current Fellowship exists + - no current FellowshipNomination exists + - no 'not elected' decision in last 2 years + - no invitation was turned down in the last 2 years + """ + blocks = [] + if not profile.acad_field: + blocks.append('No academic field is specified for this profile. ' + 'Contact EdAdmin or techsupport.') + elif not College.objects.filter(acad_field=profile.acad_field).exists(): + blocks.append('There is currently no College in {profile.acad_field}. ' + 'Contact EdAdmin or techsupport to get one started.') + if Fellowship.objects.active().regular_or_senior().filter( + contributor__profile=profile).exists(): + blocks.append('This Profile is associated to an active Fellowship.') + latest_nomination = FellowshipNomination.objects.filter( + profile=profile).first() + if latest_nomination: + try: + if (latest_nomination.decision.fixed_on + + datetime.timedelta(days=730)) > timezone.now(): + if latest_nomination.decision.elected: + try: + if latest_nomination.invitation.declined: + blocks.append('Invitation declined less that 2 years ago. ' + 'Wait to try again.') + else: + blocks.append('Already elected, invitation in process.') + except AttributeError: + blocks.append('Already elected, invitation pending.') + blocks.append('Election failed less that 2 years ago. Must wait.') + except AttributeError: # no decision yet + blocks.append('This Profile is associated to an ongoing Nomination process.') + return blocks if len(blocks) > 0 else None diff --git a/scipost_django/colleges/views.py b/scipost_django/colleges/views.py index c76603285e736c37c197ba0a9c024caa0805f177..e96e3d3ad63e0d2d6eb1eaa2af3d545d7faa4d9a 100644 --- a/scipost_django/colleges/views.py +++ b/scipost_django/colleges/views.py @@ -9,27 +9,39 @@ from dal import autocomplete from django.contrib import messages from django.contrib.auth.models import Group from django.contrib.auth.decorators import login_required, permission_required, user_passes_test +from django.core.paginator import Paginator from django.urls import reverse, reverse_lazy -from django.http import Http404 +from django.http import HttpResponse, Http404 from django.shortcuts import get_object_or_404, render, redirect from django.utils import timezone from django.views.generic.detail import DetailView from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.views.generic.list import ListView +from colleges.permissions import ( + is_edadmin_or_senior_fellow, is_edadmin_or_advisory_or_active_regular_or_senior_fellow +) +from colleges.utils import check_profile_eligibility_for_fellowship from submissions.models import Submission -from submissions.permissions import is_edadmin_or_senior_fellow from .constants import ( POTENTIAL_FELLOWSHIP_STATUSES, POTENTIAL_FELLOWSHIP_EVENT_STATUSUPDATED, POTENTIAL_FELLOWSHIP_INVITED, POTENTIAL_FELLOWSHIP_ACTIVE_IN_COLLEGE, potential_fellowship_statuses_dict, POTENTIAL_FELLOWSHIP_EVENT_VOTED_ON, POTENTIAL_FELLOWSHIP_EVENT_EMAILED) -from .forms import FellowshipDynSelForm, FellowshipForm, FellowshipRemoveSubmissionForm,\ - FellowshipAddSubmissionForm, SubmissionAddFellowshipForm,\ - FellowshipRemoveProceedingsForm, FellowshipAddProceedingsForm, \ - PotentialFellowshipForm, PotentialFellowshipStatusForm, PotentialFellowshipEventForm -from .models import College, Fellowship, PotentialFellowship, PotentialFellowshipEvent +from .forms import ( + FellowshipDynSelForm, FellowshipForm, + FellowshipRemoveSubmissionForm, FellowshipAddSubmissionForm, + SubmissionAddFellowshipForm, + FellowshipRemoveProceedingsForm, FellowshipAddProceedingsForm, + PotentialFellowshipForm, PotentialFellowshipStatusForm, PotentialFellowshipEventForm, + FellowshipNominationForm, FellowshipNominationSearchForm, +) +from .models import ( + College, Fellowship, + PotentialFellowship, PotentialFellowshipEvent, + FellowshipNominationVotingRound +) from scipost.forms import EmailUsersForm, SearchTextForm from scipost.mixins import PermissionsMixin, PaginationMixin, RequestViewMixin @@ -38,6 +50,8 @@ from scipost.models import Contributor from common.utils import Q_with_alternative_spellings from mails.views import MailView from ontology.models import Branch +from profiles.models import Profile +from profiles.forms import ProfileDynSelForm class CollegeListView(ListView): @@ -181,6 +195,7 @@ def _hx_fellowship_dynsel_list(request): 'fellowships': fellowships, 'action_url_name': form.cleaned_data['action_url_name'], 'action_url_base_kwargs': form.cleaned_data['action_url_base_kwargs'], + 'action_target_element_id': form.cleaned_data['action_target_element_id'], } return render(request, 'colleges/_hx_fellowship_dynsel_list.html', context) @@ -513,3 +528,101 @@ class PotentialFellowshipEventCreateView(PermissionsMixin, CreateView): form.instance.noted_by = self.request.user.contributor messages.success(self.request, 'Event added successfully') return super().form_valid(form) + + + +############### +# Nominations # +############### + + +@user_passes_test(is_edadmin_or_advisory_or_active_regular_or_senior_fellow) +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' + } + ) + context = { + 'profile_dynsel_form': profile_dynsel_form, + 'search_nominations_form': FellowshipNominationSearchForm(), + } + return render(request, 'colleges/nominations.html', context) + + +@user_passes_test(is_edadmin_or_advisory_or_active_regular_or_senior_fellow) +def _hx_nomination_form(request, profile_id): + profile = get_object_or_404(Profile, pk=profile_id) + failed_eligibility_criteria = check_profile_eligibility_for_fellowship(profile) + if failed_eligibility_criteria: + return render( + request, + 'colleges/_hx_failed_eligibility_criteria.html', + { + 'profile': profile, + 'failed_eligibility_criteria': failed_eligibility_criteria + } + ) + nomination_form = FellowshipNominationForm( + request.POST or None, + profile=profile + ) + if nomination_form.is_valid(): + nomination = nomination_form.save() + return HttpResponse( + f'<div class="bg-success text-white p-2 ">{nomination.profile} ' + f'successfully nominated to {nomination.college}.</div>') + nomination_form.fields['nominated_by'].initial = request.user.contributor + context = { + 'profile': profile, + 'nomination_form': nomination_form, + } + return render(request, 'colleges/_hx_nomination_form.html', context) + + +@user_passes_test(is_edadmin_or_advisory_or_active_regular_or_senior_fellow) +def _hx_nominations(request): + form = FellowshipNominationSearchForm(request.POST or None) + if form.is_valid(): + nominations = form.search_results() + else: + nominations = FellowshipNomination.objects.all() + 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) + + +@user_passes_test(is_edadmin_or_advisory_or_active_regular_or_senior_fellow) +def _hx_nomination_voting_rounds(request): + fellowship = request.user.contributor.session_fellowship(request) + filters = request.GET.get('filters', None) + if filters: + filters = filters.split(',') + if not filters: # if no filters present, return empty response + voting_rounds = FellowshipNominationVotingRound.objects.none() + else: + voting_rounds = FellowshipNominationVotingRound.objects.all() + for filter in filters: + if filter == 'ongoing': + voting_rounds = voting_rounds.ongoing() + if filter == 'closed': + voting_rounds = voting_rounds.closed() + if filter == 'vote_required': + # 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 filter == 'voted': + voting_rounds = voting_rounds.filter(votes__fellow=fellowship) + context = { + 'voting_rounds': voting_rounds, + } + return render(request, 'colleges/_hx_nomination_voting_rounds.html', context) diff --git a/scipost_django/journals/forms.py b/scipost_django/journals/forms.py index 8a327f7add5bce6b5bb25be3a397a55bc447c4b5..2c3678d50ea9b078eba1d0eabb675573b845d401 100644 --- a/scipost_django/journals/forms.py +++ b/scipost_django/journals/forms.py @@ -874,7 +874,8 @@ OrgPubFractionsFormSet = modelformset_factory(OrgPubFraction, class PublicationDynSelForm(forms.Form): q = forms.CharField(max_length=32, label='Search (by title, author names)') action_url_name = forms.CharField() - action_url_base_kwargs = forms.JSONField() + action_url_base_kwargs = forms.JSONField(required=False) + action_target_element_id = forms.CharField() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -883,6 +884,7 @@ class PublicationDynSelForm(forms.Form): FloatingField('q', autocomplete='off'), Field('action_url_name', type='hidden'), Field('action_url_base_kwargs', type='hidden'), + Field('action_target_element_id', type='hidden'), ) def search_results(self): diff --git a/scipost_django/journals/templates/journals/_hx_publication_dynsel_list.html b/scipost_django/journals/templates/journals/_hx_publication_dynsel_list.html index 285383d7cb74fe76d113fe6a3b1ff87f307009a1..8c26b988b7ee14e38603ed532604d004bf343d5a 100644 --- a/scipost_django/journals/templates/journals/_hx_publication_dynsel_list.html +++ b/scipost_django/journals/templates/journals/_hx_publication_dynsel_list.html @@ -8,7 +8,7 @@ <li class="m-1"> <a hx-get="{% publication_dynsel_action_url publication %}" - hx-target="#publications" + hx-target="#{{ action_target_element_id }}" > {{ publication }} </a> diff --git a/scipost_django/journals/views.py b/scipost_django/journals/views.py index 2731f5c7ce87ccb5fbbf93c2957b60f64cbf83b1..0277b9597673f62ab29a6cc2b1f362bb37ab2c37 100644 --- a/scipost_django/journals/views.py +++ b/scipost_django/journals/views.py @@ -102,6 +102,7 @@ def _hx_publication_dynsel_list(request): 'publications': publications, 'action_url_name': form.cleaned_data['action_url_name'], 'action_url_base_kwargs': form.cleaned_data['action_url_base_kwargs'], + 'action_target_element_id': form.cleaned_data['action_target_element_id'], } return render(request, 'journals/_hx_publication_dynsel_list.html', context) diff --git a/scipost_django/proceedings/views.py b/scipost_django/proceedings/views.py index dffd8a24aff2ef49fa48043aff2c0ca6460a7291..ea7c79d0b306f0d9fd0c7723c41e873dbe1530ad 100644 --- a/scipost_django/proceedings/views.py +++ b/scipost_django/proceedings/views.py @@ -64,7 +64,8 @@ def _hx_proceedings_fellowships(request, id): form = FellowshipDynSelForm( initial={ 'action_url_name': 'proceedings:_hx_proceedings_fellowship_action', - 'action_url_base_kwargs': {'id': proceedings.id, 'action': 'add'} + 'action_url_base_kwargs': {'id': proceedings.id, 'action': 'add'}, + 'action_target_element_id': 'fellowships', } ) context = { diff --git a/scipost_django/profiles/forms.py b/scipost_django/profiles/forms.py index 9b26bebf94c45119013b86441475dcc9f1e8353c..c5e1b948870552d539a61ecfae1425d5a733f3ae 100644 --- a/scipost_django/profiles/forms.py +++ b/scipost_django/profiles/forms.py @@ -201,7 +201,8 @@ class ProfileSelectForm(forms.Form): class ProfileDynSelForm(forms.Form): q = forms.CharField(max_length=32, label='Search (by name)') action_url_name = forms.CharField() - action_url_base_kwargs = forms.JSONField() + action_url_base_kwargs = forms.JSONField(required=False) + action_target_element_id = forms.CharField() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -210,6 +211,7 @@ class ProfileDynSelForm(forms.Form): FloatingField('q', autocomplete='off'), Field('action_url_name', type='hidden'), Field('action_url_base_kwargs', type='hidden'), + Field('action_target_element_id', type='hidden') ) def search_results(self): diff --git a/scipost_django/profiles/models.py b/scipost_django/profiles/models.py index ca3b4193e7669763017e5a4d89e4dd7fd29ff113..b7ee85663d2f71f3d3d5f574ae1cd8e3b3ad67e6 100644 --- a/scipost_django/profiles/models.py +++ b/scipost_django/profiles/models.py @@ -85,6 +85,11 @@ class Profile(models.Model): class Meta: ordering = ['last_name'] + def __str__(self): + return '%s, %s %s' % (self.last_name, + self.get_title_display() if self.title != None else '', + self.first_name) + @property def roles(self): try: @@ -92,7 +97,7 @@ class Profile(models.Model): except (KeyError, Contributor.DoesNotExist): return None - def __str__(self): + def str_with_roles(self): r = self.roles return '%s, %s %s%s' % (self.last_name, self.get_title_display() if self.title != None else '', diff --git a/scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html b/scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html index d5f6043919d2c1400149f4985d5451470d4b0e54..1a850fd67a444afef387ccd5a0d68a9ba27cfb88 100644 --- a/scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html +++ b/scipost_django/profiles/templates/profiles/_hx_profile_dynsel_list.html @@ -1,6 +1,6 @@ {% load profiles_extras %} -<ul class="list list-unstyled"> +<ul class="list list-unstyled dynsel-list"> {% for profile in profiles|slice:":11" %} {% if forloop.counter == 11 %} <li> ...</li> @@ -8,7 +8,8 @@ <li class="m-1"> <a hx-get="{% profile_dynsel_action_url profile %}" - hx-target="#profiles" + hx-target="#{{ action_target_element_id }}" + hx-indicator="#{{ action_target_element_id }}-indicator" > {{ profile }} </a> diff --git a/scipost_django/profiles/views.py b/scipost_django/profiles/views.py index a5f695e888f225dcdd7e4325d686705a78e02df1..94e383477ac2c8cc26a7f145e5c5a315f8d48904 100644 --- a/scipost_django/profiles/views.py +++ b/scipost_django/profiles/views.py @@ -300,7 +300,9 @@ def _hx_profile_dynsel_list(request): context = { 'profiles': profiles, 'action_url_name': form.cleaned_data['action_url_name'], - 'action_url_base_kwargs': form.cleaned_data['action_url_base_kwargs'], + 'action_url_base_kwargs': (form.cleaned_data['action_url_base_kwargs'] + if 'action_url_base_kwargs' in form.cleaned_data else {}), + 'action_target_element_id': form.cleaned_data['action_target_element_id'], } return render(request, 'profiles/_hx_profile_dynsel_list.html', context) diff --git a/scipost_django/scipost/models.py b/scipost_django/scipost/models.py index bd4678e12be716f7917b36d33a92a0f114e602fe..6b9311eb36a13bfeefc2ff1937fdd00fdc52ba31 100644 --- a/scipost_django/scipost/models.py +++ b/scipost_django/scipost/models.py @@ -159,6 +159,22 @@ class Contributor(models.Model): def is_active_senior_fellow(self): return self.fellowships.active().senior().exists() + def session_fellowship(self, request): + """Return session's fellowship, if any; if Fellow, set session_fellowship_id if not set.""" + fellowships = self.fellowships.active() + if fellowships.exists(): + if request.session['session_fellowship_id']: + from colleges.models import Fellowship + try: + return self.fellowships.active().get(pk=request.session['session_fellowship_id']) + except Fellowship.DoesNotExist: + return None + # set the session's fellowship_id to default + fellowship = fellowships.first() + request.session['session_fellowship_id'] = fellowship.id + return fellowship + return None + @property def is_vetting_editor(self): """Check if Contributor is a Vetting Editor.""" diff --git a/scipost_django/scipost/static/scipost/assets/css/_dynsel.scss b/scipost_django/scipost/static/scipost/assets/css/_dynsel.scss new file mode 100644 index 0000000000000000000000000000000000000000..c9f0b364c54df9e953db482aa1e39681077b06e7 --- /dev/null +++ b/scipost_django/scipost/static/scipost/assets/css/_dynsel.scss @@ -0,0 +1,14 @@ + +.dynsel-list { + border: 2px gray; + background-color: #e0e0e0; + padding: 1rem; +} + +.dynsel-list > li > a { + padding: 0.2rem; +} + +.dynsel-list > li > a:hover { + background-color: #b0b0b0; +} diff --git a/scipost_django/scipost/static/scipost/assets/css/style.scss b/scipost_django/scipost/static/scipost/assets/css/style.scss index 0f6dcd2e6d0e3511447365b53a9f3de757750dd7..604b806a485b3fa758c8a34ea1a7669cff8e5669 100644 --- a/scipost_django/scipost/static/scipost/assets/css/style.scss +++ b/scipost_django/scipost/static/scipost/assets/css/style.scss @@ -48,6 +48,7 @@ @import "general"; @import "colleges"; @import "comments"; +@import "dynsel"; @import "icons"; @import "journals"; @import "personal_page"; diff --git a/scipost_django/scipost/views.py b/scipost_django/scipost/views.py index d62b6df9631f0a08bbb0f56c3b2c2cbcbcb189f2..348aefd3d091ea138e832d2602bc8963b9835556 100644 --- a/scipost_django/scipost/views.py +++ b/scipost_django/scipost/views.py @@ -730,10 +730,13 @@ class SciPostLoginView(LoginView): return self.request.GET def get_success_url(self): - """Add the `acad_field_view` item to session.""" + """Add items to session variables.""" self.request.session['session_acad_field_slug'] = \ self.request.user.contributor.profile.acad_field.slug if \ self.request.user.contributor.profile.acad_field else '' + if self.request.user.contributor.fellowships.active(): + self.request.session['session_fellowship_id'] = \ + self.request.user.contributor.fellowships.active().first().id return super().get_success_url() def get_redirect_url(self): diff --git a/scipost_django/series/views.py b/scipost_django/series/views.py index 544b1825b2b4f34eabdaf592acf36caaac2eff36..c91bf6b93745dec94824e5e584a55f0bad637266 100644 --- a/scipost_django/series/views.py +++ b/scipost_django/series/views.py @@ -49,7 +49,8 @@ def _hx_collection_expected_authors(request, slug): form = ProfileDynSelForm( initial={ 'action_url_name': 'series:_hx_collection_expected_author_action', - 'action_url_base_kwargs': {'slug': collection.slug, 'action': 'add'} + 'action_url_base_kwargs': {'slug': collection.slug, 'action': 'add'}, + 'action_target_element_id': 'profiles' } ) context = { @@ -84,7 +85,8 @@ def _hx_collection_publications(request, slug): form = PublicationDynSelForm( initial={ 'action_url_name': 'series:_hx_collection_publication_action', - 'action_url_base_kwargs': {'slug': collection.slug, 'action': 'add'} + 'action_url_base_kwargs': {'slug': collection.slug, 'action': 'add'}, + 'action_target_element_id': 'publications' } ) context = { diff --git a/scipost_django/submissions/permissions.py b/scipost_django/submissions/permissions.py deleted file mode 100644 index b9cd3d3f0a00ab133d82a10d05f417c341186651..0000000000000000000000000000000000000000 --- a/scipost_django/submissions/permissions.py +++ /dev/null @@ -1,14 +0,0 @@ -__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" -__license__ = "AGPL v3" - - -from colleges.models import Fellowship - -def is_edadmin_or_senior_fellow(user): - if not user.has_perm('scipost.can_run_pre_screening'): - try: - fellow = Fellowship.objects.get(contributor__user=user) - return fellow.senior - except: - return False - return True diff --git a/scipost_django/submissions/views.py b/scipost_django/submissions/views.py index cb61a2649ada965cdcb8bf7bad96e24eff80b1c9..7c414b900e7728134f73dd562fddaa99ac2b351c 100644 --- a/scipost_django/submissions/views.py +++ b/scipost_django/submissions/views.py @@ -60,11 +60,13 @@ from .forms import ( SubmissionTargetJournalForm, SubmissionTargetProceedingsForm, SubmissionPreprintFileForm, SubmissionPrescreeningForm, PreassignEditorsFormSet, SubmissionReassignmentForm) -from .permissions import is_edadmin_or_senior_fellow from .utils import SubmissionUtils from colleges.models import PotentialFellowship, Fellowship -from colleges.permissions import fellowship_required, fellowship_or_admin_required +from colleges.permissions import ( + fellowship_required, fellowship_or_admin_required, + is_edadmin_or_senior_fellow +) from comments.forms import CommentForm from common.helpers import get_new_secrets_key from common.utils import workdays_between diff --git a/scipost_django/templates/bi/cone-striped.html b/scipost_django/templates/bi/cone-striped.html new file mode 100644 index 0000000000000000000000000000000000000000..e6e66d232742fbe9c4182fb525b1fcb41e2c540d --- /dev/null +++ b/scipost_django/templates/bi/cone-striped.html @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-cone-striped" viewBox="0 0 16 16"> + <path d="m9.97 4.88.953 3.811C10.159 8.878 9.14 9 8 9c-1.14 0-2.158-.122-2.923-.309L6.03 4.88C6.635 4.957 7.3 5 8 5s1.365-.043 1.97-.12zm-.245-.978L8.97.88C8.718-.13 7.282-.13 7.03.88L6.275 3.9C6.8 3.965 7.382 4 8 4c.618 0 1.2-.036 1.725-.098zm4.396 8.613a.5.5 0 0 1 .037.96l-6 2a.5.5 0 0 1-.316 0l-6-2a.5.5 0 0 1 .037-.96l2.391-.598.565-2.257c.862.212 1.964.339 3.165.339s2.303-.127 3.165-.339l.565 2.257 2.391.598z"/> +</svg>