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/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/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..7d95293ec745e6f93c4b23a0f7bea5ca9dff79be --- /dev/null +++ b/scipost_django/colleges/models/nomination.py @@ -0,0 +1,208 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models +from django.utils import timezone + + +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) + + 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'Nomination of {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 = 'Fellowhips Nomination Events' + + def __str__(self): + return f'Event for {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() + + class Meta: + ordering = [ + 'nomination__profile__last_name' + ] + verbose_name_plural = 'Fellowship Nomination Voting Rounds' + + def __str__(self): + return (f'Voting round ({voting_opens.strftime("%Y-%m-%d")} -' + f' {voting_deadline.strftime("%Y-%m-%d")}) for {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(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) + + class Meta: + ordering = ['nomination',] + verbose_name_plural = 'Fellowship Nomination Decisions' + + def __str__(self): + return f'Decision for {nomination}: {self.get_outcome_display()}' + + +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) + + class Meta: + ordering = ['nomination',] + verbose_name_plural = 'Fellowship Invitations' + + def __str__(self): + return f'Invitation for {nomination}'