diff --git a/backend/root/management/commands/seed_scripts/recruitment_position.py b/backend/root/management/commands/seed_scripts/recruitment_position.py index 0a2739d9f..5d6198dff 100644 --- a/backend/root/management/commands/seed_scripts/recruitment_position.py +++ b/backend/root/management/commands/seed_scripts/recruitment_position.py @@ -21,10 +21,10 @@ def seed(): RecruitmentPosition.objects.all().delete() yield 0, 'Deleted old recruitmentpositions' - gangs = Gang.objects.all() recruitments = Recruitment.objects.all() created_count = 0 for recruitment_index, recruitment in enumerate(recruitments): + gangs = Gang.objects.filter(organization=recruitment.organization) for gang_index, gang in enumerate(sample(list(gangs), 6)): for i in range(2): # Create 2 instances for each gang and recruitment position_data = POSITION_DATA.copy() diff --git a/backend/samfundet/conftest.py b/backend/samfundet/conftest.py index 8efc8ff6e..5b37ad608 100644 --- a/backend/samfundet/conftest.py +++ b/backend/samfundet/conftest.py @@ -237,26 +237,38 @@ def fixture_organization2() -> Iterator[Organization]: @pytest.fixture def fixture_gang(fixture_organization: Organization) -> Iterator[Gang]: - organization = Gang.objects.create( + gang = Gang.objects.create( name_nb='Gang', name_en='Gang', abbreviation='G', organization=fixture_organization, ) - yield organization - organization.delete() + yield gang + gang.delete() @pytest.fixture -def fixture_gang2(fixture_organization2: Organization) -> Iterator[Gang]: - organization = Gang.objects.create( +def fixture_gang2(fixture_organization: Organization) -> Iterator[Gang]: + gang = Gang.objects.create( name_nb='Gang 2', name_en='Gang 2', abbreviation='G2', + organization=fixture_organization, + ) + yield gang + gang.delete() + + +@pytest.fixture +def fixture_gang_org2(fixture_organization2: Organization) -> Iterator[Gang]: + gang = Gang.objects.create( + name_nb='Gang 3', + name_en='Gang 3', + abbreviation='G3', organization=fixture_organization2, ) - yield organization - organization.delete() + yield gang + gang.delete() @pytest.fixture diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 0c1e4ecc2..f579b7ba5 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -192,6 +192,7 @@ def __str__(self) -> str: # 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' + POSITION_NOT_IN_RECRUITMENTORGANIZATION_ERROR = 'Position must be of the organization which hosts the recruitment' FILE_DESCRIPTION_REQUIRED_ERROR = 'Description of file is needed, if position has file upload' def clean(self) -> None: # noqa: C901 @@ -206,6 +207,8 @@ def clean(self) -> None: # noqa: C901 # neither gang nor section provided errors['gang'].append(self.NO_OWNER_ERROR) errors['section'].append(self.NO_OWNER_ERROR) + if self.gang and self.gang.organization != self.recruitment.organization: + errors['gang'].append(self.POSITION_NOT_IN_RECRUITMENTORGANIZATION_ERROR) if self.has_file_upload: # Check Norwegian file description if not self.file_description_nb or len(self.file_description_nb) == 0: diff --git a/backend/samfundet/models/tests/test_recruitment.py b/backend/samfundet/models/tests/test_recruitment.py index 1d88aebff..e25d807d4 100644 --- a/backend/samfundet/models/tests/test_recruitment.py +++ b/backend/samfundet/models/tests/test_recruitment.py @@ -110,10 +110,26 @@ class TestRecruitmentPosition: 'tags': 'tag1, tag2, tag3', } - def test_create_recruitmentposition_gang(self, fixture_gang: Gang): - test_position = RecruitmentPosition.objects.create(**self.default_data, gang=fixture_gang) + def test_create_recruitmentposition_gang(self, fixture_recruitment: Recruitment, fixture_gang: Gang): + test_position = RecruitmentPosition.objects.create(**self.default_data, recruitment=fixture_recruitment, gang=fixture_gang) assert test_position.id + def test_edit_recruitmentposition_gang_from_other_org(self, fixture_recruitment: Recruitment, fixture_gang: Gang, fixture_gang_org2: Gang): + test_position = RecruitmentPosition.objects.create(**self.default_data, recruitment=fixture_recruitment, gang=fixture_gang) + assert test_position.id + + with pytest.raises(ValidationError) as error: + test_position.gang = fixture_gang_org2 + test_position.save() + e = dict(error.value) + assert RecruitmentPosition.POSITION_NOT_IN_RECRUITMENTORGANIZATION_ERROR in e['gang'] + + def test_create_recruitmentposition_gang_from_other_org(self, fixture_recruitment: Recruitment, fixture_gang_org2: Gang): + with pytest.raises(ValidationError) as error: + RecruitmentPosition.objects.create(**self.default_data, recruitment=fixture_recruitment, gang=fixture_gang_org2) + e = dict(error.value) + assert RecruitmentPosition.POSITION_NOT_IN_RECRUITMENTORGANIZATION_ERROR in e['gang'] + 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 @@ -125,9 +141,9 @@ def test_create_recruitmentposition_no_section(self): 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): + def test_create_recruitmentposition_only_one_owner(self, fixture_recruitment: Recruitment, 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) + RecruitmentPosition.objects.create(**self.default_data, recruitment=fixture_recruitment, 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'] diff --git a/backend/samfundet/tests/test_roles.py b/backend/samfundet/tests/test_roles.py index 859ecee98..61e1da489 100644 --- a/backend/samfundet/tests/test_roles.py +++ b/backend/samfundet/tests/test_roles.py @@ -119,7 +119,7 @@ def test_has_perm_different_orgs( def test_has_perm_different_gangs( fixture_user: User, fixture_gang: Gang, - fixture_gang2: Gang, + fixture_gang_org2: Gang, fixture_gang_permission: Permission, fixture_role: Role, ): @@ -132,7 +132,7 @@ def test_has_perm_different_gangs( UserGangRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang) assert backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang) - assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang2) + assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang_org2) def test_has_perm_different_gang_sections( @@ -185,7 +185,7 @@ def test_has_perm_org_downward( fixture_organization: Organization, fixture_organization2: Organization, fixture_gang: Gang, - fixture_gang2: Gang, + fixture_gang_org2: Gang, fixture_gang_section: GangSection, fixture_role: Role, fixture_org_permission: Permission, @@ -230,7 +230,7 @@ def test_has_perm_org_downward( assert backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) - fixture_gang_section.gang = fixture_gang2 + fixture_gang_section.gang = fixture_gang_org2 assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index 940ac346f..1b183eab5 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -94,7 +94,11 @@ views.RecruitmentInterviewGroupView.as_view(), name='recruitment_shared_interviews', ), - path('recruitment-positions-gang-for-gangs/', views.RecruitmentPositionsPerGangForGangView.as_view(), name='recruitment_positions_gang_for_gangs'), + path( + 'recruitment-positions-gang-for-gangs///', + views.RecruitmentPositionsPerGangForGangView.as_view(), + name='recruitment_positions_gang_for_gangs', + ), path('recruitment-set-interview//', views.RecruitmentApplicationSetInterviewView.as_view(), name='recruitment_set_interview'), path( 'recruitment-application-states-choices', diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 59666f8aa..4ed427c79 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -731,7 +731,7 @@ def get_queryset(self) -> Response | None: @method_decorator(ensure_csrf_cookie, 'dispatch') -class RecruitmentPositionsPerGangForApplicantView(ListAPIView): +class RecruitmentPositionsPerGangForApplicantView(APIView): permission_classes = [AllowAny] serializer_class = RecruitmentPositionForApplicantSerializer @@ -748,20 +748,17 @@ def get_queryset(self) -> Response | None: @method_decorator(ensure_csrf_cookie, 'dispatch') -class RecruitmentPositionsPerGangForGangView(ListAPIView): +class RecruitmentPositionsPerGangForGangView(APIView): permission_classes = [IsAuthenticated] serializer_class = RecruitmentPositionSerializer - def get_queryset(self) -> Response | None: - """ - Optionally restricts the returned positions to a given recruitment, - by filtering against a `recruitment` query parameter in the URL. - """ - recruitment = self.request.query_params.get('recruitment', None) - gang = self.request.query_params.get('gang', None) - if recruitment is not None and gang is not None: - return RecruitmentPosition.objects.filter(gang=gang, recruitment=recruitment) - return None + def get(self, request: Request, recruitment_id: int, gang_id: int) -> Response: + gang = get_object_or_404(Gang, id=gang_id) + recruitment = get_object_or_404(Recruitment, id=recruitment_id) + if recruitment.resolve_org() != gang.resolve_org(): + return Response('Gang not found in recruitment organization', status=status.HTTP_404_NOT_FOUND) + data = RecruitmentPosition.objects.filter(gang=gang, recruitment=recruitment) + return Response(data=self.serializer_class(data, many=True).data, status=status.HTTP_200_OK) class SendRejectionMailView(APIView): diff --git a/frontend/src/PagesAdmin/RecruitmentGangAdminPage/RecruitmentGangAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentGangAdminPage/RecruitmentGangAdminPage.tsx index 936037ebd..51526ef44 100644 --- a/frontend/src/PagesAdmin/RecruitmentGangAdminPage/RecruitmentGangAdminPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentGangAdminPage/RecruitmentGangAdminPage.tsx @@ -1,15 +1,16 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useLoaderData, useNavigate, useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; import { Button, CrudButtons, Link } from '~/Components'; import { Table } from '~/Components/Table'; -import { getGang, getRecruitment, getRecruitmentPositionsGangForGang } from '~/api'; +import { getRecruitmentPositionsGangForGang } from '~/api'; import type { GangDto, RecruitmentDto, RecruitmentPositionDto } from '~/dto'; import { useTitle } from '~/hooks'; import { STATUS } from '~/http_status_codes'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; +import type { RecruitmentGangLoader } from '~/router/loaders'; import { ROUTES } from '~/routes'; import { dbT, getObjectFieldOrNumber, lowerCapitalize } from '~/utils'; import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; @@ -19,6 +20,7 @@ export function RecruitmentGangAdminPage() { const { recruitmentId, gangId } = useParams(); const navigate = useNavigate(); const [gang, setGang] = useState(); + const loader = useLoaderData() as RecruitmentGangLoader | undefined; const [recruitment, setRecruitment] = useState(); const [recruitmentPositions, setRecruitmentPositions] = useState([]); const [showSpinner, setShowSpinner] = useState(true); @@ -32,19 +34,9 @@ export function RecruitmentGangAdminPage() { // biome-ignore lint/correctness/useExhaustiveDependencies: t and navigate do not need to be in deplist useEffect(() => { if (recruitmentId && gangId) { - Promise.allSettled([ - getRecruitmentPositionsGangForGang(recruitmentId, gangId).then((data) => { + getRecruitmentPositionsGangForGang(recruitmentId, gangId) + .then((data) => { setRecruitmentPositions(data.data); - }), - getGang(gangId).then((data) => { - setGang(data); - }), - getRecruitment(recruitmentId).then(async (data) => { - setRecruitment(data.data); - }), - ]) - .then(() => { - setShowSpinner(false); }) .catch((data) => { if (data.request.status === STATUS.HTTP_404_NOT_FOUND) { @@ -55,6 +47,19 @@ export function RecruitmentGangAdminPage() { } }, [recruitmentId, gangId]); + useEffect(() => { + if (recruitmentPositions && gang && recruitment) { + setShowSpinner(false); + } + }, [recruitmentPositions, gang, recruitment]); + + useEffect(() => { + if (loader) { + setGang(loader.gang); + setRecruitment(loader.recruitment); + } + }, [loader]); + const tableColumns = [ { content: t(KEY.recruitment_position), sortable: true }, { content: t(KEY.recruitment_jobtype), sortable: true }, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ed62fcd69..02809edab 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -606,7 +606,7 @@ export async function getRecruitmentPositionsGangForGang( BACKEND_DOMAIN + reverse({ pattern: ROUTES.backend.samfundet__recruitment_positions_gang_for_gangs, - queryParams: { recruitment: recruitmentId, gang: gangId }, + urlParams: { recruitmentId: recruitmentId, gangId: gangId }, }); const response = await axios.get(url, { withCredentials: true }); diff --git a/frontend/src/router/loaders.ts b/frontend/src/router/loaders.ts index c3c1ec630..1e373201e 100644 --- a/frontend/src/router/loaders.ts +++ b/frontend/src/router/loaders.ts @@ -14,6 +14,11 @@ export type PositionLoader = { position: RecruitmentPositionDto | undefined; }; +export type RecruitmentGangLoader = { + recruitment: RecruitmentDto | undefined; + gang: GangDto | undefined; +}; + export type SeparatePositionLoader = { separatePosition: RecruitmentSeparatePositionDto | undefined; }; diff --git a/frontend/src/router/router.tsx b/frontend/src/router/router.tsx index d9084088e..4c757f9c5 100644 --- a/frontend/src/router/router.tsx +++ b/frontend/src/router/router.tsx @@ -566,6 +566,7 @@ export const router = createBrowserRouter( > } />} />