diff --git a/backend/samfundet/migrations/0011_recruitmentposition_file_description_en_and_more.py b/backend/samfundet/migrations/0011_recruitmentposition_file_description_en_and_more.py new file mode 100644 index 000000000..8f43a3976 --- /dev/null +++ b/backend/samfundet/migrations/0011_recruitmentposition_file_description_en_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.1 on 2024-12-07 12:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("samfundet", "0010_recruitment_promo_media"), + ] + + operations = [ + migrations.AddField( + model_name="recruitmentposition", + name="file_description_en", + field=models.TextField( + blank=True, help_text="Description of file needed (EN)", null=True + ), + ), + migrations.AddField( + model_name="recruitmentposition", + name="file_description_nb", + field=models.TextField( + blank=True, help_text="Description of file needed (NB)", null=True + ), + ), + migrations.AddField( + model_name="recruitmentposition", + name="has_file_upload", + field=models.BooleanField( + default=False, help_text="Does position have file upload" + ), + ), + ] diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 514fcda9d..0c1e4ecc2 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -143,6 +143,16 @@ class RecruitmentPosition(CustomBaseModel): gang = models.ForeignKey(to=Gang, on_delete=models.CASCADE, help_text='The gang that is recruiting', null=True, blank=True) section = models.ForeignKey(GangSection, on_delete=models.CASCADE, help_text='The section that is recruiting', null=True, blank=True) + has_file_upload = models.BooleanField(help_text='Does position have file upload', default=False) + file_description_nb = models.TextField(help_text='Description of file needed (NB)', null=True, blank=True) + file_description_en = models.TextField(help_text='Description of file needed (EN)', null=True, blank=True) + + # 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='interviewers') + recruitment = models.ForeignKey( Recruitment, on_delete=models.CASCADE, @@ -161,12 +171,6 @@ class RecruitmentPosition(CustomBaseModel): help_text='Shared interviewgroup for position', ) - # 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='interviewers') - def resolve_section(self, *, return_id: bool = False) -> GangSection | int: if return_id: # noinspection PyTypeChecker @@ -185,11 +189,31 @@ def resolve_org(self, *, return_id: bool = False) -> Organization | int: def __str__(self) -> str: return f'Position: {self.name_en} in {self.recruitment}' - def clean(self) -> None: + # Error messages + ONLY_ONE_OWNER_ERROR = 'Position must be owned by either gang or section, not both' + NO_OWNER_ERROR = 'Position must have an owner, either a gang or a gang section' + FILE_DESCRIPTION_REQUIRED_ERROR = 'Description of file is needed, if position has file upload' + + def clean(self) -> None: # noqa: C901 super().clean() + errors: dict[str, list[ValidationError]] = defaultdict(list) - if (self.gang and self.section) or not (self.gang or self.section): - raise ValidationError('Position must be owned by either gang or section, not both') + if self.gang and self.section: + # Both gang and section provide + errors['gang'].append(self.ONLY_ONE_OWNER_ERROR) + errors['section'].append(self.ONLY_ONE_OWNER_ERROR) + elif not (self.gang or self.section): + # neither gang nor section provided + errors['gang'].append(self.NO_OWNER_ERROR) + errors['section'].append(self.NO_OWNER_ERROR) + if self.has_file_upload: + # Check Norwegian file description + if not self.file_description_nb or len(self.file_description_nb) == 0: + errors['file_description_nb'].append(self.FILE_DESCRIPTION_REQUIRED_ERROR) + # Check English file description + if not self.file_description_en or len(self.file_description_en) == 0: + errors['file_description_en'].append(self.FILE_DESCRIPTION_REQUIRED_ERROR) + raise ValidationError(errors) def save(self, *args: tuple, **kwargs: dict) -> None: if self.norwegian_applicants_only: diff --git a/backend/samfundet/models/tests/test_recruitment.py b/backend/samfundet/models/tests/test_recruitment.py index 5a754d239..1d88aebff 100644 --- a/backend/samfundet/models/tests/test_recruitment.py +++ b/backend/samfundet/models/tests/test_recruitment.py @@ -95,6 +95,67 @@ def test_actual_deadline_before_shown_deadline(self, fixture_org): assert Recruitment.SHOWN_AFTER_ACTUAL_ERROR in e['shown_application_deadline'] +class TestRecruitmentPosition: + default_data = { + 'name_en': 'Name_en', + 'name_nb': 'Name_nb', + 'short_description_nb': 'short_description_nb', + 'short_description_en': 'short_description_en', + 'long_description_nb': 'long_description_nb', + 'long_description_en': 'long_description_en', + 'is_funksjonaer_position': False, + 'default_application_letter_nb': 'default_application_letter_nb', + 'default_application_letter_en': 'default_application_letter_en', + 'norwegian_applicants_only': False, + 'tags': 'tag1, tag2, tag3', + } + + def test_create_recruitmentposition_gang(self, fixture_gang: Gang): + test_position = RecruitmentPosition.objects.create(**self.default_data, gang=fixture_gang) + assert test_position.id + + def test_create_recruitmentposition_section(self, fixture_gang_section: GangSection): + test_position = RecruitmentPosition.objects.create(**self.default_data, section=fixture_gang_section) + assert test_position.id + + def test_create_recruitmentposition_no_section(self): + with pytest.raises(ValidationError) as error: + RecruitmentPosition.objects.create(**self.default_data) + e = dict(error.value) + assert RecruitmentPosition.NO_OWNER_ERROR in e['section'] + assert RecruitmentPosition.NO_OWNER_ERROR in e['gang'] + + def test_create_recruitmentposition_only_one_owner(self, fixture_gang_section: GangSection, fixture_gang: Gang): + with pytest.raises(ValidationError) as error: + RecruitmentPosition.objects.create(**self.default_data, section=fixture_gang_section, gang=fixture_gang) + e = dict(error.value) + assert RecruitmentPosition.ONLY_ONE_OWNER_ERROR in e['section'] + assert RecruitmentPosition.ONLY_ONE_OWNER_ERROR in e['gang'] + + def test_create_recruitmentposition_file_upload_no_description(self, fixture_gang_section: GangSection): + with pytest.raises(ValidationError) as error: + RecruitmentPosition.objects.create(**self.default_data, section=fixture_gang_section, has_file_upload=True) + e = dict(error.value) + assert RecruitmentPosition.FILE_DESCRIPTION_REQUIRED_ERROR in e['file_description_nb'] + assert RecruitmentPosition.FILE_DESCRIPTION_REQUIRED_ERROR in e['file_description_en'] + + with pytest.raises(ValidationError) as error: + RecruitmentPosition.objects.create(**self.default_data, section=fixture_gang_section, has_file_upload=True, file_description_en='Description') + e = dict(error.value) + assert RecruitmentPosition.FILE_DESCRIPTION_REQUIRED_ERROR in e['file_description_nb'] + + with pytest.raises(ValidationError) as error: + RecruitmentPosition.objects.create(**self.default_data, section=fixture_gang_section, has_file_upload=True, file_description_nb='Description') + e = dict(error.value) + assert RecruitmentPosition.FILE_DESCRIPTION_REQUIRED_ERROR in e['file_description_en'] + + def test_create_recruitmentposition_file_upload(self, fixture_gang_section: GangSection): + test_position = RecruitmentPosition.objects.create( + **self.default_data, section=fixture_gang_section, has_file_upload=True, file_description_en='Description', file_description_nb='Description' + ) + assert test_position.id + + class TestRecruitmentStats: def test_recruitment_has_stats(self, fixture_recruitment: Recruitment): """Check if fixture_recruitment has the related object"""