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;
};