From 96df1c8409e5263498f6f521f600f172def37ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=98vre=20Sygard?= <56266980+magsyg@users.noreply.github.com> Date: Fri, 20 Dec 2024 16:25:14 +0100 Subject: [PATCH 1/4] separate get user in positionform (#1639) * separate get user in positionform * feat: fix infinte loop --------- Co-authored-by: magsyg --- .../RecruitmentPositionForm.tsx | 16 ++++++---------- .../RecruitmentPositionFormAdminPage.tsx | 11 +++++++---- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx index 481dbdfc1..2453a0008 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx @@ -48,7 +48,7 @@ interface FormProps { positionId?: string; recruitmentId?: string; gangId?: string; - users?: Partial; + users?: UserDto[]; } export function RecruitmentPositionForm({ initialData, positionId, recruitmentId, gangId, users }: FormProps) { @@ -98,12 +98,10 @@ export function RecruitmentPositionForm({ initialData, positionId, recruitmentId // Convert users array to dropdown options const interviewerOptions = - users - ?.filter((user) => user?.id && (user?.username || user?.first_name)) - .map((user) => ({ - value: user?.id, - label: user?.username || `${user?.first_name} ${user?.last_name}`, - })) || []; + users?.map((user) => ({ + value: user.id, + label: user?.username || `${user?.first_name} ${user?.last_name}`, + })) || []; // Get currently selected interviewers const selectedInterviewers = form.watch('interviewer_ids') || []; @@ -282,11 +280,9 @@ export function RecruitmentPositionForm({ initialData, positionId, recruitmentId selected={interviewerOptions.filter( (option) => option.value && selectedInterviewers.includes(option.value), )} - onChange={(values) => { - field.onChange(values); - }} optionsLabel="Available Interviewers" selectedLabel="Selected Interviewers" + {...field} /> diff --git a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx index 0536248ec..a02b5c976 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx @@ -16,7 +16,13 @@ export function RecruitmentPositionFormAdminPage() { const navigate = useNavigate(); const { recruitmentId, gangId, positionId } = useParams(); const [position, setPosition] = useState>(); - const [users, setUsers] = useState>(); + const [users, setUsers] = useState(); + + useEffect(() => { + getUsers().then((data) => { + setUsers(data); + }); + }, []); useEffect(() => { if (positionId) { @@ -35,9 +41,6 @@ export function RecruitmentPositionFormAdminPage() { ); }); } - getUsers().then((data) => { - setUsers(data); - }); }, [positionId, recruitmentId, gangId, navigate, t]); const initialData: Partial = { From ce08b37e7af72d3273966b48347421521e2b8e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=98vre=20Sygard?= <56266980+magsyg@users.noreply.github.com> Date: Fri, 20 Dec 2024 16:51:40 +0100 Subject: [PATCH 2/4] feat: add has_fileuploadfield (#1632) * feat: add has_fileuploadfield * add docs --------- Co-authored-by: magsyg --- ...ntposition_file_description_en_and_more.py | 33 ++++++++++ backend/samfundet/models/recruitment.py | 42 ++++++++++--- .../models/tests/test_recruitment.py | 61 +++++++++++++++++++ 3 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 backend/samfundet/migrations/0011_recruitmentposition_file_description_en_and_more.py 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""" From 2fba039191e519a35e2c4aff52cc99f9746dbca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=98vre=20Sygard?= <56266980+magsyg@users.noreply.github.com> Date: Thu, 2 Jan 2025 15:36:28 +0100 Subject: [PATCH 3/4] fix header overflow (#1648) Co-authored-by: magsyg --- frontend/src/Components/Table/Table.module.scss | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/src/Components/Table/Table.module.scss b/frontend/src/Components/Table/Table.module.scss index 3c98d8871..afd13d3a6 100644 --- a/frontend/src/Components/Table/Table.module.scss +++ b/frontend/src/Components/Table/Table.module.scss @@ -96,16 +96,13 @@ th { .sortable_th { cursor: pointer; + position: relative; transition: 0.2s; } .sort_icons { - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - float: right; - + position: absolute; + right: 0; .icon { background-color: transparent; padding: 2px; From 39fda686350999958ab0e8eaf7f7532c85711ce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=98vre=20Sygard?= <56266980+magsyg@users.noreply.github.com> Date: Thu, 2 Jan 2025 15:40:57 +0100 Subject: [PATCH 4/4] fix header showing active recruitment when none (#1645) Co-authored-by: magsyg --- frontend/src/Components/Navbar/Navbar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/Components/Navbar/Navbar.tsx b/frontend/src/Components/Navbar/Navbar.tsx index 46e823da5..4f2ab2c73 100644 --- a/frontend/src/Components/Navbar/Navbar.tsx +++ b/frontend/src/Components/Navbar/Navbar.tsx @@ -56,6 +56,8 @@ export function Navbar() { }); }, []); + const showActiveRecruitments = activeRecruitments !== undefined && activeRecruitments?.length > 0; + // Return profile button for navbar if logged in. const mobileProfileButton = (
@@ -133,7 +135,7 @@ export function Navbar() { expandedDropdown={expandedDropdown} route={ROUTES.frontend.recruitment} label={t(KEY.common_volunteer)} - labelClassName={activeRecruitments && styles.active_recruitment} + labelClassName={showActiveRecruitments ? styles.active_recruitment : ''} />
);