diff --git a/backend/root/management/commands/seed_cypress.py b/backend/root/management/commands/seed_cypress.py index 8a36306e6..2e09c8071 100755 --- a/backend/root/management/commands/seed_cypress.py +++ b/backend/root/management/commands/seed_cypress.py @@ -58,6 +58,7 @@ def handle(self, *args, **options): Venue.objects.create( name='Cypress Area', + slug='cypress-area', description='Some description', floor=1, last_renovated=timezone.now(), diff --git a/backend/root/management/commands/seed_scripts/information_pages.py b/backend/root/management/commands/seed_scripts/information_pages.py index 7b440710d..1dcf0c5a5 100755 --- a/backend/root/management/commands/seed_scripts/information_pages.py +++ b/backend/root/management/commands/seed_scripts/information_pages.py @@ -1,3 +1,5 @@ +from django.utils.text import slugify + from root.utils.samfundet_random import words from samfundet.models.general import InformationPage @@ -14,7 +16,7 @@ def seed(): for i in range(COUNT): # Event title and time title_nb, title_en = words(2, include_english=True) - slug_field = title_nb.lower().replace(' ', '-') + slug_field = slugify(title_nb) # Make sure slug field is unique if slug_field in used_slug_fields: diff --git a/backend/root/management/commands/seed_scripts/recruitment.py b/backend/root/management/commands/seed_scripts/recruitment.py index 1883b7623..73b79bf9a 100644 --- a/backend/root/management/commands/seed_scripts/recruitment.py +++ b/backend/root/management/commands/seed_scripts/recruitment.py @@ -41,23 +41,27 @@ def seed(): total_recruitments = len(recruitments) * len(organizations) created_recruitments = 0 + recruitment_objects = [] for org_name in organizations: org = Organization.objects.get(name=org_name) for recruitment_data in recruitments: - Recruitment.objects.get_or_create( + recruitment_instance = Recruitment( name_nb=recruitment_data['name_nb'], name_en=recruitment_data['name_en'], organization=org, - defaults={ - 'visible_from': recruitment_data['visible_from'], - 'shown_application_deadline': recruitment_data['shown_application_deadline'], - 'actual_application_deadline': recruitment_data['actual_application_deadline'], - 'reprioritization_deadline_for_applicant': recruitment_data['reprioritization_deadline_for_applicant'], - 'reprioritization_deadline_for_groups': recruitment_data['reprioritization_deadline_for_groups'], - } + visible_from=recruitment_data['visible_from'], + shown_application_deadline=recruitment_data['shown_application_deadline'], + actual_application_deadline=recruitment_data['actual_application_deadline'], + reprioritization_deadline_for_applicant=recruitment_data['reprioritization_deadline_for_applicant'], + reprioritization_deadline_for_groups=recruitment_data['reprioritization_deadline_for_groups'] ) + recruitment_objects.append(recruitment_instance) + created_recruitments += 1 yield (created_recruitments / total_recruitments) * 100, 'recruitment' + # Using bulk_create to add all recruitment instances to the database + Recruitment.objects.bulk_create(recruitment_objects) + yield 100, f'Created {created_recruitments} recruitments' diff --git a/backend/root/management/commands/seed_scripts/recruitment_admissions.py b/backend/root/management/commands/seed_scripts/recruitment_admissions.py index 60aab0005..aeefc1b91 100644 --- a/backend/root/management/commands/seed_scripts/recruitment_admissions.py +++ b/backend/root/management/commands/seed_scripts/recruitment_admissions.py @@ -7,10 +7,8 @@ ADMISSION_DATA = { 'admission_text': 'This is the admission text', 'applicant_priority': 0, - 'interview_time': None, - 'interview_location': None, 'recruiter_priority': 0, - 'recruiter_status': 0 + 'recruiter_status': 0, } diff --git a/backend/root/utils/mixins.py b/backend/root/utils/mixins.py index 91a17c1cc..9302349dd 100644 --- a/backend/root/utils/mixins.py +++ b/backend/root/utils/mixins.py @@ -162,3 +162,14 @@ def from_db(cls, db, field_names, values): # type: ignore # noqa: ANN001,ANN206 setattr(instance, instance._FTM_LOADED_FIELDS_NAME, loaded_fields) # noqa: FKA01 return instance + + +class FullCleanSaveMixin(Model): + """Mixin to call full_clean() before save().""" + + class Meta: + abstract = True + + def save(self, *args: Any, **kwargs: Any) -> None: + self.full_clean() + super().save(*args, **kwargs) diff --git a/backend/samfundet/admin.py b/backend/samfundet/admin.py index 15d37f79f..f780483bf 100644 --- a/backend/samfundet/admin.py +++ b/backend/samfundet/admin.py @@ -14,7 +14,13 @@ CustomGuardedModelAdmin, ) from .models.event import (Event, EventGroup, EventRegistration) -from .models.recruitment import (Recruitment, RecruitmentPosition, RecruitmentAdmission, InterviewRoom) +from .models.recruitment import ( + Recruitment, + RecruitmentPosition, + RecruitmentAdmission, + InterviewRoom, + Interview, +) from .models.general import ( Tag, User, @@ -536,24 +542,18 @@ class RecruitmentAdmissionAdmin(CustomGuardedModelAdmin): 'id', 'recruitment_position', 'recruitment', - 'interview_time', - 'interview_location', 'user', ] list_display = [ 'id', 'recruitment_position', 'recruitment', - 'interview_time', - 'interview_location', 'user', ] search_fields = [ 'id', 'recruitment_position', 'recruitment', - 'interview_time', - 'interview_location', 'user', ] list_select_related = True @@ -576,4 +576,12 @@ class InterviewRoomAdmin(CustomGuardedModelAdmin): list_select_related = ['recruitment', 'gang'] +@admin.register(Interview) +class InterviewAdmin(CustomGuardedModelAdmin): + list_filter = ['id', 'notes'] + list_display = ['id', 'notes'] + search_fields = ['id', 'notes'] + list_display_links = ['id', 'notes'] + + ### End: Our models ### diff --git a/backend/samfundet/conftest.py b/backend/samfundet/conftest.py index 12985e7ae..56c0affbc 100644 --- a/backend/samfundet/conftest.py +++ b/backend/samfundet/conftest.py @@ -13,7 +13,7 @@ from samfundet.models.billig import BilligEvent from samfundet.models.event import Event, EventAgeRestriction, EventTicketType from samfundet.models.recruitment import Recruitment, RecruitmentPosition, RecruitmentAdmission -from samfundet.models.general import User, Image, InformationPage, Organization, Gang, BlogPost +from samfundet.models.general import User, Image, InformationPage, Organization, Gang, BlogPost, TextItem import root.management.commands.seed_scripts.billig as billig_seed """ @@ -193,6 +193,17 @@ def fixture_gang(fixture_organization: Organization) -> Iterator[Gang]: organization.delete() +@pytest.fixture +def fixture_text_item() -> Iterator[TextItem]: + text_item = TextItem.objects.create( + key='foo', + text_nb='foo', + text_en='foo', + ) + yield text_item + text_item.delete() + + @pytest.fixture def fixture_recruitment(fixture_organization: Organization) -> Iterator[Recruitment]: now = timezone.now() diff --git a/backend/samfundet/migrations/0039_remove_recruitmentadmission_interview_location_and_more.py b/backend/samfundet/migrations/0039_remove_recruitmentadmission_interview_location_and_more.py new file mode 100644 index 000000000..44463297b --- /dev/null +++ b/backend/samfundet/migrations/0039_remove_recruitmentadmission_interview_location_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.3 on 2023-10-07 22:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0038_alter_recruitmentadmission_interview_location_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='recruitmentadmission', + name='interview_location', + ), + migrations.RemoveField( + model_name='recruitmentadmission', + name='interview_time', + ), + migrations.RemoveField( + model_name='recruitmentadmission', + name='room', + ), + migrations.AddField( + model_name='recruitmentposition', + name='shared_interview_positions', + field=models.ManyToManyField(blank=True, help_text='Positions with shared interview', to='samfundet.recruitmentposition'), + ), + migrations.AlterField( + model_name='recruitmentposition', + name='interviewers', + field=models.ManyToManyField(blank=True, help_text='Interviewers for the position', related_name='interviewers', to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='Interview', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('interview_time', models.DateTimeField(blank=True, help_text='The time of the interview', null=True)), + ('interview_location', models.CharField(blank=True, help_text='The location of the interview', max_length=255, null=True)), + ('notes', models.TextField(blank=True, help_text='Notes for the interview', null=True)), + ('room', models.ForeignKey(blank=True, help_text='Room where the interview is held', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='samfundet.interviewroom')), + ], + ), + migrations.AddField( + model_name='recruitmentadmission', + name='interview', + field=models.ForeignKey(blank=True, help_text='The interview for the admission', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='admissions', to='samfundet.interview'), + ), + ] diff --git a/backend/samfundet/models/general.py b/backend/samfundet/models/general.py index 433b199cc..4bb8d6a7d 100644 --- a/backend/samfundet/models/general.py +++ b/backend/samfundet/models/general.py @@ -17,6 +17,7 @@ from guardian.shortcuts import assign_perm from django.utils.translation import gettext as _ +from root.utils.mixins import FullCleanSaveMixin from root.utils import permissions from .utils.fields import LowerCaseField @@ -32,7 +33,7 @@ class Meta(AbstractNotification.Meta): abstract = False -class Tag(models.Model): +class Tag(FullCleanSaveMixin): # TODO make name case-insensitive # Kan tvinge alt til lowercase, er enklere. name = models.CharField(max_length=140) @@ -69,7 +70,7 @@ def save(self, *args: Any, **kwargs: Any) -> None: super().save(*args, **kwargs) -class Image(models.Model): +class Image(FullCleanSaveMixin): title = models.CharField(max_length=140) tags = models.ManyToManyField(Tag, blank=True, related_name='images') image = models.ImageField(upload_to='images/', blank=False, null=False) @@ -125,7 +126,7 @@ def impersonated_by(self) -> User: return self._impersonated_by -class UserPreference(models.Model): +class UserPreference(FullCleanSaveMixin): """Group all preferences and config per user.""" class Theme(models.TextChoices): @@ -150,7 +151,7 @@ def __str__(self) -> str: return f'UserPreference ({self.user})' -class Profile(models.Model): +class Profile(FullCleanSaveMixin): user = models.OneToOneField(User, on_delete=models.CASCADE, blank=True, null=True) nickname = models.CharField(max_length=30, blank=True, null=True) @@ -173,7 +174,7 @@ def save(self, *args: Any, **kwargs: Any) -> None: assign_perm(perm=permissions.SAMFUNDET_CHANGE_PROFILE, user_or_group=self.user, obj=self) -class Venue(models.Model): +class Venue(FullCleanSaveMixin): name = models.CharField(max_length=140, blank=True, null=True, unique=True) slug = models.SlugField(unique=True, null=True) description = models.TextField(blank=True, null=True) @@ -211,7 +212,7 @@ def __str__(self) -> str: return f'{self.name}' -class ClosedPeriod(models.Model): +class ClosedPeriod(FullCleanSaveMixin): message_nb = models.TextField(blank=True, null=True, verbose_name='Melding (norsk)') message_en = models.TextField(blank=True, null=True, verbose_name='Melding (engelsk)') @@ -233,7 +234,7 @@ def __str__(self) -> str: # GANGS ### -class Organization(models.Model): +class Organization(FullCleanSaveMixin): """ Object for mapping out the orgs with different gangs, eg. Samfundet, UKA, ISFiT """ @@ -247,7 +248,7 @@ def __str__(self) -> str: return self.name -class GangType(models.Model): +class GangType(FullCleanSaveMixin): """ Type of gang. eg. 'arrangerende', 'kunstnerisk' etc. """ @@ -265,7 +266,7 @@ def __str__(self) -> str: return f'{self.title_nb}' -class Gang(models.Model): +class Gang(FullCleanSaveMixin): name_nb = models.CharField(max_length=64, blank=True, null=True, verbose_name='Navn Norsk') name_en = models.CharField(max_length=64, blank=True, null=True, verbose_name='Navn Engelsk') abbreviation = models.CharField(max_length=8, blank=True, null=True, verbose_name='Forkortelse') @@ -304,7 +305,7 @@ def __str__(self) -> str: return f'{self.gang_type} - {self.name_nb}' -class InformationPage(models.Model): +class InformationPage(FullCleanSaveMixin): slug_field = models.SlugField( max_length=64, blank=True, @@ -333,7 +334,7 @@ def __str__(self) -> str: return f'{self.slug_field}' -class BlogPost(models.Model): +class BlogPost(FullCleanSaveMixin): title_nb = models.CharField(max_length=64, blank=True, null=True, verbose_name='Tittel (norsk)') text_nb = models.TextField(blank=True, null=True, verbose_name='Tekst (norsk)') @@ -356,7 +357,7 @@ def __str__(self) -> str: return f'{self.title_nb} {self.published_at}' -class Table(models.Model): +class Table(FullCleanSaveMixin): name_nb = models.CharField(max_length=64, unique=True, blank=True, null=True, verbose_name='Navn (norsk)') description_nb = models.CharField(max_length=64, blank=True, null=True, verbose_name='Beskrivelse (norsk)') @@ -381,7 +382,7 @@ def __str__(self) -> str: return f'{self.name_nb}' -class Reservation(models.Model): +class Reservation(FullCleanSaveMixin): user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True) name = models.CharField(max_length=64, blank=True, verbose_name='Navn') email = models.EmailField(max_length=64, blank=True, verbose_name='Epost') @@ -408,7 +409,7 @@ def __str__(self) -> str: return f'{self.name}' -class FoodPreference(models.Model): +class FoodPreference(FullCleanSaveMixin): name_nb = models.CharField(max_length=64, unique=True, blank=True, null=True, verbose_name='Navn (norsk)') name_en = models.CharField(max_length=64, blank=True, null=True, verbose_name='Navn (engelsk)') @@ -423,7 +424,7 @@ def __str__(self) -> str: return f'{self.name_nb}' -class FoodCategory(models.Model): +class FoodCategory(FullCleanSaveMixin): name_nb = models.CharField(max_length=64, unique=True, blank=True, null=True, verbose_name='Navn (norsk)') name_en = models.CharField(max_length=64, blank=True, null=True, verbose_name='Navn (engelsk)') order = models.PositiveSmallIntegerField(blank=True, null=True, unique=True) @@ -439,7 +440,7 @@ def __str__(self) -> str: return f'{self.name_nb}' -class MenuItem(models.Model): +class MenuItem(FullCleanSaveMixin): name_nb = models.CharField(max_length=64, unique=True, blank=True, null=True, verbose_name='Navn (norsk)') description_nb = models.TextField(blank=True, null=True, verbose_name='Beskrivelse (norsk)') @@ -465,7 +466,7 @@ def __str__(self) -> str: return f'{self.name_nb}' -class Menu(models.Model): +class Menu(FullCleanSaveMixin): name_nb = models.CharField(max_length=64, unique=True, blank=True, null=True, verbose_name='Navn (norsk)') description_nb = models.TextField(blank=True, null=True, verbose_name='Beskrivelse (norsk)') @@ -485,7 +486,7 @@ def __str__(self) -> str: return f'{self.name_nb}' -class Saksdokument(models.Model): +class Saksdokument(FullCleanSaveMixin): title_nb = models.CharField(max_length=80, blank=True, null=True, verbose_name='Tittel (Norsk)') title_en = models.CharField(max_length=80, blank=True, null=True, verbose_name='Tittel (Engelsk)') publication_date = models.DateTimeField(blank=True, null=True) @@ -510,7 +511,7 @@ def __str__(self) -> str: return f'{self.title_nb}' -class Booking(models.Model): +class Booking(FullCleanSaveMixin): name = models.CharField(max_length=64, blank=True, null=True) text = models.TextField(blank=True, null=True) from_dt = models.DateTimeField(blank=True, null=True) @@ -562,7 +563,7 @@ def save(self, *args: Any, **kwargs: Any) -> None: super().save(*args, **kwargs) -class Infobox(models.Model): +class Infobox(FullCleanSaveMixin): title_nb = models.CharField(max_length=60, blank=False, null=False, verbose_name='Infoboks titel (norsk)') text_nb = models.CharField(max_length=255, blank=False, null=False, verbose_name='Infoboks tekst (norsk)') @@ -585,7 +586,7 @@ def __str__(self) -> str: return f'{self.title_nb}' -class TextItem(models.Model): +class TextItem(FullCleanSaveMixin): key = models.CharField(max_length=40, blank=False, null=False, unique=True, primary_key=True) text_nb = models.TextField() text_en = models.TextField() @@ -598,7 +599,7 @@ def __str__(self) -> str: return f'{self.key}' -class KeyValue(models.Model): +class KeyValue(FullCleanSaveMixin): """ Model for environment variables in the database. Should not be used for secrets. diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 38db6033c..333ea7cc6 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -8,10 +8,11 @@ from django.db import models +from root.utils.mixins import FullCleanSaveMixin from .general import Organization, User, Gang -class Recruitment(models.Model): +class Recruitment(FullCleanSaveMixin): name_nb = models.CharField(max_length=100, help_text='Name of the recruitment') name_en = models.CharField(max_length=100, help_text='Name of the recruitment') visible_from = models.DateTimeField(help_text='When it becomes visible for applicants') @@ -26,7 +27,7 @@ class Recruitment(models.Model): def is_active(self) -> bool: return self.visible_from < timezone.now() < self.actual_application_deadline - def clean(self) -> None: + def clean(self, *args: tuple, **kwargs: dict) -> None: # All times should be in the future now = timezone.now() if any( @@ -59,7 +60,7 @@ def __str__(self) -> str: return f'Recruitment: {self.name_en} at {self.organization}' -class RecruitmentPosition(models.Model): +class RecruitmentPosition(FullCleanSaveMixin): name_nb = models.CharField(max_length=100, help_text='Name of the position') name_en = models.CharField(max_length=100, help_text='Name of the position') @@ -84,17 +85,19 @@ class RecruitmentPosition(models.Model): blank=True, ) + shared_interview_positions = models.ManyToManyField('self', symmetrical=True, blank=True, help_text='Positions with shared interview') + # TODO: Implement tag functionality tags = models.CharField(max_length=100, help_text='Tags for the position') # TODO: Implement interviewer functionality - interviewers = models.ManyToManyField(to=User, help_text='Interviewers for the position', blank=True, related_name='interviews') + interviewers = models.ManyToManyField(to=User, help_text='Interviewers for the position', blank=True, related_name='interviewers') def __str__(self) -> str: return f'Position: {self.name_en} in {self.recruitment}' -class InterviewRoom(models.Model): +class InterviewRoom(FullCleanSaveMixin): name = models.CharField(max_length=255, help_text='Name of the room') location = models.CharField(max_length=255, help_text='Physical location, eg. campus') start_time = models.DateTimeField(help_text='Start time of availability') @@ -112,17 +115,12 @@ def clean(self) -> None: super().clean() -class RecruitmentAdmission(models.Model): - admission_text = models.TextField(help_text='Admission text for the admission') - recruitment_position = models.ForeignKey( - RecruitmentPosition, on_delete=models.CASCADE, help_text='The recruitment position that is recruiting', related_name='admissions' - ) - recruitment = models.ForeignKey(Recruitment, on_delete=models.CASCADE, help_text='The recruitment that is recruiting', related_name='admissions') - user = models.ForeignKey(User, on_delete=models.CASCADE, help_text='The user that is applying', related_name='admissions') - applicant_priority = models.IntegerField(help_text='The priority of the admission') - +class Interview(FullCleanSaveMixin): + # User visible fields interview_time = models.DateTimeField(help_text='The time of the interview', null=True, blank=True) - interview_location = models.CharField(max_length=100, help_text='Where the intevjuee should wait', null=True, blank=True) + interview_location = models.CharField(max_length=255, help_text='The location of the interview', null=True, blank=True) + + # Admin visible fields room = models.ForeignKey( InterviewRoom, on_delete=models.SET_NULL, @@ -131,6 +129,21 @@ class RecruitmentAdmission(models.Model): help_text='Room where the interview is held', related_name='interviews', ) + notes = models.TextField(help_text='Notes for the interview', null=True, blank=True) + + +class RecruitmentAdmission(FullCleanSaveMixin): + admission_text = models.TextField(help_text='Admission text for the admission') + recruitment_position = models.ForeignKey( + RecruitmentPosition, on_delete=models.CASCADE, help_text='The recruitment position that is recruiting', related_name='admissions' + ) + recruitment = models.ForeignKey(Recruitment, on_delete=models.CASCADE, help_text='The recruitment that is recruiting', related_name='admissions') + user = models.ForeignKey(User, on_delete=models.CASCADE, help_text='The user that is applying', related_name='admissions') + applicant_priority = models.IntegerField(help_text='The priority of the admission') + + interview = models.ForeignKey( + Interview, on_delete=models.SET_NULL, null=True, blank=True, help_text='The interview for the admission', related_name='admissions' + ) PRIORITY_CHOICES = [ (0, 'Not Set'), @@ -153,3 +166,21 @@ class RecruitmentAdmission(models.Model): def __str__(self) -> str: return f'Admission: {self.user} for {self.recruitment_position} in {self.recruitment}' + + def save(self, *args: tuple, **kwargs: dict) -> None: + """ + If the admission is saved without an interview, try to find an interview from a shared position. + """ + if not self.interview: + # Check if there is already an interview for the same user in shared positions + shared_interview_positions = self.recruitment_position.shared_interview_positions.all() + shared_interview = RecruitmentAdmission.objects.filter(user=self.user, + recruitment_position__in=shared_interview_positions).exclude(interview=None).first() + + if shared_interview: + self.interview = shared_interview.interview + else: + # Create a new interview instance if needed + self.interview = Interview.objects.create() + + super(RecruitmentAdmission, self).save(*args, **kwargs) diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index 30e186bc3..2a1204f8d 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -9,7 +9,13 @@ from rest_framework import serializers from .models.billig import BilligEvent, BilligTicketGroup, BilligPriceGroup -from .models.recruitment import (Recruitment, RecruitmentPosition, RecruitmentAdmission, InterviewRoom) +from .models.recruitment import ( + Recruitment, + RecruitmentPosition, + RecruitmentAdmission, + InterviewRoom, + Interview, +) from .models.event import (Event, EventGroup, EventCustomTicket) from .models.general import ( Tag, @@ -525,8 +531,6 @@ class Meta: fields = [ 'admission_text', 'recruitment_position', - 'interview_time', - 'interview_location', ] def create(self, validated_data: dict) -> RecruitmentAdmission: @@ -541,8 +545,6 @@ def create(self, validated_data: dict) -> RecruitmentAdmission: recruitment=recruitment, user=user, applicant_priority=applicant_priority, - interview_time=validated_data.get('interview_time'), - interview_location=validated_data.get('interview_location') ) return recruitment_admission @@ -555,16 +557,35 @@ class Meta: fields = ['id', 'first_name', 'last_name', 'email'] +class InterviewRoomSerializer(serializers.ModelSerializer): + + class Meta: + model = InterviewRoom + fields = '__all__' + + +class InterviewSerializer(serializers.ModelSerializer): + + class Meta: + model = Interview + fields = '__all__' + + class RecruitmentAdmissionForGangSerializer(serializers.ModelSerializer): user = ApplicantInfoSerializer(read_only=True) + interview = InterviewSerializer(read_only=False) class Meta: model = RecruitmentAdmission fields = '__all__' + def update(self, instance: RecruitmentAdmission, validated_data: dict) -> RecruitmentAdmission: + interview_data = validated_data.pop('interview', {}) -class InterviewRoomSerializer(serializers.ModelSerializer): + interview_instance = instance.interview + interview_instance.interview_location = interview_data.get('interview_location', interview_instance.interview_location) + interview_instance.interview_time = interview_data.get('interview_time', interview_instance.interview_time) + interview_instance.save() - class Meta: - model = InterviewRoom - fields = '__all__' + # Update other fields of RecruitmentAdmission instance + return super().update(instance, validated_data) diff --git a/backend/samfundet/tests/test_views.py b/backend/samfundet/tests/test_views.py index ae3ddfed2..8bc0940fc 100644 --- a/backend/samfundet/tests/test_views.py +++ b/backend/samfundet/tests/test_views.py @@ -3,14 +3,25 @@ from typing import TYPE_CHECKING from django.contrib.auth.models import Permission, Group -from django.urls import reverse from django.utils import timezone +from django.urls import reverse from rest_framework import status from guardian.shortcuts import assign_perm from root.utils import routes, permissions -from samfundet.models.recruitment import Recruitment, RecruitmentPosition, RecruitmentAdmission -from samfundet.models.general import User, KeyValue, TextItem, InformationPage, BlogPost, Image +from samfundet.models.recruitment import ( + Recruitment, + RecruitmentPosition, + RecruitmentAdmission, +) +from samfundet.models.general import ( + User, + KeyValue, + TextItem, + InformationPage, + BlogPost, + Image, +) from samfundet.serializers import UserSerializer if TYPE_CHECKING: @@ -101,9 +112,17 @@ def test_get_groups(fixture_rest_client: APIClient, fixture_user: User): class TestInformationPagesView: - def test_get_informationpage(self, fixture_rest_client: APIClient, fixture_user: User, fixture_informationpage: InformationPage): + def test_get_informationpage( + self, + fixture_rest_client: APIClient, + fixture_user: User, + fixture_informationpage: InformationPage, + ): ### Arrange ### - url = reverse(routes.samfundet__information_detail, kwargs={'pk': fixture_informationpage.slug_field}) + url = reverse( + routes.samfundet__information_detail, + kwargs={'pk': fixture_informationpage.slug_field}, + ) ### Act ### response: Response = fixture_rest_client.get(path=url) @@ -113,7 +132,12 @@ def test_get_informationpage(self, fixture_rest_client: APIClient, fixture_user: assert status.is_success(code=response.status_code) assert data['slug_field'] == fixture_informationpage.slug_field - def test_get_informationpages(self, fixture_rest_client: APIClient, fixture_user: User, fixture_informationpage: InformationPage): + def test_get_informationpages( + self, + fixture_rest_client: APIClient, + fixture_user: User, + fixture_informationpage: InformationPage, + ): ### Arrange ### url = reverse(routes.samfundet__information_list) @@ -144,9 +168,17 @@ def test_create_informationpage(self, fixture_rest_client: APIClient, fixture_us data = response.json() assert data['slug_field'] == post_data['slug_field'] - def test_delete_informationpage(self, fixture_rest_client: APIClient, fixture_user: User, fixture_informationpage: InformationPage): + def test_delete_informationpage( + self, + fixture_rest_client: APIClient, + fixture_user: User, + fixture_informationpage: InformationPage, + ): fixture_rest_client.force_authenticate(user=fixture_user) - url = reverse(routes.samfundet__information_detail, kwargs={'pk': fixture_informationpage.slug_field}) + url = reverse( + routes.samfundet__information_detail, + kwargs={'pk': fixture_informationpage.slug_field}, + ) response: Response = fixture_rest_client.delete(path=url) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -157,9 +189,17 @@ def test_delete_informationpage(self, fixture_rest_client: APIClient, fixture_us assert status.is_success(code=response.status_code) - def test_put_informationpage(self, fixture_rest_client: APIClient, fixture_user: User, fixture_informationpage: InformationPage): + def test_put_informationpage( + self, + fixture_rest_client: APIClient, + fixture_user: User, + fixture_informationpage: InformationPage, + ): fixture_rest_client.force_authenticate(user=fixture_user) - url = reverse(routes.samfundet__information_detail, kwargs={'pk': fixture_informationpage.slug_field}) + url = reverse( + routes.samfundet__information_detail, + kwargs={'pk': fixture_informationpage.slug_field}, + ) put_data = {'title_nb': 'lol'} response: Response = fixture_rest_client.put(path=url, data=put_data) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -177,7 +217,12 @@ def test_put_informationpage(self, fixture_rest_client: APIClient, fixture_user: class TestBlogPostView: - def test_get_blogpost(self, fixture_rest_client: APIClient, fixture_user: User, fixture_blogpost: BlogPost): + def test_get_blogpost( + self, + fixture_rest_client: APIClient, + fixture_user: User, + fixture_blogpost: BlogPost, + ): ### Arrange ### url = reverse(routes.samfundet__blog_detail, kwargs={'pk': fixture_blogpost.id}) @@ -189,7 +234,12 @@ def test_get_blogpost(self, fixture_rest_client: APIClient, fixture_user: User, assert status.is_success(code=response.status_code) assert data['id'] == fixture_blogpost.id - def test_get_blogposts(self, fixture_rest_client: APIClient, fixture_user: User, fixture_blogpost: BlogPost): + def test_get_blogposts( + self, + fixture_rest_client: APIClient, + fixture_user: User, + fixture_blogpost: BlogPost, + ): ### Arrange ### url = reverse(routes.samfundet__blog_list) @@ -220,7 +270,12 @@ def test_create_blogpost(self, fixture_rest_client: APIClient, fixture_user: Use data = response.json() assert data['title_nb'] == post_data['title_nb'] - def test_delete_blogpost(self, fixture_rest_client: APIClient, fixture_user: User, fixture_blogpost: BlogPost): + def test_delete_blogpost( + self, + fixture_rest_client: APIClient, + fixture_user: User, + fixture_blogpost: BlogPost, + ): fixture_rest_client.force_authenticate(user=fixture_user) url = reverse(routes.samfundet__blog_detail, kwargs={'pk': fixture_blogpost.id}) response: Response = fixture_rest_client.delete(path=url) @@ -233,7 +288,12 @@ def test_delete_blogpost(self, fixture_rest_client: APIClient, fixture_user: Use assert status.is_success(code=response.status_code) - def test_put_blogpost(self, fixture_rest_client: APIClient, fixture_user: User, fixture_blogpost: BlogPost): + def test_put_blogpost( + self, + fixture_rest_client: APIClient, + fixture_user: User, + fixture_blogpost: BlogPost, + ): fixture_rest_client.force_authenticate(user=fixture_user) url = reverse(routes.samfundet__blog_detail, kwargs={'pk': fixture_blogpost.id}) put_data = {'title_nb': 'Samfundet blir gult!'} @@ -303,10 +363,9 @@ def test_crud_not_possible(self, fixture_rest_client: APIClient, fixture_superus class TestTextItemView: - def test_anyone_can_retrieve_textitems(self, fixture_rest_client: APIClient): + def test_anyone_can_retrieve_textitems(self, fixture_rest_client: APIClient, fixture_text_item: TextItem): ### Arrange ### - textitem = TextItem.objects.create(key='FOO') - url = reverse(routes.samfundet__text_item_detail, kwargs={'pk': textitem.key}) + url = reverse(routes.samfundet__text_item_detail, kwargs={'pk': fixture_text_item.key}) ### Act ### response: Response = fixture_rest_client.get(path=url) @@ -314,11 +373,10 @@ def test_anyone_can_retrieve_textitems(self, fixture_rest_client: APIClient): ### Assert ### assert status.is_success(code=response.status_code) - assert data['key'] == textitem.key + assert data['key'] == fixture_text_item.key - def test_anyone_can_list_textitems(self, fixture_rest_client: APIClient): + def test_anyone_can_list_textitems(self, fixture_rest_client: APIClient, fixture_text_item: TextItem): ### Arrange ### - textitem = TextItem.objects.create(key='FOO') url = reverse(routes.samfundet__text_item_list) ### Act ### @@ -327,7 +385,7 @@ def test_anyone_can_list_textitems(self, fixture_rest_client: APIClient): ### Assert ### assert status.is_success(code=response.status_code) - assert any([kv['key'] == textitem.key for kv in data]) + assert any([kv['key'] == fixture_text_item.key for kv in data]) def test_crud_not_possible(self, fixture_rest_client: APIClient, fixture_superuser: User): """Not even superuser can do anything.""" @@ -351,7 +409,12 @@ def test_crud_not_possible(self, fixture_rest_client: APIClient, fixture_superus class TestAssignGroupView: - def test_assign_group(self, fixture_rest_client: APIClient, fixture_superuser: User, fixture_user: User): + def test_assign_group( + self, + fixture_rest_client: APIClient, + fixture_superuser: User, + fixture_user: User, + ): ### Arrange ### fixture_rest_client.force_authenticate(user=fixture_superuser) url = reverse(routes.samfundet__assign_group) @@ -365,7 +428,12 @@ def test_assign_group(self, fixture_rest_client: APIClient, fixture_superuser: U assert status.is_success(code=response.status_code) assert group in fixture_user.groups.all() - def test_remove_group(self, fixture_rest_client: APIClient, fixture_superuser: User, fixture_user: User): + def test_remove_group( + self, + fixture_rest_client: APIClient, + fixture_superuser: User, + fixture_user: User, + ): ### Arrange ### fixture_rest_client.force_authenticate(user=fixture_superuser) url = reverse(routes.samfundet__assign_group) @@ -425,7 +493,11 @@ def test_assign_group_not_found(self, fixture_rest_client: APIClient, fixture_su # =============================== # -def test_get_recruitments(fixture_rest_client: APIClient, fixture_superuser: User, fixture_recruitment: Recruitment): +def test_get_recruitments( + fixture_rest_client: APIClient, + fixture_superuser: User, + fixture_recruitment: Recruitment, +): ### Arrange ### fixture_rest_client.force_authenticate(user=fixture_superuser) url = reverse(routes.samfundet__recruitment_list) @@ -439,7 +511,11 @@ def test_get_recruitments(fixture_rest_client: APIClient, fixture_superuser: Use assert response.data[0]['name_nb'] == fixture_recruitment.name_nb -def test_get_recruitment_positions(fixture_rest_client: APIClient, fixture_superuser: User, fixture_recruitment_position: RecruitmentPosition): +def test_get_recruitment_positions( + fixture_rest_client: APIClient, + fixture_superuser: User, + fixture_recruitment_position: RecruitmentPosition, +): ### Arrange ### fixture_rest_client.force_authenticate(user=fixture_superuser) url = reverse(routes.samfundet__recruitment_position_list) @@ -473,8 +549,11 @@ def test_recruitment_positions_per_recruitment( def test_get_applicants_without_interviews( - fixture_rest_client: APIClient, fixture_superuser: User, fixture_recruitment: Recruitment, fixture_user: User, - fixture_recruitment_admission: RecruitmentAdmission + fixture_rest_client: APIClient, + fixture_superuser: User, + fixture_recruitment: Recruitment, + fixture_user: User, + fixture_recruitment_admission: RecruitmentAdmission, ): ### Arrange ### fixture_rest_client.force_authenticate(user=fixture_superuser) @@ -493,15 +572,19 @@ def test_get_applicants_without_interviews( def test_get_applicants_without_interviews_when_interview_is_set( - fixture_rest_client: APIClient, fixture_superuser: User, fixture_recruitment: Recruitment, fixture_user: User, - fixture_recruitment_admission: RecruitmentAdmission + fixture_rest_client: APIClient, + fixture_superuser: User, + fixture_recruitment: Recruitment, + fixture_user: User, + fixture_recruitment_admission: RecruitmentAdmission, ): ### Arrange ### fixture_rest_client.force_authenticate(user=fixture_superuser) url = reverse(routes.samfundet__applicants_without_interviews) # Setting the interview time for the user's admission - fixture_recruitment_admission.interview_time = timezone.now() + timezone.timedelta(hours=1) + fixture_recruitment_admission.interview.interview_time = timezone.now() + fixture_recruitment_admission.interview.save() fixture_recruitment_admission.save() ### Act ### @@ -513,7 +596,10 @@ def test_get_applicants_without_interviews_when_interview_is_set( def test_recruitment_admission_for_applicant( - fixture_rest_client: APIClient, fixture_user: User, fixture_recruitment_admission: RecruitmentAdmission, fixture_recruitment: Recruitment + fixture_rest_client: APIClient, + fixture_user: User, + fixture_recruitment_admission: RecruitmentAdmission, + fixture_recruitment: Recruitment, ): ### Arrange ### fixture_rest_client.force_authenticate(user=fixture_user) @@ -526,6 +612,5 @@ def test_recruitment_admission_for_applicant( assert response.status_code == status.HTTP_200_OK # Assert the returned data based on the logic in the view assert len(response.data) == 1 - assert response.data[0]['admission_text'] == fixture_recruitment_admission.admission_text - assert response.data[0]['recruitment_position'] == fixture_recruitment_admission.recruitment_position.id - assert response.data[0]['interview_location'] == fixture_recruitment_admission.interview_location + assert (response.data[0]['admission_text'] == fixture_recruitment_admission.admission_text) + assert (response.data[0]['recruitment_position'] == fixture_recruitment_admission.recruitment_position.id) diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index 482467ea9..187455f24 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -37,6 +37,7 @@ router.register('recruitment-position', views.RecruitmentPositionView, 'recruitment_position') router.register('recruitment-admisisons-for-applicant', views.RecruitmentAdmissionForApplicantView, 'recruitment_admissions_for_applicant') router.register('recruitment-admisisons-for-gang', views.RecruitmentAdmissionForGangView, 'recruitment_admissions_for_gang') +router.register('interview', views.InterviewView, 'interview') app_name = 'samfundet' diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 1bdcbb614..84e012343 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -31,6 +31,7 @@ RecruitmentPosition, RecruitmentAdmission, InterviewRoom, + Interview, ) from .models.general import ( Tag, @@ -75,6 +76,7 @@ KeyValueSerializer, MenuItemSerializer, GangTypeSerializer, + InterviewSerializer, BlogPostSerializer, EventGroupSerializer, RecruitmentSerializer, @@ -527,9 +529,14 @@ def get_queryset(self) -> QuerySet[User]: return User.objects.none() # Return an empty queryset instead of None # Exclude users who have any admissions for the given recruitment that have an interview_time - users_without_interviews = User.objects.filter(admissions__recruitment=recruitment).annotate( - num_interviews=Count(Case(When(admissions__recruitment=recruitment, then='admissions__interview_time'), default=None, output_field=None)) - ).filter(num_interviews=0) + interview_times_for_recruitment = Case( + When(admissions__recruitment=recruitment, then='admissions__interview__interview_time'), + default=None, + output_field=None, + ) + users_without_interviews = ( + User.objects.filter(admissions__recruitment=recruitment).annotate(num_interviews=Count(interview_times_for_recruitment)).filter(num_interviews=0) + ) return users_without_interviews @@ -624,3 +631,9 @@ def list(self, request: Request) -> Response: filtered_rooms = InterviewRoom.objects.filter(recruitment__id=recruitment) serialized_rooms = self.get_serializer(filtered_rooms, many=True) return Response(serialized_rooms.data) + + +class InterviewView(ModelViewSet): + permission_classes = [AllowAny] + serializer_class = InterviewSerializer + queryset = Interview.objects.all() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b255b1fa0..5c8267452 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Helmet, HelmetProvider } from 'react-helmet-async'; import { BrowserRouter } from 'react-router-dom'; import { ToastContainer } from 'react-toastify'; import { AppRoutes } from '~/AppRoutes'; +import { useIsDarkTheme } from '~/hooks'; import 'react-toastify/dist/ReactToastify.min.css'; @@ -13,6 +14,7 @@ export function App() { const goatCounterCode = import.meta.env.VITE_GOATCOUNTER_CODE; const isDev = import.meta.env.DEV; const localSetup = isDev ? '{"allow_local": true}' : undefined; + const isDarkTheme = useIsDarkTheme(); return ( @@ -32,7 +34,7 @@ export function App() { {/* Move down from navbar. */} - + ); diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index ef7b8e299..cb70b5156 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -92,10 +92,11 @@ export function RecruitmentPositionOverviewPage() { { content: ( putRecruitmentAdmissionForGang(admission.id.toString(), admission)} onChange={(value: string) => { - const newAdmission = { ...admission, interview_time: value.toString() }; + const updatedInterview = { ...admission.interview, interview_time: value.toString() }; + const newAdmission = { ...admission, interview: updatedInterview }; setRecruitmentApplicants(immutableSet(recruitmentApplicants, admission, newAdmission)); }} type="datetime-local" @@ -105,10 +106,11 @@ export function RecruitmentPositionOverviewPage() { { content: ( putRecruitmentAdmissionForGang(admission.id.toString(), admission)} onChange={(value: string) => { - const newAdmission = { ...admission, interview_location: value.toString() }; + const updatedInterview = { ...admission.interview, interview_location: value.toString() }; + const newAdmission = { ...admission, interview: updatedInterview }; setRecruitmentApplicants(immutableSet(recruitmentApplicants, admission, newAdmission)); }} /> diff --git a/frontend/src/dto.ts b/frontend/src/dto.ts index 93b3e31b0..3688d5c83 100644 --- a/frontend/src/dto.ts +++ b/frontend/src/dto.ts @@ -341,15 +341,22 @@ export type RecruitmentPositionDto = { interviewers: UserDto[]; }; +export type InterviewDto = { + id: number; + interview_time: string; + interview_location: string; + room: string; + notes: string; +}; + export type RecruitmentAdmissionDto = { id: number; + interview: InterviewDto; admission_text: string; recruitment_position?: number; recruitment: number; user: UserDto; applicant_priority: number; - interview_time?: string; - interview_location?: string; recruiter_priority?: number; recruiter_status?: number; };