Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix postitions can have gangs from seperate organizations #1636

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
26 changes: 19 additions & 7 deletions backend/samfundet/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions backend/samfundet/models/recruitment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
24 changes: 20 additions & 4 deletions backend/samfundet/models/tests/test_recruitment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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']
Expand Down
8 changes: 4 additions & 4 deletions backend/samfundet/tests/test_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
6 changes: 5 additions & 1 deletion backend/samfundet/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<int:recruitment_id>/<int:gang_id>/',
views.RecruitmentPositionsPerGangForGangView.as_view(),
name='recruitment_positions_gang_for_gangs',
),
path('recruitment-set-interview/<slug:pk>/', views.RecruitmentApplicationSetInterviewView.as_view(), name='recruitment_set_interview'),
path(
'recruitment-application-states-choices',
Expand Down
21 changes: 9 additions & 12 deletions backend/samfundet/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +20,7 @@ export function RecruitmentGangAdminPage() {
const { recruitmentId, gangId } = useParams();
const navigate = useNavigate();
const [gang, setGang] = useState<GangDto>();
const loader = useLoaderData() as RecruitmentGangLoader | undefined;
const [recruitment, setRecruitment] = useState<RecruitmentDto>();
const [recruitmentPositions, setRecruitmentPositions] = useState<RecruitmentPositionDto[]>([]);
const [showSpinner, setShowSpinner] = useState<boolean>(true);
Expand All @@ -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) {
Expand All @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/router/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
1 change: 1 addition & 0 deletions frontend/src/router/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ export const router = createBrowserRouter(
>
<Route
path={ROUTES.frontend.admin_recruitment_gang_position_overview}
loader={recruitmentGangLoader}
element={<PermissionRoute required={[]} element={<RecruitmentGangAdminPage />} />}
/>
<Route
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/routes/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,6 @@ export const ROUTES_BACKEND = {
samfundet__recruitment_applications_for_position_detail: '/api/recruitment-applications-for-position/:pk/',
samfundet__interview_list: '/api/interview/',
samfundet__interview_detail: '/api/interview/:pk/',
samfundet__api_root: '/api/',
samfundet__schema: '/schema/',
samfundet__swagger_ui: '/schema/swagger-ui/',
samfundet__redoc: '/schema/redoc/',
Expand All @@ -588,7 +587,7 @@ export const ROUTES_BACKEND = {
samfundet__recruitment_show_unprocessed_applicants: '/recruitment-show-unprocessed-applicants/',
samfundet__recruitment_positions_gang_for_applicants: '/recruitment-positions-gang-for-applicant/',
samfundet__recruitment_shared_interviews: '/recruitment-shared-interview-groups/:recruitmentId/',
samfundet__recruitment_positions_gang_for_gangs: '/recruitment-positions-gang-for-gangs/',
samfundet__recruitment_positions_gang_for_gangs: '/recruitment-positions-gang-for-gangs/:recruitmentId/:gangId/',
samfundet__recruitment_set_interview: '/recruitment-set-interview/:pk/',
samfundet__recruitment_application_states_choices: '/recruitment-application-states-choices',
samfundet__recruitment_application_update_state_gang: '/recruitment-application-update-state-gang/:pk/',
Expand All @@ -611,4 +610,4 @@ export const ROUTES_BACKEND = {
samfundet__gang_application_stats: '/recruitment/:recruitmentId/gang/:gangId/stats/',
static__path: '/static/:path',
media__path: '/media/:path',
} as const;
} as const;
Loading