From 9f4bfa1837dc2da787569d8c76889843dab4eaea Mon Sep 17 00:00:00 2001 From: Mathias Aas Date: Sun, 13 Oct 2024 19:50:44 +0200 Subject: [PATCH 01/26] Add withdraw to application overview --- .../RecruitmentApplicationsOverviewPage.tsx | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx index 2a2f5cc09..0014669a2 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx @@ -1,12 +1,17 @@ import { Icon } from '@iconify/react'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; +import { toast } from 'react-toastify'; import { Button, Link, Page } from '~/Components'; import { OccupiedFormModal } from '~/Components/OccupiedForm'; import { Table } from '~/Components/Table'; import { Text } from '~/Components/Text/Text'; -import { getRecruitmentApplicationsForApplicant, putRecruitmentPriorityForUser } from '~/api'; +import { + getRecruitmentApplicationsForApplicant, + putRecruitmentPriorityForUser, + withdrawRecruitmentApplicationApplicant, +} from '~/api'; import type { RecruitmentApplicationDto, UserPriorityDto } from '~/dto'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; @@ -18,6 +23,7 @@ export function RecruitmentApplicationsOverviewPage() { const { recruitmentID } = useParams(); const [applications, setApplications] = useState([]); const [withdrawnApplications, setWithdrawnApplications] = useState([]); + const navigate = useNavigate(); const { t } = useTranslation(); @@ -52,6 +58,7 @@ export function RecruitmentApplicationsOverviewPage() { { sortable: false, content: t(KEY.recruitment_interview_location) }, { sortable: true, content: t(KEY.recruitment_priority) }, { sortable: false, content: '' }, + { sortable: false, content: '' }, ]; function applicationToTableRow(application: RecruitmentApplicationDto) { @@ -88,7 +95,26 @@ export function RecruitmentApplicationsOverviewPage() { ), }, ]; - return [...position, ...(application.withdrawn ? withdrawn : notWithdrawn)]; + const widthdrawButton = { + content: ( + + ), + }; + return [...position, ...(application.withdrawn ? withdrawn : notWithdrawn), widthdrawButton]; } const withdrawnTableColumns = [{ sortable: true, content: t(KEY.recruitment_withdrawn) }]; From 52308bae7e57287a407e9014f58a022e4c1547e9 Mon Sep 17 00:00:00 2001 From: Mathias Aas Date: Tue, 22 Oct 2024 18:51:43 +0200 Subject: [PATCH 02/26] add confirm --- .../RecruitmentApplicationsOverviewPage.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx index 0014669a2..dde4ccbd5 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx @@ -100,14 +100,16 @@ export function RecruitmentApplicationsOverviewPage() { - ), - }; - return [...position, ...(application.withdrawn ? withdrawn : notWithdrawn), widthdrawButton]; - } - - const withdrawnTableColumns = [{ sortable: true, content: t(KEY.recruitment_withdrawn) }]; - - function withdrawnApplicationToTableRow(application: RecruitmentApplicationDto) { - return [ - { - value: dbT(application.recruitment_position, 'name'), - content: ( - - {dbT(application.recruitment_position, 'name')} - - ), - }, - ]; - } - return (
@@ -154,31 +22,9 @@ export function RecruitmentApplicationsOverviewPage() {

{t(KEY.recruitment_will_be_anonymized)}

- {applications.length > 0 ? ( - ({ cells: applicationToTableRow(application) }))} - columns={tableColumns} - defaultSortColumn={3} - /> - ) : ( -

{t(KEY.recruitment_not_applied)}

- )} - + - - {withdrawnApplications.length > 0 && ( -
-
({ - cells: withdrawnApplicationToTableRow(application), - }))} - columns={withdrawnTableColumns} - /> - - )} + ); diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss new file mode 100644 index 000000000..8c835e5f7 --- /dev/null +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss @@ -0,0 +1,10 @@ +.arrows { + &:hover { + filter: brightness(150%); + transform: scale(1.05); + } + &:active { + filter: brightness(200%); + transform: scale(1.1); + } +} diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx new file mode 100644 index 000000000..9fab4976c --- /dev/null +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx @@ -0,0 +1,128 @@ +import { Icon } from '@iconify/react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { Button, Link, Table, Text } from '~/Components'; +import { + getRecruitmentApplicationsForApplicant, + putRecruitmentPriorityForUser, + withdrawRecruitmentApplicationApplicant, +} from '~/api'; +import type { RecruitmentApplicationDto, UserPriorityDto } from '~/dto'; +import { KEY } from '~/i18n/constants'; +import { reverse } from '~/named-urls'; +import { ROUTES } from '~/routes'; +import { dbT, niceDateTime } from '~/utils'; +import styles from './ActiveApplications.module.scss'; + +type ActiveApplicationsProps = { + recruitmentId?: string; +}; +export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { + const [applications, setApplications] = useState([]); + const { t } = useTranslation(); + const navigate = useNavigate(); + + function handleChangePriority(id: string, direction: 'up' | 'down') { + const data: UserPriorityDto = { direction: direction === 'up' ? 1 : -1 }; + putRecruitmentPriorityForUser(id, data).then((response) => { + setApplications(response.data); + }); + } + + const upDownArrow = (id: string) => { + return ( + <> + handleChangePriority(id, 'up')} /> + handleChangePriority(id, 'down')} /> + + ); + }; + useEffect(() => { + if (recruitmentId) { + getRecruitmentApplicationsForApplicant(recruitmentId).then((response) => { + setApplications(response.data.filter((application) => !application.withdrawn)); + }); + } + }, [recruitmentId]); + const tableColumns = [ + { sortable: false, content: t(KEY.recruitment_position) }, + { sortable: false, content: t(KEY.recruitment_interview_time) }, + { sortable: false, content: t(KEY.recruitment_interview_location) }, + { sortable: true, content: t(KEY.recruitment_priority) }, + { sortable: false, content: '' }, + { sortable: false, content: '' }, + ]; + + function applicationToTableRow(application: RecruitmentApplicationDto) { + const position = [ + { + content: ( + + {dbT(application.recruitment_position, 'name')} + + ), + }, + ]; + const notWithdrawn = [ + niceDateTime(application.interview?.interview_time), + application.interview?.interview_location, + application.applicant_priority, + { content: upDownArrow(application.id) }, + ]; + const withdrawn = [ + { + content: ( + + {t(KEY.recruitment_withdrawn)} + + ), + }, + ]; + const widthdrawButton = { + content: ( + + ), + }; + return [...position, ...(application.withdrawn ? withdrawn : notWithdrawn), widthdrawButton]; + } + return ( +
+ {applications.length > 0 ? ( +
({ cells: applicationToTableRow(application) }))} + columns={tableColumns} + defaultSortColumn={3} + /> + ) : ( +

{t(KEY.recruitment_not_applied)}

+ )} + + ); +} diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/index.ts b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/index.ts new file mode 100644 index 000000000..5a85a5931 --- /dev/null +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/index.ts @@ -0,0 +1 @@ +export { ActiveApplications } from './ActiveApplications'; diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.module.scss b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.module.scss new file mode 100644 index 000000000..12e923507 --- /dev/null +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.module.scss @@ -0,0 +1,31 @@ +@import 'src/mixins'; + +@import 'src/constants'; + +.withdrawnHeader { + background-color: $red-samf; + color: $white; + &:hover { + background-color: $red-samf; + filter: brightness(95%); + } + border: none; +} + +.withdrawnRow { + background-color: $grey-3; + &:hover { + background-color: $grey-3; + filter: brightness(95%); + } + border-bottom: none; + border-top: 1px solid $grey-2; +} + +.withdrawnContainer { + margin-top: 2em; +} + +.withdrawnText { + color: $red; +} diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.tsx new file mode 100644 index 000000000..0993e683c --- /dev/null +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, Table } from '~/Components'; +import { getRecruitmentApplicationsForApplicant } from '~/api'; +import type { RecruitmentApplicationDto } from '~/dto'; +import { KEY } from '~/i18n/constants'; +import { reverse } from '~/named-urls'; +import { ROUTES } from '~/routes'; +import { dbT } from '~/utils'; +import styles from './WithdrawnApplications.module.scss'; + +type WithdrawnApplicationsProps = { + recruitmentId?: string; +}; +export function WithdrawnApplications({ recruitmentId }: WithdrawnApplicationsProps) { + const [withdrawnApplications, setWithdrawnApplications] = useState([]); + const { t } = useTranslation(); + useEffect(() => { + if (recruitmentId) { + getRecruitmentApplicationsForApplicant(recruitmentId).then((response) => { + setWithdrawnApplications(response.data.filter((application) => application.withdrawn)); + }); + } + }, [recruitmentId]); + + const withdrawnTableColumns = [{ sortable: true, content: t(KEY.recruitment_withdrawn) }]; + + function withdrawnApplicationToTableRow(application: RecruitmentApplicationDto) { + return [ + { + value: dbT(application.recruitment_position, 'name'), + content: ( + + {dbT(application.recruitment_position, 'name')} + + ), + }, + ]; + } + + return ( +
+ {withdrawnApplications.length > 0 && ( +
+
({ + cells: withdrawnApplicationToTableRow(application), + }))} + columns={withdrawnTableColumns} + /> + + )} + + ); +} diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/index.ts b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/index.ts new file mode 100644 index 000000000..31d7f4813 --- /dev/null +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/index.ts @@ -0,0 +1 @@ +export { WithdrawnApplications } from './WithdrawnApplications'; From 8f18de954797705dd8977505f2244585c0bcacc8 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 24 Oct 2024 02:11:03 +0200 Subject: [PATCH 04/26] adds filter which fixes the bug in frontend. Should try ti implement fix for backend --- .../components/ActiveApplications/ActiveApplications.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx index 9fab4976c..31eb661e2 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx @@ -27,7 +27,7 @@ export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { function handleChangePriority(id: string, direction: 'up' | 'down') { const data: UserPriorityDto = { direction: direction === 'up' ? 1 : -1 }; putRecruitmentPriorityForUser(id, data).then((response) => { - setApplications(response.data); + setApplications(response.data.filter((application) => !application.withdrawn)); }); } From d963df37a578d2b6f99f56f2f336a418326f96ca Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 24 Oct 2024 03:46:40 +0200 Subject: [PATCH 05/26] configurates views to fetch correct withdrwan or non-withdrawn applications --- backend/samfundet/views.py | 58 ++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 5d1d4aa9a..e293c7ba1 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -846,7 +846,7 @@ def update(self, request: Request, pk: int) -> Response: data['user'] = request.user.pk serializer = self.get_serializer(data=data) if serializer.is_valid(): - existing_application = RecruitmentApplication.objects.filter(user=request.user, recruitment_position=pk).first() + existing_application = RecruitmentApplication.objects.filter(user=request.user, recruitment_position=pk, withdrawn=False).first() if existing_application: existing_application.application_text = serializer.validated_data['application_text'] existing_application.save() @@ -857,12 +857,11 @@ def update(self, request: Request, pk: int) -> Response: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request: Request, pk: int) -> Response: - application = get_object_or_404(RecruitmentApplication, user=request.user, recruitment_position=pk) - + application = get_object_or_404(RecruitmentApplication, user=request.user, recruitment_position=pk, withdrawn=False) user_id = request.query_params.get('user_id') if user_id: # TODO: Add permissions - application = RecruitmentApplication.objects.filter(recruitment_position=pk, user_id=user_id).first() + application = RecruitmentApplication.objects.filter(recruitment_position=pk, user_id=user_id, witdrawn=False).first() serializer = self.get_serializer(application) return Response(serializer.data) @@ -876,16 +875,13 @@ def list(self, request: Request) -> Response: recruitment = get_object_or_404(Recruitment, id=recruitment_id) - applications = RecruitmentApplication.objects.filter( - recruitment=recruitment, - user=request.user, - ) + applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user=request.user, withdrawn=False) if user_id: # TODO: Add permissions - applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user_id=user_id) + applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user_id=user_id, withdrawn=False) else: - applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user=request.user) + applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user=request.user, withdrawn=False) serializer = self.get_serializer(applications, many=True) return Response(serializer.data) @@ -894,10 +890,20 @@ def list(self, request: Request) -> Response: class RecruitmentApplicationWithdrawApplicantView(APIView): permission_classes = [IsAuthenticated] + def get(self, request: Request, pk: int) -> Response: + # Get applications for specific recruitment process + applications = RecruitmentApplication.objects.filter( + recruitment_position__recruitment_id=pk, + user=request.user, + withdrawn=True, # Only get non-withdrawn applications + ) + serializer = RecruitmentApplicationForApplicantSerializer(applications, many=True) + return Response(serializer.data) + def put(self, request: Request, pk: int) -> Response: # Checks if user has applied for position application = get_object_or_404(RecruitmentApplication, recruitment_position=pk, user=request.user) - # Withdraw if applied + # Application confirmed by get_object_or_404, contiues with withdrawing application application.withdrawn = True application.save() serializer = RecruitmentApplicationForApplicantSerializer(application) @@ -920,29 +926,39 @@ class RecruitmentApplicationApplicantPriorityView(APIView): permission_classes = [IsAuthenticated] serializer_class = RecruitmentUpdateUserPrioritySerializer - def put( - self, - request: Request, - pk: int, - ) -> Response: + def put(self, request: Request, pk: int) -> Response: direction = RecruitmentUpdateUserPrioritySerializer(data=request.data) if direction.is_valid(): direction = direction.validated_data['direction'] else: return Response(direction.errors, status=status.HTTP_400_BAD_REQUEST) - # Dont think we need any extra perms in this view, admin should not be able to change priority + # Get the current application and verify it exists and isn't withdrawn application = get_object_or_404( RecruitmentApplication, id=pk, user=request.user, + withdrawn=False, ) + + # Update the priority application.update_priority(direction) + + # Get all non-withdrawn applications for this recruitment and user + active_applications = RecruitmentApplication.objects.filter( + recruitment=application.recruitment, + user=request.user, + withdrawn=False, # Explicitly exclude withdrawn applications + ).order_by('applicant_priority') + + # Rebase priorities to ensure they're sequential starting from 1 + for index, app in enumerate(active_applications, start=1): + if app.applicant_priority != index: + app.applicant_priority = index + app.save() + serializer = RecruitmentApplicationForApplicantSerializer( - RecruitmentApplication.objects.filter( - recruitment=application.recruitment, - user=request.user, - ).order_by('applicant_priority'), + active_applications, many=True, ) return Response(serializer.data) From 6482d45ab18c5e0ac85af6a79a0c199d6f1fbc16 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 24 Oct 2024 03:47:03 +0200 Subject: [PATCH 06/26] adds api call to get withdrawn applications --- frontend/src/api.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4b9d5872c..79fededf5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -952,6 +952,18 @@ export async function withdrawRecruitmentApplicationApplicant(positionId: number return response; } +export async function getWithdrawRecruitmentApplicationApplicant(positionId: number | string): Promise { + const url = + BACKEND_DOMAIN + + reverse({ + pattern: ROUTES.backend.samfundet__recruitment_withdraw_application, + urlParams: { pk: positionId }, + }); + const response = await axios.get(url, { withCredentials: true }); + + return response; +} + export async function withdrawRecruitmentApplicationRecruiter(id: string): Promise { const url = BACKEND_DOMAIN + From df9184c693a93069192e1c98148778a1c056eb55 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 24 Oct 2024 03:47:44 +0200 Subject: [PATCH 07/26] implements withdrawn / ono-withdrawn api calls in components --- .../ApplicantApplicationOverviewPage.tsx | 0 .../components/ActiveApplications/ActiveApplications.tsx | 4 ++-- .../WithdrawnApplications/WithdrawnApplications.tsx | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 frontend/src/Pages/ApplicantApplicationOverviewPage/ApplicantApplicationOverviewPage.tsx diff --git a/frontend/src/Pages/ApplicantApplicationOverviewPage/ApplicantApplicationOverviewPage.tsx b/frontend/src/Pages/ApplicantApplicationOverviewPage/ApplicantApplicationOverviewPage.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx index 31eb661e2..342c8762b 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx @@ -27,7 +27,7 @@ export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { function handleChangePriority(id: string, direction: 'up' | 'down') { const data: UserPriorityDto = { direction: direction === 'up' ? 1 : -1 }; putRecruitmentPriorityForUser(id, data).then((response) => { - setApplications(response.data.filter((application) => !application.withdrawn)); + setApplications(response.data); }); } @@ -42,7 +42,7 @@ export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { useEffect(() => { if (recruitmentId) { getRecruitmentApplicationsForApplicant(recruitmentId).then((response) => { - setApplications(response.data.filter((application) => !application.withdrawn)); + setApplications(response.data); }); } }, [recruitmentId]); diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.tsx index 0993e683c..7dc40ae4a 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, Table } from '~/Components'; -import { getRecruitmentApplicationsForApplicant } from '~/api'; +import { getWithdrawRecruitmentApplicationApplicant } from '~/api'; import type { RecruitmentApplicationDto } from '~/dto'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; @@ -15,10 +15,11 @@ type WithdrawnApplicationsProps = { export function WithdrawnApplications({ recruitmentId }: WithdrawnApplicationsProps) { const [withdrawnApplications, setWithdrawnApplications] = useState([]); const { t } = useTranslation(); + useEffect(() => { if (recruitmentId) { - getRecruitmentApplicationsForApplicant(recruitmentId).then((response) => { - setWithdrawnApplications(response.data.filter((application) => application.withdrawn)); + getWithdrawRecruitmentApplicationApplicant(recruitmentId).then((response) => { + setWithdrawnApplications(response.data); }); } }, [recruitmentId]); From 8153eff0fd4db5864e01188530553638379ddcd2 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 24 Oct 2024 04:42:18 +0200 Subject: [PATCH 08/26] adds translations and changes styling --- .../ActiveApplications.module.scss | 5 +++- .../ActiveApplications/ActiveApplications.tsx | 27 +++++++++++++------ frontend/src/i18n/constants.ts | 2 ++ frontend/src/i18n/translations.ts | 4 +++ 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss index 8c835e5f7..22b9030f1 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss @@ -1,10 +1,13 @@ .arrows { + cursor: pointer; + &:hover { filter: brightness(150%); transform: scale(1.05); } &:active { + cursor: none; filter: brightness(200%); - transform: scale(1.1); + transform: scale(0.5); } } diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx index 342c8762b..feab58534 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx @@ -34,8 +34,18 @@ export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { const upDownArrow = (id: string) => { return ( <> - handleChangePriority(id, 'up')} /> - handleChangePriority(id, 'down')} /> + handleChangePriority(id, 'up')} + /> + handleChangePriority(id, 'down')} + /> ); }; @@ -48,11 +58,12 @@ export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { }, [recruitmentId]); const tableColumns = [ { sortable: false, content: t(KEY.recruitment_position) }, + { sortable: false, content: t(KEY.recruitment_change_priority) }, + { sortable: true, content: t(KEY.recruitment_your_priority) }, { sortable: false, content: t(KEY.recruitment_interview_time) }, { sortable: false, content: t(KEY.recruitment_interview_location) }, - { sortable: true, content: t(KEY.recruitment_priority) }, - { sortable: false, content: '' }, - { sortable: false, content: '' }, + + { sortable: false, content: t(KEY.recruitment_withdraw_application) }, ]; function applicationToTableRow(application: RecruitmentApplicationDto) { @@ -75,10 +86,10 @@ export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { }, ]; const notWithdrawn = [ - niceDateTime(application.interview?.interview_time), - application.interview?.interview_location, - application.applicant_priority, { content: upDownArrow(application.id) }, + application.applicant_priority, + niceDateTime(application.interview?.interview_time) ?? '-', + application.interview?.interview_location ?? '-', ]; const withdrawn = [ { diff --git a/frontend/src/i18n/constants.ts b/frontend/src/i18n/constants.ts index 3e9717c39..830d7d15c 100644 --- a/frontend/src/i18n/constants.ts +++ b/frontend/src/i18n/constants.ts @@ -282,7 +282,9 @@ export const KEY = { recruitment_no_active: 'recruitment_no_active', recruitment_interview_notes: 'recruitment_interview_notes', recruitment_priority: 'recruitment_priority', + recruitment_your_priority: 'recruitment_your_priority', recruitment_recruiter_priority: 'recruitment_recruiter_priority', + recruitment_change_priority: 'recruitment_change_priority', recruitment_recruiter_status: 'recruitment_recruiter_status', recruitment_duration: 'recruitment_duration', recruitment_funksjonaer: 'recruitment_funksjonaer', diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index 6505dc4ff..b8a42434b 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -268,6 +268,8 @@ export const nb = prepareTranslations({ [KEY.recruitment_no_active]: 'Ingen aktive opptak', [KEY.recruitment_interview_notes]: 'Intervju notater', [KEY.recruitment_priority]: 'Søkers prioritet', + [KEY.recruitment_your_priority]: 'Dine søknads prioritet', + [KEY.recruitment_change_priority]: 'Endre prioritet', [KEY.recruitment_recruiter_priority]: 'Prioritet', [KEY.recruitment_recruiter_status]: 'Status', [KEY.common_not_set]: 'Ikke satt', @@ -725,6 +727,8 @@ export const en = prepareTranslations({ [KEY.recruitment_interview_location]: 'Interview Location', [KEY.recruitment_interview_notes]: 'Interview notes', [KEY.recruitment_priority]: 'Applicants priority', + [KEY.recruitment_your_priority]: 'Your applications priority', + [KEY.recruitment_change_priority]: 'Change priority', [KEY.recruitment_recruiter_priority]: 'Priority', [KEY.recruitment_recruiter_status]: 'Status', [KEY.common_not_set]: 'Not set', From 770282117c62c72a90f0ca6aa082d49b11296989 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 24 Oct 2024 05:05:27 +0200 Subject: [PATCH 09/26] fixed translations and refactored table --- .../RecruitmentApplicationsOverviewPage.tsx | 2 +- .../ActiveApplications/ActiveApplications.tsx | 140 +++++++++--------- frontend/src/i18n/translations.ts | 4 +- 3 files changed, 75 insertions(+), 71 deletions(-) diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx index 571cdf7ff..2029bb30c 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx @@ -21,9 +21,9 @@ export function RecruitmentApplicationsOverviewPage() {

{t(KEY.recruitment_my_applications)}

+

{t(KEY.recruitment_will_be_anonymized)}

- diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx index feab58534..4974d4502 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; -import { Button, Link, Table, Text } from '~/Components'; +import { Button, Link, Table } from '~/Components'; import { getRecruitmentApplicationsForApplicant, putRecruitmentPriorityForUser, @@ -31,6 +31,14 @@ export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { }); } + useEffect(() => { + if (recruitmentId) { + getRecruitmentApplicationsForApplicant(recruitmentId).then((response) => { + setApplications(response.data); + }); + } + }, [recruitmentId]); + const upDownArrow = (id: string) => { return ( <> @@ -49,88 +57,84 @@ export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { ); }; - useEffect(() => { - if (recruitmentId) { - getRecruitmentApplicationsForApplicant(recruitmentId).then((response) => { - setApplications(response.data); - }); - } - }, [recruitmentId]); + + const applicationLink = (application: RecruitmentApplicationDto) => { + return ( + + {dbT(application.recruitment_position, 'name')} + + ); + }; + + const withdrawButton = (application: RecruitmentApplicationDto) => { + return ( + + ); + }; const tableColumns = [ - { sortable: false, content: t(KEY.recruitment_position) }, { sortable: false, content: t(KEY.recruitment_change_priority) }, - { sortable: true, content: t(KEY.recruitment_your_priority) }, + { sortable: false, content: t(KEY.recruitment_position) }, + + { sortable: false, content: t(KEY.recruitment_your_priority) }, { sortable: false, content: t(KEY.recruitment_interview_time) }, { sortable: false, content: t(KEY.recruitment_interview_location) }, { sortable: false, content: t(KEY.recruitment_withdraw_application) }, ]; - function applicationToTableRow(application: RecruitmentApplicationDto) { - const position = [ + const tableRows = applications.map((application) => ({ + cells: [ { - content: ( - - {dbT(application.recruitment_position, 'name')} - - ), + content: upDownArrow(application.id), }, - ]; - const notWithdrawn = [ - { content: upDownArrow(application.id) }, - application.applicant_priority, - niceDateTime(application.interview?.interview_time) ?? '-', - application.interview?.interview_location ?? '-', - ]; - const withdrawn = [ { - content: ( - - {t(KEY.recruitment_withdrawn)} - - ), + content: applicationLink(application), }, - ]; - const widthdrawButton = { - content: ( - - ), - }; - return [...position, ...(application.withdrawn ? withdrawn : notWithdrawn), widthdrawButton]; - } + + { + content: application.applicant_priority, + }, + { + content: niceDateTime(application.interview?.interview_time) ?? '-', + }, + { + content: application.interview?.interview_location ?? '-', + }, + { + content: withdrawButton(application), + }, + ], + })); + return (
{applications.length > 0 ? ( -
({ cells: applicationToTableRow(application) }))} - columns={tableColumns} - defaultSortColumn={3} - /> +
) : (

{t(KEY.recruitment_not_applied)}

)} diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index b8a42434b..67732c2d3 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -268,7 +268,7 @@ export const nb = prepareTranslations({ [KEY.recruitment_no_active]: 'Ingen aktive opptak', [KEY.recruitment_interview_notes]: 'Intervju notater', [KEY.recruitment_priority]: 'Søkers prioritet', - [KEY.recruitment_your_priority]: 'Dine søknads prioritet', + [KEY.recruitment_your_priority]: 'Din søknads prioritet', [KEY.recruitment_change_priority]: 'Endre prioritet', [KEY.recruitment_recruiter_priority]: 'Prioritet', [KEY.recruitment_recruiter_status]: 'Status', @@ -727,7 +727,7 @@ export const en = prepareTranslations({ [KEY.recruitment_interview_location]: 'Interview Location', [KEY.recruitment_interview_notes]: 'Interview notes', [KEY.recruitment_priority]: 'Applicants priority', - [KEY.recruitment_your_priority]: 'Your applications priority', + [KEY.recruitment_your_priority]: 'Your application priority', [KEY.recruitment_change_priority]: 'Change priority', [KEY.recruitment_recruiter_priority]: 'Priority', [KEY.recruitment_recruiter_status]: 'Status', From 275d758dfd37a49f9bf9007b40ecb9f57230e5ec Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 24 Oct 2024 05:41:20 +0200 Subject: [PATCH 10/26] implements react query --- .../RecruitmentApplicationsOverviewPage.tsx | 15 ++- .../ActiveApplications/ActiveApplications.tsx | 101 ++++++++++++------ .../WithdrawnApplications.tsx | 22 ++-- 3 files changed, 91 insertions(+), 47 deletions(-) diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx index 2029bb30c..7e4ec4756 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx @@ -8,9 +8,20 @@ import styles from './RecruitmentApplicationsOverviewPage.module.scss'; import { ActiveApplications } from './components/ActiveApplications'; import { WithdrawnApplications } from './components/WithdrawnApplications'; +export type ApplicantApplicationManagementQK = { + applications: (recruitmentId: string) => readonly ['applications', string]; + withdrawnApplications: (recruitmentId: string) => readonly ['withdrawnApplications', string]; +}; + export function RecruitmentApplicationsOverviewPage() { const { recruitmentID } = useParams(); const { t } = useTranslation(); + + const QUERY_KEYS: ApplicantApplicationManagementQK = { + applications: (recruitmentId: string) => ['applications', recruitmentId] as const, + withdrawnApplications: (recruitmentId: string) => ['withdrawnApplications', recruitmentId] as const, + }; + return (
@@ -23,8 +34,8 @@ export function RecruitmentApplicationsOverviewPage() {

{t(KEY.recruitment_will_be_anonymized)}

- - + +
); diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx index 4974d4502..06d7b0f8c 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx @@ -1,5 +1,5 @@ import { Icon } from '@iconify/react'; -import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; @@ -14,30 +14,61 @@ import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; import { dbT, niceDateTime } from '~/utils'; +import type { ApplicantApplicationManagementQK } from '../../RecruitmentApplicationsOverviewPage'; import styles from './ActiveApplications.module.scss'; type ActiveApplicationsProps = { recruitmentId?: string; + queryKey: ApplicantApplicationManagementQK; }; -export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { - const [applications, setApplications] = useState([]); +export function ActiveApplications({ recruitmentId, queryKey }: ActiveApplicationsProps) { const { t } = useTranslation(); const navigate = useNavigate(); - function handleChangePriority(id: string, direction: 'up' | 'down') { - const data: UserPriorityDto = { direction: direction === 'up' ? 1 : -1 }; - putRecruitmentPriorityForUser(id, data).then((response) => { - setApplications(response.data); - }); - } + const queryClient = useQueryClient(); - useEffect(() => { - if (recruitmentId) { - getRecruitmentApplicationsForApplicant(recruitmentId).then((response) => { - setApplications(response.data); + // Query for fetching applications + const { data: applications = [] } = useQuery({ + queryKey: ['applications', recruitmentId], + queryFn: () => getRecruitmentApplicationsForApplicant(recruitmentId as string).then((response) => response.data), + enabled: !!recruitmentId, + }); + + // Mutation for changing priority + const priorityMutation = useMutation({ + mutationFn: ({ id, direction }: { id: string; direction: 'up' | 'down' }) => { + const data: UserPriorityDto = { direction: direction === 'up' ? 1 : -1 }; + return putRecruitmentPriorityForUser(id, data); + }, + onSuccess: (response) => { + // Update the applications in the cache with the new data + queryClient.setQueryData(['applications', recruitmentId], response.data); + }, + onError: () => { + toast.error(t(KEY.common_something_went_wrong)); + }, + }); + + // Mutation for withdrawing application + const withdrawMutation = useMutation({ + mutationFn: (positionId: string) => withdrawRecruitmentApplicationApplicant(positionId), + onSuccess: () => { + // Pass the proper query filter objects + queryClient.invalidateQueries({ + queryKey: queryKey.applications(recruitmentId as string), + }); + queryClient.invalidateQueries({ + queryKey: queryKey.withdrawnApplications(recruitmentId as string), }); - } - }, [recruitmentId]); + }, + onError: () => { + toast.error(t(KEY.common_something_went_wrong)); + }, + }); + + const handleChangePriority = (id: string, direction: 'up' | 'down') => { + priorityMutation.mutate({ id, direction }); + }; const upDownArrow = (id: string) => { return ( @@ -81,14 +112,7 @@ export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { theme="samf" onClick={() => { if (window.confirm(t(KEY.recruitment_withdraw_application))) { - withdrawRecruitmentApplicationApplicant(application.recruitment_position.id) - .then(() => { - // redirect to the same page to refresh the data - navigate(0); - }) - .catch(() => { - toast.error(t(KEY.common_something_went_wrong)); - }); + withdrawMutation.mutate(application.recruitment_position.id); } }} > @@ -97,28 +121,37 @@ export function ActiveApplications({ recruitmentId }: ActiveApplicationsProps) { ); }; const tableColumns = [ - { sortable: false, content: t(KEY.recruitment_change_priority) }, + // Only include priority column if there are multiple applications + ...(applications.length > 1 ? [{ sortable: false, content: t(KEY.recruitment_change_priority) }] : []), { sortable: false, content: t(KEY.recruitment_position) }, - - { sortable: false, content: t(KEY.recruitment_your_priority) }, + // Only include priority display if there are multiple applications + ...(applications.length > 1 ? [{ sortable: false, content: t(KEY.recruitment_your_priority) }] : []), { sortable: false, content: t(KEY.recruitment_interview_time) }, { sortable: false, content: t(KEY.recruitment_interview_location) }, - { sortable: false, content: t(KEY.recruitment_withdraw_application) }, ]; const tableRows = applications.map((application) => ({ cells: [ - { - content: upDownArrow(application.id), - }, + // Only include priority arrows if there are multiple applications + ...(applications.length > 1 + ? [ + { + content: upDownArrow(application.id), + }, + ] + : []), { content: applicationLink(application), }, - - { - content: application.applicant_priority, - }, + // Only include priority number if there are multiple applications + ...(applications.length > 1 + ? [ + { + content: application.applicant_priority, + }, + ] + : []), { content: niceDateTime(application.interview?.interview_time) ?? '-', }, diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.tsx index 7dc40ae4a..a99fef535 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/WithdrawnApplications/WithdrawnApplications.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Link, Table } from '~/Components'; import { getWithdrawRecruitmentApplicationApplicant } from '~/api'; @@ -7,22 +7,22 @@ import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; import { dbT } from '~/utils'; +import type { ApplicantApplicationManagementQK } from '../../RecruitmentApplicationsOverviewPage'; import styles from './WithdrawnApplications.module.scss'; type WithdrawnApplicationsProps = { recruitmentId?: string; + queryKey: ApplicantApplicationManagementQK; }; -export function WithdrawnApplications({ recruitmentId }: WithdrawnApplicationsProps) { - const [withdrawnApplications, setWithdrawnApplications] = useState([]); +export function WithdrawnApplications({ recruitmentId, queryKey }: WithdrawnApplicationsProps) { const { t } = useTranslation(); - useEffect(() => { - if (recruitmentId) { - getWithdrawRecruitmentApplicationApplicant(recruitmentId).then((response) => { - setWithdrawnApplications(response.data); - }); - } - }, [recruitmentId]); + const { data: withdrawnApplications = [] } = useQuery({ + queryKey: queryKey.withdrawnApplications(recruitmentId as string), + queryFn: () => + getWithdrawRecruitmentApplicationApplicant(recruitmentId as string).then((response) => response.data), + enabled: !!recruitmentId, + }); const withdrawnTableColumns = [{ sortable: true, content: t(KEY.recruitment_withdrawn) }]; @@ -56,7 +56,7 @@ export function WithdrawnApplications({ recruitmentId }: WithdrawnApplicationsPr bodyRowClassName={styles.withdrawnRow} headerClassName={styles.withdrawnHeader} headerColumnClassName={styles.withdrawnHeader} - data={withdrawnApplications.map((application) => ({ + data={withdrawnApplications.map((application: RecruitmentApplicationDto) => ({ cells: withdrawnApplicationToTableRow(application), }))} columns={withdrawnTableColumns} From d96c59fb1c1a26b863a3377c193ee89cb707616c Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 24 Oct 2024 05:44:58 +0200 Subject: [PATCH 11/26] adds logic for rebasing application priority --- backend/samfundet/views.py | 87 ++++++++++++++----- .../ActiveApplications/ActiveApplications.tsx | 2 +- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index e293c7ba1..44840fb68 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -838,32 +838,59 @@ class RecruitmentApplicationForApplicantView(ModelViewSet): serializer_class = RecruitmentApplicationForApplicantSerializer queryset = RecruitmentApplication.objects.all() + def _rebase_priorities(self, recruitment: Recruitment, user: User) -> list[RecruitmentApplication]: + """Helper method to rebase priorities for a user's active applications""" + active_applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user=user, withdrawn=False).order_by('applicant_priority') + + # Rebase priorities to ensure they're sequential starting from 1 + for index, application in enumerate(active_applications, start=1): + if application.applicant_priority != index: + application.applicant_priority = index + application.save() + + return active_applications + def update(self, request: Request, pk: int) -> Response: data = request.data.dict() if isinstance(request.data, QueryDict) else request.data recruitment_position = get_object_or_404(RecruitmentPosition, pk=pk) data['recruitment_position'] = recruitment_position.pk data['recruitment'] = recruitment_position.recruitment.pk data['user'] = request.user.pk + serializer = self.get_serializer(data=data) if serializer.is_valid(): - existing_application = RecruitmentApplication.objects.filter(user=request.user, recruitment_position=pk, withdrawn=False).first() + existing_application = RecruitmentApplication.objects.filter(user=request.user, recruitment_position=pk).first() + if existing_application: - existing_application.application_text = serializer.validated_data['application_text'] - existing_application.save() - serializer = self.get_serializer(existing_application) - return Response(serializer.data, status=status.HTTP_200_OK) - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + if existing_application.withdrawn: + # This is a re-application after withdrawal + highest_priority = RecruitmentApplication.objects.filter( + recruitment=recruitment_position.recruitment, user=request.user, withdrawn=False + ).count() + + existing_application.withdrawn = False + existing_application.application_text = serializer.validated_data['application_text'] + existing_application.applicant_priority = highest_priority + 1 + existing_application.save() + else: + # Normal update of existing application + existing_application.application_text = serializer.validated_data['application_text'] + existing_application.save() + + # Rebase priorities and return all active applications + active_applications = self._rebase_priorities(recruitment_position.recruitment, request.user) + return Response(self.get_serializer(active_applications, many=True).data, status=status.HTTP_200_OK) + + # New application - assign next priority number + new_priority = RecruitmentApplication.objects.filter(recruitment=recruitment_position.recruitment, user=request.user, withdrawn=False).count() + 1 + + application = serializer.save(applicant_priority=new_priority) + + # Rebase priorities and return all active applications + active_applications = self._rebase_priorities(recruitment_position.recruitment, request.user) + return Response(self.get_serializer(active_applications, many=True).data, status=status.HTTP_201_CREATED) - def retrieve(self, request: Request, pk: int) -> Response: - application = get_object_or_404(RecruitmentApplication, user=request.user, recruitment_position=pk, withdrawn=False) - user_id = request.query_params.get('user_id') - if user_id: - # TODO: Add permissions - application = RecruitmentApplication.objects.filter(recruitment_position=pk, user_id=user_id, witdrawn=False).first() - serializer = self.get_serializer(application) - return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def list(self, request: Request) -> Response: """Returns a list of all the applications for a user for a specified recruitment""" @@ -875,17 +902,29 @@ def list(self, request: Request) -> Response: recruitment = get_object_or_404(Recruitment, id=recruitment_id) - applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user=request.user, withdrawn=False) - if user_id: # TODO: Add permissions - applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user_id=user_id, withdrawn=False) + applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user_id=user_id, withdrawn=False).order_by('applicant_priority') else: - applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user=request.user, withdrawn=False) + applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user=request.user, withdrawn=False).order_by('applicant_priority') + + # Rebase priorities before returning + applications = self._rebase_priorities(recruitment, request.user if not user_id else user_id) serializer = self.get_serializer(applications, many=True) return Response(serializer.data) + def retrieve(self, request: Request, pk: int) -> Response: + application = get_object_or_404(RecruitmentApplication, user=request.user, recruitment_position=pk, withdrawn=False) + + user_id = request.query_params.get('user_id') + if user_id: + # TODO: Add permissions + application = RecruitmentApplication.objects.filter(recruitment_position=pk, user_id=user_id, withdrawn=False).first() + + serializer = self.get_serializer(application) + return Response(serializer.data) + class RecruitmentApplicationWithdrawApplicantView(APIView): permission_classes = [IsAuthenticated] @@ -952,10 +991,10 @@ def put(self, request: Request, pk: int) -> Response: ).order_by('applicant_priority') # Rebase priorities to ensure they're sequential starting from 1 - for index, app in enumerate(active_applications, start=1): - if app.applicant_priority != index: - app.applicant_priority = index - app.save() + for index, application in enumerate(active_applications, start=1): + if application.applicant_priority != index: + application.applicant_priority = index + application.save() serializer = RecruitmentApplicationForApplicantSerializer( active_applications, diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx index 06d7b0f8c..647422a0c 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx @@ -112,7 +112,7 @@ export function ActiveApplications({ recruitmentId, queryKey }: ActiveApplicatio theme="samf" onClick={() => { if (window.confirm(t(KEY.recruitment_withdraw_application))) { - withdrawMutation.mutate(application.recruitment_position.id); + withdrawMutation.mutate(application.recruitment_position.id.toString()); } }} > From c2159f7b7f3ed23812233209eae83b340afe40d8 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sat, 2 Nov 2024 18:07:50 +0100 Subject: [PATCH 12/26] adds priority direction indicator on positions --- .../ActiveApplications.module.scss | 37 ++++++++ .../ActiveApplications/ActiveApplications.tsx | 94 ++++++++++++++----- 2 files changed, 108 insertions(+), 23 deletions(-) diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss index 22b9030f1..ea6e307d8 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss @@ -11,3 +11,40 @@ transform: scale(0.5); } } + +.positionName { + display: inline-block; +} + +.positionLinkWrapper { + position: relative; + display: inline-block; +} + +.priorityChangeIndicator { + position: absolute; + top: -8px; + right: -24px; + font-size: 24px; + opacity: 0; // Start with opacity 0 + animation: fadeInOut 2s ease; // 2s matches your timeout duration +} + +@keyframes fadeInOut { + 0% { + opacity: 0; + transform: translateY(10px); + } + 15% { + opacity: 1; + transform: translateY(0); + } + 85% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-10px); + } +} diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx index 647422a0c..cff16d33e 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx @@ -1,7 +1,7 @@ import { Icon } from '@iconify/react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; import { Button, Link, Table } from '~/Components'; import { @@ -13,19 +13,33 @@ import type { RecruitmentApplicationDto, UserPriorityDto } from '~/dto'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; +import { COLORS } from '~/types'; import { dbT, niceDateTime } from '~/utils'; import type { ApplicantApplicationManagementQK } from '../../RecruitmentApplicationsOverviewPage'; import styles from './ActiveApplications.module.scss'; +type PriorityChange = { + id: string; + direction: 'up' | 'down'; +}; type ActiveApplicationsProps = { recruitmentId?: string; queryKey: ApplicantApplicationManagementQK; }; + export function ActiveApplications({ recruitmentId, queryKey }: ActiveApplicationsProps) { const { t } = useTranslation(); - const navigate = useNavigate(); - const queryClient = useQueryClient(); + const [recentChanges, setRecentChanges] = useState([]); + // Clear the recent change after 2 seconds + useEffect(() => { + if (recentChanges.length > 0) { + const timer = setTimeout(() => { + setRecentChanges([]); + }, 2000); + return () => clearTimeout(timer); + } + }, [recentChanges]); // Query for fetching applications const { data: applications = [] } = useQuery({ @@ -34,15 +48,31 @@ export function ActiveApplications({ recruitmentId, queryKey }: ActiveApplicatio enabled: !!recruitmentId, }); - // Mutation for changing priority + // Mutation for changing priority, also deals with displaying priority direction const priorityMutation = useMutation({ - mutationFn: ({ id, direction }: { id: string; direction: 'up' | 'down' }) => { + mutationFn: ({ id, direction }: PriorityChange) => { const data: UserPriorityDto = { direction: direction === 'up' ? 1 : -1 }; return putRecruitmentPriorityForUser(id, data); }, - onSuccess: (response) => { - // Update the applications in the cache with the new data + onSuccess: (response, variables) => { + const oldData = queryClient.getQueryData(['applications', recruitmentId]); queryClient.setQueryData(['applications', recruitmentId], response.data); + + if (oldData) { + const clickedApp = oldData.find((app) => app.id === variables.id); + const swappedApp = response.data.find( + (newApp) => + clickedApp && newApp.applicant_priority === clickedApp.applicant_priority && newApp.id !== clickedApp.id, + ); + + if (clickedApp && swappedApp) { + const changes: PriorityChange[] = [ + { id: clickedApp.id, direction: variables.direction }, + { id: swappedApp.id, direction: variables.direction === 'up' ? 'down' : 'up' }, + ]; + setRecentChanges(changes); + } + } }, onError: () => { toast.error(t(KEY.common_something_went_wrong)); @@ -74,15 +104,15 @@ export function ActiveApplications({ recruitmentId, queryKey }: ActiveApplicatio return ( <> handleChangePriority(id, 'up')} /> handleChangePriority(id, 'down')} /> @@ -90,19 +120,37 @@ export function ActiveApplications({ recruitmentId, queryKey }: ActiveApplicatio }; const applicationLink = (application: RecruitmentApplicationDto) => { + const change = recentChanges.find((change) => change.id === application.id); + return ( - - {dbT(application.recruitment_position, 'name')} - +
+ {change && + (change.direction === 'up' ? ( + + ) : ( + + ))} + + {dbT(application.recruitment_position, 'name')} + +
); }; From 9f52e63209a05bba5f6caa92f30ea64e085bee47 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sat, 2 Nov 2024 18:25:14 +0100 Subject: [PATCH 13/26] improved priority controll by adding buttons --- .../ActiveApplications.module.scss | 16 +++++++--- .../ActiveApplications/ActiveApplications.tsx | 30 ++++++++++--------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss index ea6e307d8..78fed115a 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss @@ -1,9 +1,9 @@ -.arrows { +.priorityControllArrow { cursor: pointer; &:hover { filter: brightness(150%); - transform: scale(1.05); + transform: scale(1.5); } &:active { cursor: none; @@ -12,6 +12,14 @@ } } +.priorityControllBtnWrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: start; + gap: 1rem; +} + .positionName { display: inline-block; } @@ -26,8 +34,8 @@ top: -8px; right: -24px; font-size: 24px; - opacity: 0; // Start with opacity 0 - animation: fadeInOut 2s ease; // 2s matches your timeout duration + opacity: 0; + animation: fadeInOut 2s ease; } @keyframes fadeInOut { diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx index cff16d33e..6809cd402 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx @@ -102,20 +102,22 @@ export function ActiveApplications({ recruitmentId, queryKey }: ActiveApplicatio const upDownArrow = (id: string) => { return ( - <> - handleChangePriority(id, 'up')} - /> - handleChangePriority(id, 'down')} - /> - +
+ + +
); }; From 5204851d408259fbc168db90d44a8fce59947e61 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sat, 2 Nov 2024 18:29:10 +0100 Subject: [PATCH 14/26] small tweek to tyling of priority indicator, makes it larger and repositions it --- .../ActiveApplications/ActiveApplications.module.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss index 78fed115a..20fc72101 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.module.scss @@ -31,9 +31,9 @@ .priorityChangeIndicator { position: absolute; - top: -8px; - right: -24px; - font-size: 24px; + top: -1rem; + right: -2.25rem; + font-size: 3rem; opacity: 0; animation: fadeInOut 2s ease; } From f64d84385c06b5202a2aeb0870e49bbb4885abb2 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sat, 2 Nov 2024 18:40:04 +0100 Subject: [PATCH 15/26] small changes to translation --- .../components/ActiveApplications/ActiveApplications.tsx | 2 +- frontend/src/i18n/constants.ts | 1 + frontend/src/i18n/translations.ts | 6 ++++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx index 6809cd402..179feae4a 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx @@ -173,7 +173,7 @@ export function ActiveApplications({ recruitmentId, queryKey }: ActiveApplicatio const tableColumns = [ // Only include priority column if there are multiple applications ...(applications.length > 1 ? [{ sortable: false, content: t(KEY.recruitment_change_priority) }] : []), - { sortable: false, content: t(KEY.recruitment_position) }, + { sortable: false, content: t(KEY.recruitment_application_for_position) }, // Only include priority display if there are multiple applications ...(applications.length > 1 ? [{ sortable: false, content: t(KEY.recruitment_your_priority) }] : []), { sortable: false, content: t(KEY.recruitment_interview_time) }, diff --git a/frontend/src/i18n/constants.ts b/frontend/src/i18n/constants.ts index 830d7d15c..dceefef43 100644 --- a/frontend/src/i18n/constants.ts +++ b/frontend/src/i18n/constants.ts @@ -302,6 +302,7 @@ export const KEY = { recruitment_unprocessed_applicants: 'recruitment_unprocessed_applicants', recruitment_administrate_reservations: 'recruitment_administrate_reservations', recruitment_my_applications: 'recruitment_my_applications', + recruitment_application_for_position: 'recruitment_application_for_position', recruitment_all_applications: 'recruitment_all_applications', recruitment_not_applied: 'recruitment_not_applied', recruitment_will_be_anonymized: 'recruitment_will_be_anonymized', diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index 67732c2d3..ccf97a1e1 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -254,6 +254,7 @@ export const nb = prepareTranslations({ [KEY.recruitment_applicant]: 'Søker', [KEY.recruitment_applicants]: 'Søkere', [KEY.recruitment_my_applications]: 'Mine søknader', + [KEY.recruitment_application_for_position]: 'Søknad på stilling', [KEY.recruitment_all_applications]: 'Alle søknader', [KEY.recruitment_not_applied]: 'Du har ikke sendt søknader til noen stillinger ennå', [KEY.recruitment_will_be_anonymized]: 'All info relatert til dine søknader vil bli slettet 3 uker etter opptaket', @@ -268,7 +269,7 @@ export const nb = prepareTranslations({ [KEY.recruitment_no_active]: 'Ingen aktive opptak', [KEY.recruitment_interview_notes]: 'Intervju notater', [KEY.recruitment_priority]: 'Søkers prioritet', - [KEY.recruitment_your_priority]: 'Din søknads prioritet', + [KEY.recruitment_your_priority]: 'Søknads prioritet', [KEY.recruitment_change_priority]: 'Endre prioritet', [KEY.recruitment_recruiter_priority]: 'Prioritet', [KEY.recruitment_recruiter_status]: 'Status', @@ -716,6 +717,7 @@ export const en = prepareTranslations({ [KEY.recruitment_applicant]: 'Applicant', [KEY.recruitment_applicants]: 'Applicants', [KEY.recruitment_my_applications]: 'My applications', + [KEY.recruitment_application_for_position]: 'Application for position', [KEY.recruitment_all_applications]: 'All applications', [KEY.recruitment_not_applied]: 'You have not applied to any positions yet', [KEY.recruitment_will_be_anonymized]: @@ -727,7 +729,7 @@ export const en = prepareTranslations({ [KEY.recruitment_interview_location]: 'Interview Location', [KEY.recruitment_interview_notes]: 'Interview notes', [KEY.recruitment_priority]: 'Applicants priority', - [KEY.recruitment_your_priority]: 'Your application priority', + [KEY.recruitment_your_priority]: 'Application priority', [KEY.recruitment_change_priority]: 'Change priority', [KEY.recruitment_recruiter_priority]: 'Priority', [KEY.recruitment_recruiter_status]: 'Status', From a171a3913e0998e0db75032da5c6525cf52191ba Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sat, 2 Nov 2024 18:41:42 +0100 Subject: [PATCH 16/26] small change to tranlsation --- frontend/src/i18n/translations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index ccf97a1e1..451cc4f75 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -269,7 +269,7 @@ export const nb = prepareTranslations({ [KEY.recruitment_no_active]: 'Ingen aktive opptak', [KEY.recruitment_interview_notes]: 'Intervju notater', [KEY.recruitment_priority]: 'Søkers prioritet', - [KEY.recruitment_your_priority]: 'Søknads prioritet', + [KEY.recruitment_your_priority]: 'Søknads prioritering', [KEY.recruitment_change_priority]: 'Endre prioritet', [KEY.recruitment_recruiter_priority]: 'Prioritet', [KEY.recruitment_recruiter_status]: 'Status', From 277550036b702ea1c4b557385e953dad272d44d3 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sat, 2 Nov 2024 18:50:34 +0100 Subject: [PATCH 17/26] changed translation, hopfully the last time --- frontend/src/i18n/translations.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index 451cc4f75..1be854247 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -254,7 +254,7 @@ export const nb = prepareTranslations({ [KEY.recruitment_applicant]: 'Søker', [KEY.recruitment_applicants]: 'Søkere', [KEY.recruitment_my_applications]: 'Mine søknader', - [KEY.recruitment_application_for_position]: 'Søknad på stilling', + [KEY.recruitment_application_for_position]: 'Søknad på verv', [KEY.recruitment_all_applications]: 'Alle søknader', [KEY.recruitment_not_applied]: 'Du har ikke sendt søknader til noen stillinger ennå', [KEY.recruitment_will_be_anonymized]: 'All info relatert til dine søknader vil bli slettet 3 uker etter opptaket', @@ -269,7 +269,7 @@ export const nb = prepareTranslations({ [KEY.recruitment_no_active]: 'Ingen aktive opptak', [KEY.recruitment_interview_notes]: 'Intervju notater', [KEY.recruitment_priority]: 'Søkers prioritet', - [KEY.recruitment_your_priority]: 'Søknads prioritering', + [KEY.recruitment_your_priority]: 'Din prioritering av verv', [KEY.recruitment_change_priority]: 'Endre prioritet', [KEY.recruitment_recruiter_priority]: 'Prioritet', [KEY.recruitment_recruiter_status]: 'Status', @@ -729,7 +729,7 @@ export const en = prepareTranslations({ [KEY.recruitment_interview_location]: 'Interview Location', [KEY.recruitment_interview_notes]: 'Interview notes', [KEY.recruitment_priority]: 'Applicants priority', - [KEY.recruitment_your_priority]: 'Application priority', + [KEY.recruitment_your_priority]: 'Your position priority', [KEY.recruitment_change_priority]: 'Change priority', [KEY.recruitment_recruiter_priority]: 'Priority', [KEY.recruitment_recruiter_status]: 'Status', From db7bb363989a76082b6b6ac582466b030ed1b1b9 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sat, 2 Nov 2024 20:41:55 +0100 Subject: [PATCH 18/26] moved the logic for handling the applicant position prioritization to the model, out if the view --- backend/samfundet/models/recruitment.py | 204 ++++++++++-------- .../models/tests/test_recruitment.py | 128 +++++++++++ backend/samfundet/views.py | 71 ++---- 3 files changed, 269 insertions(+), 134 deletions(-) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 2df6249d9..a11bb7d05 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -289,6 +289,7 @@ class RecruitmentApplication(CustomBaseModel): Interview, on_delete=models.SET_NULL, null=True, blank=True, help_text='The interview for the application', related_name='applications' ) withdrawn = models.BooleanField(default=False, blank=True, null=True) + # TODO: Important that the following is not sent along with the rest of the object whenever a user retrieves its application recruiter_priority = models.IntegerField( choices=RecruitmentPriorityChoices.choices, default=RecruitmentPriorityChoices.NOT_SET, help_text='The priority of the application' @@ -302,125 +303,158 @@ class RecruitmentApplication(CustomBaseModel): choices=RecruitmentApplicantStates.choices, default=RecruitmentApplicantStates.NOT_SET, help_text='The state of the applicant for the recruiter' ) + # TODO: BETTER COMMENT Removed organize_priorities and update_priority methods as they're replaced by new priority handling + + REAPPLY_TOO_MANY_APPLICATIONS_ERROR = 'Can not reapply application, too many active application' + TOO_MANY_APPLICATIONS_ERROR = 'Too many applications for recruitment' + def resolve_org(self, *, return_id: bool = False) -> Organization | int: return self.recruitment.resolve_org(return_id=return_id) def resolve_gang(self, *, return_id: bool = False) -> Gang | int: return self.recruitment_position.resolve_gang(return_id=return_id) - def organize_priorities(self) -> None: - """Organizes priorites from 1 to n, so that it is sequential with no gaps""" - applications_for_user = RecruitmentApplication.objects.filter(recruitment=self.recruitment, user=self.user).order_by('applicant_priority') - for i in range(len(applications_for_user)): - correct_position = i + 1 - if applications_for_user[i].applicant_priority != correct_position: - applications_for_user[i].applicant_priority = correct_position - applications_for_user[i].save() + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Track original withdrawn status + self._original_withdrawn = self.withdrawn if self.pk else False + + def save(self, *args: tuple, **kwargs: dict) -> None: + """Handle priority management, withdrawal status, and shared interviews.""" + with transaction.atomic(): + if not self.recruitment: + self.recruitment = self.recruitment_position.recruitment + + # Track if this is a withdrawal + is_withdrawal = self.pk and self.withdrawn and not self._original_withdrawn + + if self.withdrawn: + # Clear priority when withdrawn + self.applicant_priority = None + self.recruiter_priority = RecruitmentPriorityChoices.NOT_WANTED + self.recruiter_status = RecruitmentStatusChoices.AUTOMATIC_REJECTION + elif not self.applicant_priority: + # Set initial priority for new active applications + self.applicant_priority = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() + 1 + + # Handle shared interviews + if not self.interview and self.recruitment_position.shared_interview_group: + shared_interview = ( + RecruitmentApplication.objects.filter( + user=self.user, recruitment_position__in=self.recruitment_position.shared_interview_group.positions.all() + ) + .exclude(interview=None) + .first() + ) + if shared_interview: + self.interview = shared_interview.interview + + super().save(*args, **kwargs) + + # If this was a withdrawal, reorder remaining active applications + if is_withdrawal: + self._reorder_remaining_priorities() + + def _reorder_remaining_priorities(self): + """Reorder priorities for remaining active applications after withdrawal""" + active_applications = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).order_by( + 'applicant_priority' + ) + + for index, application in enumerate(active_applications, start=1): + if application.applicant_priority != index: + application.applicant_priority = index + application.save(update_fields=['applicant_priority']) def update_priority(self, direction: int) -> None: """ - Method for moving priorites up or down, + Method for moving priorities up or down, positive direction indicates moving it to higher priority, negative direction indicates moving it to lower priority, can move n positions up or down - """ - # Use order for more simple an unified for direction - ordering = f"{'' if direction < 0 else '-' }applicant_priority" - applications_for_user = RecruitmentApplication.objects.filter(recruitment=self.recruitment, user=self.user).order_by(ordering) - direction = abs(direction) # convert to absolute - for i in range(len(applications_for_user)): - if applications_for_user[i].id == self.id: # find current - # Find index of which to switch priority with - switch = len(applications_for_user) - 1 if i + direction >= len(applications_for_user) else i + direction - new_priority = applications_for_user[switch].applicant_priority - # Move priorites down in direction - for ii in range(switch, i, -1): - applications_for_user[ii].applicant_priority = applications_for_user[ii - 1].applicant_priority - applications_for_user[ii].save() - # update priority - applications_for_user[i].applicant_priority = new_priority - applications_for_user[i].save() - break - self.organize_priorities() - REAPPLY_TOO_MANY_APPLICATIONS_ERROR = 'Can not reapply application, too many active application' - TOO_MANY_APPLICATIONS_ERROR = 'Too many applications for recruitment' + with transaction.atomic(): + # Use order for more simple and unified direction + ordering = f"{'' if direction < 0 else '-'}applicant_priority" + applications_for_user = RecruitmentApplication.objects.filter( + recruitment=self.recruitment, + user=self.user, + withdrawn=False, # Only consider active applications + ).order_by(ordering) + + direction = abs(direction) # convert to absolute + for i in range(len(applications_for_user)): + if applications_for_user[i].id == self.id: # find current + # Find index of which to switch priority with + switch = len(applications_for_user) - 1 if i + direction >= len(applications_for_user) else i + direction + new_priority = applications_for_user[switch].applicant_priority + # Move priorities down in direction + for ii in range(switch, i, -1): + applications_for_user[ii].applicant_priority = applications_for_user[ii - 1].applicant_priority + applications_for_user[ii].save(update_fields=['applicant_priority']) + # update priority + applications_for_user[i].applicant_priority = new_priority + applications_for_user[i].save(update_fields=['applicant_priority']) + break + + def update_applicant_state(self) -> None: + """Updates the applicant state based on recruiter priorities""" + applications = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment).order_by('applicant_priority') + + # Get top priority applications + top_wanted = applications.filter(recruiter_priority=RecruitmentPriorityChoices.WANTED).order_by('applicant_priority').first() + + top_reserved = applications.filter(recruiter_priority=RecruitmentPriorityChoices.RESERVE).order_by('applicant_priority').first() + + with transaction.atomic(): + for application in applications: + # Matrix indexing formula for state calculation + has_priority = 0 + if top_reserved and top_reserved.applicant_priority < application.applicant_priority: + has_priority = 1 + if top_wanted and top_wanted.applicant_priority < application.applicant_priority: + has_priority = 2 + + application.applicant_state = application.recruiter_priority + 3 * has_priority - def clean(self, *args: tuple, **kwargs: dict) -> None: + if application.recruiter_priority == RecruitmentPriorityChoices.NOT_WANTED: + application.applicant_state = RecruitmentApplicantStates.NOT_WANTED + + application.save() + + def clean(self, *args, **kwargs): + """Validate application constraints""" super().clean() errors: dict[str, list[ValidationError]] = defaultdict(list) - # If there is max applications, check if applicant have applied to not to many - # Cant use not self.pk, due to UUID generating it before save. + # Add priority validation + if not self.withdrawn and self.applicant_priority: + active_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).exclude(pk=self.pk).count() + if self.applicant_priority > active_count + 1: + errors['applicant_priority'].append(f'Priority cannot be higher than the number of active applications ({active_count + 1})') + + # Validate max applications if self.recruitment.max_applications: user_applications_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() current_application = RecruitmentApplication.objects.filter(pk=self.pk).first() + if user_applications_count >= self.recruitment.max_applications: if not current_application: - # attempts to create new application when too many applications errors['recruitment'].append(self.TOO_MANY_APPLICATIONS_ERROR) elif current_application.withdrawn and not self.withdrawn: - # If it attempts to withdraw, when to many active applications errors['recruitment'].append(self.REAPPLY_TOO_MANY_APPLICATIONS_ERROR) - raise ValidationError(errors) - def __str__(self) -> str: - return f'Application: {self.user} for {self.recruitment_position} in {self.recruitment}' - - def save(self, *args: tuple, **kwargs: dict) -> None: # noqa: C901 - """ - If the application is saved without an interview, - try to find an interview from a shared position. - """ - if not self.recruitment: - self.recruitment = self.recruitment_position.recruitment - # If the application is saved without an interview, try to find an interview from a shared position. - if not self.applicant_priority: - self.organize_priorities() - current_applications_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment).count() - # Set the applicant_priority to the number of applications + 1 (for the current application) - self.applicant_priority = current_applications_count + 1 - # If the application is saved without an interview, try to find an interview from a shared position. - if self.withdrawn: - self.recruiter_priority = RecruitmentPriorityChoices.NOT_WANTED - self.recruiter_status = RecruitmentStatusChoices.AUTOMATIC_REJECTION - if not self.interview and self.recruitment_position.shared_interview_group: - shared_interview = ( - RecruitmentApplication.objects.filter(user=self.user, recruitment_position__in=self.recruitment_position.shared_interview_group.positions.all()) - .exclude(interview=None) - .first() - ) - if shared_interview: - self.interview = shared_interview.interview - - super().save(*args, **kwargs) + if errors: + raise ValidationError(errors) + # TODO: imrove comment Utility methods remain for compatibility def get_total_interviews(self) -> int: return RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).exclude(interview=None).count() def get_total_applications(self) -> int: return RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() - def update_applicant_state(self) -> None: - applications = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment).order_by('applicant_priority') - # Get top priority - top_wanted = applications.filter(recruiter_priority=RecruitmentPriorityChoices.WANTED).order_by('applicant_priority').first() - top_reserved = applications.filter(recruiter_priority=RecruitmentPriorityChoices.RESERVE).order_by('applicant_priority').first() - with transaction.atomic(): - for application in applications: - # I hate conditionals, so instead of checking all forms of condtions - # I use memory array indexing formula (col+row_size*row) for matrixes, to index into state - has_priority = 0 - if top_reserved and top_reserved.applicant_priority < application.applicant_priority: - has_priority = 1 - if top_wanted and top_wanted.applicant_priority < application.applicant_priority: - has_priority = 2 - application.applicant_state = application.recruiter_priority + 3 * has_priority - if application.recruiter_priority == RecruitmentPriorityChoices.NOT_WANTED: - application.applicant_state = RecruitmentApplicantStates.NOT_WANTED - application.save() - class RecruitmentInterviewAvailability(CustomBaseModel): """This models all possible times for interviews for the given recruitment. diff --git a/backend/samfundet/models/tests/test_recruitment.py b/backend/samfundet/models/tests/test_recruitment.py index 392ed364b..d2960335d 100644 --- a/backend/samfundet/models/tests/test_recruitment.py +++ b/backend/samfundet/models/tests/test_recruitment.py @@ -836,6 +836,134 @@ def test_recruitment_progress_applications_multiple_new_updates_progress( assert fixture_recruitment.recruitment_progress() == 1 +@pytest.mark.django_db +def test_withdrawn_application_priority_handling( + fixture_recruitment_application: RecruitmentApplication, + fixture_recruitment_application2: RecruitmentApplication, + fixture_recruitment_position2: RecruitmentPosition, +): + """Test that priorities are properly managed when applications are withdrawn""" + # Initial state - two applications with priorities 1 and 2 + assert fixture_recruitment_application.applicant_priority == 1 + assert fixture_recruitment_application2.applicant_priority == 2 + + # When withdrawing application 1, application 2 should become priority 1 + fixture_recruitment_application.withdrawn = True + fixture_recruitment_application.save() + + fixture_recruitment_application.refresh_from_db() + fixture_recruitment_application2.refresh_from_db() + + assert fixture_recruitment_application.applicant_priority is None + assert fixture_recruitment_application2.applicant_priority == 1 + + # New application should get priority 2 + new_application = RecruitmentApplication.objects.create( + application_text='Test application text 3', + recruitment_position=fixture_recruitment_position2, + recruitment=fixture_recruitment_position2.recruitment, + user=fixture_recruitment_application.user, + ) + + assert new_application.applicant_priority == 2 + + +@pytest.mark.django_db +def test_reapplying_after_withdrawal_priority( + fixture_recruitment_application: RecruitmentApplication, fixture_recruitment_application2: RecruitmentApplication +): + """Test that reapplying after withdrawal gets correct priority""" + # Initial state + assert fixture_recruitment_application.applicant_priority == 1 + assert fixture_recruitment_application2.applicant_priority == 2 + + # Withdraw first application + fixture_recruitment_application.withdrawn = True + fixture_recruitment_application.save() + + fixture_recruitment_application2.refresh_from_db() + assert fixture_recruitment_application2.applicant_priority == 1 + + # Reapply - should get priority 2 + fixture_recruitment_application.withdrawn = False + fixture_recruitment_application.save() + + fixture_recruitment_application.refresh_from_db() + fixture_recruitment_application2.refresh_from_db() + + assert fixture_recruitment_application2.applicant_priority == 1 + assert fixture_recruitment_application.applicant_priority == 2 + + +@pytest.mark.django_db +def test_priority_constraints_with_withdrawn_applications( + fixture_recruitment_application: RecruitmentApplication, + fixture_recruitment_application2: RecruitmentApplication, + fixture_recruitment_position2: RecruitmentPosition, +): + """Test that priorities stay within bounds of active applications only""" + # Initial state + assert fixture_recruitment_application.applicant_priority == 1 + assert fixture_recruitment_application2.applicant_priority == 2 + + # Withdraw application 2 + fixture_recruitment_application2.withdrawn = True + fixture_recruitment_application2.save() + + fixture_recruitment_application.refresh_from_db() + assert fixture_recruitment_application.applicant_priority == 1 + + # Try to set priority higher than number of active applications + with pytest.raises(ValidationError): + fixture_recruitment_application.applicant_priority = 2 + fixture_recruitment_application.save() + + +@pytest.mark.django_db +def test_multiple_withdrawals_and_priorities( + fixture_recruitment: Recruitment, fixture_recruitment_position: RecruitmentPosition, fixture_recruitment_position2: RecruitmentPosition, fixture_user: User +): + """Test complex scenario with multiple withdrawals and reapplications""" + # Create three applications + apps = [] + for i in range(3): + app = RecruitmentApplication.objects.create( + application_text=f'Test application {i}', + recruitment_position=fixture_recruitment_position if i < 2 else fixture_recruitment_position2, + recruitment=fixture_recruitment, + user=fixture_user, + ) + apps.append(app) + + # Verify initial priorities + for i, app in enumerate(apps, 1): + assert app.applicant_priority == i + + # Withdraw middle application + apps[1].withdrawn = True + apps[1].save() + + # Refresh and verify priorities adjusted + for app in apps: + app.refresh_from_db() + + assert apps[0].applicant_priority == 1 + assert apps[1].applicant_priority is None + assert apps[2].applicant_priority == 2 + + # Withdraw first application + apps[0].withdrawn = True + apps[0].save() + + # Refresh and verify + for app in apps: + app.refresh_from_db() + + assert apps[0].applicant_priority is None + assert apps[1].applicant_priority is None + assert apps[2].applicant_priority == 1 + + def test_position_must_have_single_owner(fixture_recruitment_position: RecruitmentPosition, fixture_gang: Gang, fixture_gang_section: GangSection): fixture_recruitment_position.gang = fixture_gang fixture_recruitment_position.section = fixture_gang_section diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 44840fb68..45355f037 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -838,19 +838,8 @@ class RecruitmentApplicationForApplicantView(ModelViewSet): serializer_class = RecruitmentApplicationForApplicantSerializer queryset = RecruitmentApplication.objects.all() - def _rebase_priorities(self, recruitment: Recruitment, user: User) -> list[RecruitmentApplication]: - """Helper method to rebase priorities for a user's active applications""" - active_applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user=user, withdrawn=False).order_by('applicant_priority') - - # Rebase priorities to ensure they're sequential starting from 1 - for index, application in enumerate(active_applications, start=1): - if application.applicant_priority != index: - application.applicant_priority = index - application.save() - - return active_applications - def update(self, request: Request, pk: int) -> Response: + """Handle application creation and updates""" data = request.data.dict() if isinstance(request.data, QueryDict) else request.data recruitment_position = get_object_or_404(RecruitmentPosition, pk=pk) data['recruitment_position'] = recruitment_position.pk @@ -862,38 +851,28 @@ def update(self, request: Request, pk: int) -> Response: existing_application = RecruitmentApplication.objects.filter(user=request.user, recruitment_position=pk).first() if existing_application: + # Update existing application + existing_application.application_text = serializer.validated_data['application_text'] if existing_application.withdrawn: - # This is a re-application after withdrawal - highest_priority = RecruitmentApplication.objects.filter( - recruitment=recruitment_position.recruitment, user=request.user, withdrawn=False - ).count() - existing_application.withdrawn = False - existing_application.application_text = serializer.validated_data['application_text'] - existing_application.applicant_priority = highest_priority + 1 - existing_application.save() - else: - # Normal update of existing application - existing_application.application_text = serializer.validated_data['application_text'] - existing_application.save() - - # Rebase priorities and return all active applications - active_applications = self._rebase_priorities(recruitment_position.recruitment, request.user) - return Response(self.get_serializer(active_applications, many=True).data, status=status.HTTP_200_OK) - - # New application - assign next priority number - new_priority = RecruitmentApplication.objects.filter(recruitment=recruitment_position.recruitment, user=request.user, withdrawn=False).count() + 1 + existing_application.save() + status_code = status.HTTP_200_OK + else: + # Create new application + serializer.save() + status_code = status.HTTP_201_CREATED - application = serializer.save(applicant_priority=new_priority) + # Return all active applications + active_applications = RecruitmentApplication.objects.filter( + recruitment=recruitment_position.recruitment, user=request.user, withdrawn=False + ).order_by('applicant_priority') - # Rebase priorities and return all active applications - active_applications = self._rebase_priorities(recruitment_position.recruitment, request.user) - return Response(self.get_serializer(active_applications, many=True).data, status=status.HTTP_201_CREATED) + return Response(self.get_serializer(active_applications, many=True).data, status=status_code) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def list(self, request: Request) -> Response: - """Returns a list of all the applications for a user for a specified recruitment""" + """List all applications for a user in a recruitment""" recruitment_id = request.query_params.get('recruitment') user_id = request.query_params.get('user_id') @@ -902,25 +881,19 @@ def list(self, request: Request) -> Response: recruitment = get_object_or_404(Recruitment, id=recruitment_id) - if user_id: - # TODO: Add permissions - applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user_id=user_id, withdrawn=False).order_by('applicant_priority') - else: - applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user=request.user, withdrawn=False).order_by('applicant_priority') - - # Rebase priorities before returning - applications = self._rebase_priorities(recruitment, request.user if not user_id else user_id) + # Filter active applications + applications = RecruitmentApplication.objects.filter( + recruitment=recruitment, user_id=user_id if user_id else request.user.id, withdrawn=False + ).order_by('applicant_priority') serializer = self.get_serializer(applications, many=True) return Response(serializer.data) def retrieve(self, request: Request, pk: int) -> Response: - application = get_object_or_404(RecruitmentApplication, user=request.user, recruitment_position=pk, withdrawn=False) - + """Get a specific application""" user_id = request.query_params.get('user_id') - if user_id: - # TODO: Add permissions - application = RecruitmentApplication.objects.filter(recruitment_position=pk, user_id=user_id, withdrawn=False).first() + + application = get_object_or_404(RecruitmentApplication, recruitment_position=pk, user_id=user_id if user_id else request.user.id, withdrawn=False) serializer = self.get_serializer(application) return Response(serializer.data) From 4a267bec5dce8f57bcb05d2c8234d33c7dc2c469 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 00:22:59 +0100 Subject: [PATCH 19/26] refactor recruitment application withdraw and prioritization validation as well as related view. PASSES PIPELINE :D --- ...application_applicant_priority_and_more.py | 60 +++++ backend/samfundet/models/recruitment.py | 247 ++++++++++-------- backend/samfundet/views.py | 17 +- 3 files changed, 201 insertions(+), 123 deletions(-) create mode 100644 backend/samfundet/migrations/0008_alter_recruitmentapplication_applicant_priority_and_more.py diff --git a/backend/samfundet/migrations/0008_alter_recruitmentapplication_applicant_priority_and_more.py b/backend/samfundet/migrations/0008_alter_recruitmentapplication_applicant_priority_and_more.py new file mode 100644 index 000000000..81459fe14 --- /dev/null +++ b/backend/samfundet/migrations/0008_alter_recruitmentapplication_applicant_priority_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.1 on 2024-11-02 21:46 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0007_alter_infobox_color_alter_infobox_image_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='recruitmentapplication', + name='applicant_priority', + field=models.PositiveIntegerField(blank=True, help_text='Applicant priority of the position(recruitment_position) to which this application related.', null=True), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='applicant_state', + field=models.IntegerField(choices=[(0, 'Unprocessed by all above on priority'), (1, 'Highest priority, and reserve'), (2, 'Highest priority, and wanted'), (3, 'Another position has this on reserve, with higher priority'), (4, 'Another position has this on reserve, with higher priority, but you have reserved'), (5, 'Another position has this on reserve, with higher priority, but you have them as wanted'), (6, 'Another position has this on reserve, with higher priority'), (7, 'Another position has this on wanted, with higher priority, but you have reserved'), (8, 'Another position has this on wanted, with higher priority, but you have them as wanted'), (10, 'Other position has priority')], default=0, help_text="Recruiter's view of the applicant's status."), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='application_text', + field=models.TextField(help_text='Motivation text submitted by the applicant'), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='interview', + field=models.ForeignKey(blank=True, help_text='Interview associated with this application.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to='samfundet.interview'), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='recruiter_priority', + field=models.IntegerField(choices=[(0, 'Not Set'), (1, 'Reserve'), (2, 'Wanted'), (3, 'Not Wanted')], default=0, help_text="Recruiter's priority for this application."), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='recruiter_status', + field=models.IntegerField(choices=[(0, 'Not Set'), (1, 'Called and Accepted'), (2, 'Called and Rejected'), (3, 'Rejection'), (4, 'Automatic Rejection')], default=0, help_text='Current status of this application.'), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='recruitment', + field=models.ForeignKey(help_text='Recruitment to which this application is related.', on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='samfundet.recruitment'), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='recruitment_position', + field=models.ForeignKey(help_text='Position to which this application is related.', on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='samfundet.recruitmentposition'), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='user', + field=models.ForeignKey(help_text='User who submitted this application.', on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index a11bb7d05..4c43c9f2e 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -272,41 +272,63 @@ def resolve_gang(self, *, return_id: bool = False) -> Gang | int: class RecruitmentApplication(CustomBaseModel): - # UUID so that applicants cannot see recruitment info with their own id number + """Represents an application to a recruitment position by a user.""" + + # TODO: DEAL WITH THIS IMPORTANT BLUNDER where the applicant is added to AUTOMATIC_REJECTION if they withdraw + """ + Priority Reset: + + When an applicant withdraws an application, the save method clears their priority by setting applicant_priority to None. + It also sets recruiter_priority to NOT_WANTED and recruiter_status to AUTOMATIC_REJECTION. + The _reorder_remaining_priorities method then reorders the priorities of the remaining active applications. + """ + + # Unique Identifier, so that total recruitment application count cant be infered from id id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - application_text = models.TextField(help_text='Application text') - recruitment_position = models.ForeignKey( - RecruitmentPosition, on_delete=models.CASCADE, help_text='The position which is recruiting', related_name='applications' + + # Application details + application_text = models.TextField(help_text='Motivation text submitted by the applicant') + applicant_priority = models.PositiveIntegerField( + null=True, blank=True, help_text='Applicant priority of the position(recruitment_position) to which this application related.' + ) + + # Relationships + recruitment = models.ForeignKey( + Recruitment, on_delete=models.CASCADE, related_name='applications', help_text='Recruitment to which this application is related.' ) - recruitment = models.ForeignKey(Recruitment, on_delete=models.CASCADE, help_text='The recruitment that is recruiting', related_name='applications') - user = models.ForeignKey(User, on_delete=models.CASCADE, help_text='The user that is applying', related_name='applications') - applicant_priority = models.PositiveIntegerField(null=True, blank=True, help_text='The priority of the application') - created_at = models.DateTimeField(null=True, blank=True, auto_now_add=True) + recruitment_position = models.ForeignKey( + RecruitmentPosition, on_delete=models.CASCADE, related_name='applications', help_text='Position to which this application is related.' + ) - # Foreign Key because UKA and KSG have shared interviews (multiple applications share the same interview) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='applications', help_text='User who submitted this application.') + # Foreign key because UKA and KSG have shared interviews (multiple applications share the same interview) interview = models.ForeignKey( - Interview, on_delete=models.SET_NULL, null=True, blank=True, help_text='The interview for the application', related_name='applications' + Interview, on_delete=models.SET_NULL, null=True, blank=True, related_name='applications', help_text='Interview associated with this application.' ) - withdrawn = models.BooleanField(default=False, blank=True, null=True) - # TODO: Important that the following is not sent along with the rest of the object whenever a user retrieves its application + # Recruitment statuses and flags. IMPORTANT that recruiter_priority is not communicated to applicant recruiter_priority = models.IntegerField( - choices=RecruitmentPriorityChoices.choices, default=RecruitmentPriorityChoices.NOT_SET, help_text='The priority of the application' + choices=RecruitmentPriorityChoices.choices, default=RecruitmentPriorityChoices.NOT_SET, help_text="Recruiter's priority for this application." ) - recruiter_status = models.IntegerField( - choices=RecruitmentStatusChoices.choices, default=RecruitmentStatusChoices.NOT_SET, help_text='The status of the application' + choices=RecruitmentStatusChoices.choices, default=RecruitmentStatusChoices.NOT_SET, help_text='Current status of this application.' ) - applicant_state = models.IntegerField( - choices=RecruitmentApplicantStates.choices, default=RecruitmentApplicantStates.NOT_SET, help_text='The state of the applicant for the recruiter' + choices=RecruitmentApplicantStates.choices, default=RecruitmentApplicantStates.NOT_SET, help_text="Recruiter's view of the applicant's status." ) + withdrawn = models.BooleanField(default=False, blank=True, null=True) - # TODO: BETTER COMMENT Removed organize_priorities and update_priority methods as they're replaced by new priority handling + # Timestamp + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) - REAPPLY_TOO_MANY_APPLICATIONS_ERROR = 'Can not reapply application, too many active application' - TOO_MANY_APPLICATIONS_ERROR = 'Too many applications for recruitment' + # Constants + REAPPLY_TOO_MANY_APPLICATIONS_ERROR = 'Cannot reapply; too many active applications.' + TOO_MANY_APPLICATIONS_ERROR = 'Exceeds max allowed applications for recruitment.' + + def __init__(self, *args: tuple, **kwargs: dict): + super().__init__(*args, **kwargs) + self._original_withdrawn = self.withdrawn if self.pk else False def resolve_org(self, *, return_id: bool = False) -> Organization | int: return self.recruitment.resolve_org(return_id=return_id) @@ -314,49 +336,51 @@ def resolve_org(self, *, return_id: bool = False) -> Organization | int: def resolve_gang(self, *, return_id: bool = False) -> Gang | int: return self.recruitment_position.resolve_gang(return_id=return_id) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Track original withdrawn status - self._original_withdrawn = self.withdrawn if self.pk else False - def save(self, *args: tuple, **kwargs: dict) -> None: - """Handle priority management, withdrawal status, and shared interviews.""" + """Handles status updates, priorities, and interview assignment.""" with transaction.atomic(): - if not self.recruitment: - self.recruitment = self.recruitment_position.recruitment - - # Track if this is a withdrawal - is_withdrawal = self.pk and self.withdrawn and not self._original_withdrawn - - if self.withdrawn: - # Clear priority when withdrawn - self.applicant_priority = None - self.recruiter_priority = RecruitmentPriorityChoices.NOT_WANTED - self.recruiter_status = RecruitmentStatusChoices.AUTOMATIC_REJECTION - elif not self.applicant_priority: - # Set initial priority for new active applications - self.applicant_priority = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() + 1 - - # Handle shared interviews - if not self.interview and self.recruitment_position.shared_interview_group: - shared_interview = ( - RecruitmentApplication.objects.filter( - user=self.user, recruitment_position__in=self.recruitment_position.shared_interview_group.positions.all() - ) - .exclude(interview=None) - .first() - ) - if shared_interview: - self.interview = shared_interview.interview - + self._ensure_recruitment_assignment() + self._handle_withdrawal() + self._assign_interview_if_shared() super().save(*args, **kwargs) - - # If this was a withdrawal, reorder remaining active applications - if is_withdrawal: + if self._is_withdrawal(): self._reorder_remaining_priorities() - def _reorder_remaining_priorities(self): - """Reorder priorities for remaining active applications after withdrawal""" + def _ensure_recruitment_assignment(self) -> None: + if not self.recruitment: + self.recruitment = self.recruitment_position.recruitment + + def _handle_withdrawal(self) -> None: + if self.withdrawn: + self._clear_priorities() + elif not self.applicant_priority: + self.applicant_priority = self._calculate_new_priority() + + def _clear_priorities(self) -> None: + self.applicant_priority = None + self.recruiter_priority = RecruitmentPriorityChoices.NOT_WANTED + self.recruiter_status = RecruitmentStatusChoices.AUTOMATIC_REJECTION + + def _calculate_new_priority(self) -> int: + """Calculates the priority for a new active application.""" + active_app_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() + return active_app_count + 1 + + def _assign_interview_if_shared(self) -> None: + if not self.interview and self.recruitment_position.shared_interview_group: + shared_interview = ( + RecruitmentApplication.objects.filter(user=self.user, recruitment_position__in=self.recruitment_position.shared_interview_group.positions.all()) + .exclude(interview=None) + .first() + ) + if shared_interview: + self.interview = shared_interview.interview + + def _is_withdrawal(self) -> bool: + return self.pk and self.withdrawn and not self._original_withdrawn + + def _reorder_remaining_priorities(self) -> None: + """Reorders priorities among active applications after a withdrawal.""" active_applications = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).order_by( 'applicant_priority' ) @@ -367,92 +391,91 @@ def _reorder_remaining_priorities(self): application.save(update_fields=['applicant_priority']) def update_priority(self, direction: int) -> None: - """ - Method for moving priorities up or down, - positive direction indicates moving it to higher priority, - negative direction indicates moving it to lower priority, - can move n positions up or down - """ - + """Updates the priority of the application based on the direction.""" with transaction.atomic(): - # Use order for more simple and unified direction - ordering = f"{'' if direction < 0 else '-'}applicant_priority" - applications_for_user = RecruitmentApplication.objects.filter( - recruitment=self.recruitment, - user=self.user, - withdrawn=False, # Only consider active applications - ).order_by(ordering) - - direction = abs(direction) # convert to absolute - for i in range(len(applications_for_user)): - if applications_for_user[i].id == self.id: # find current - # Find index of which to switch priority with - switch = len(applications_for_user) - 1 if i + direction >= len(applications_for_user) else i + direction - new_priority = applications_for_user[switch].applicant_priority - # Move priorities down in direction - for ii in range(switch, i, -1): - applications_for_user[ii].applicant_priority = applications_for_user[ii - 1].applicant_priority - applications_for_user[ii].save(update_fields=['applicant_priority']) - # update priority - applications_for_user[i].applicant_priority = new_priority - applications_for_user[i].save(update_fields=['applicant_priority']) - break + applications_for_user = RecruitmentApplication.objects.filter(recruitment=self.recruitment, user=self.user, withdrawn=False).order_by( + f"{'' if direction < 0 else '-'}applicant_priority" + ) + self._reorder_priorities_by_direction(applications_for_user, abs(direction)) + + def _reorder_priorities_by_direction(self, applications: list[RecruitmentApplication], steps: int) -> None: + for i, app in enumerate(applications): + if app.id == self.id: + swap_index = max(0, min(len(applications) - 1, i + steps)) + self._swap_priorities(applications, i, swap_index) + break + + def _swap_priorities(self, applications: list[RecruitmentApplication], i: int, swap_index: int) -> None: + new_priority = applications[swap_index].applicant_priority + for j in range(swap_index, i, -1): + applications[j].applicant_priority = applications[j - 1].applicant_priority + applications[j].save(update_fields=['applicant_priority']) # type: ignore + applications[i].applicant_priority = new_priority + applications[i].save(update_fields=['applicant_priority']) # type: ignore def update_applicant_state(self) -> None: - """Updates the applicant state based on recruiter priorities""" + """Updates the applicant state based on recruiter priorities.""" + # Fetch all applications for this user and recruitment session applications = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment).order_by('applicant_priority') - # Get top priority applications + # Identify the top applications for WANTED and RESERVE top_wanted = applications.filter(recruiter_priority=RecruitmentPriorityChoices.WANTED).order_by('applicant_priority').first() - top_reserved = applications.filter(recruiter_priority=RecruitmentPriorityChoices.RESERVE).order_by('applicant_priority').first() with transaction.atomic(): for application in applications: - # Matrix indexing formula for state calculation + # Determine priority modifier has_priority = 0 - if top_reserved and top_reserved.applicant_priority < application.applicant_priority: + if top_reserved and application.applicant_priority > top_reserved.applicant_priority: has_priority = 1 - if top_wanted and top_wanted.applicant_priority < application.applicant_priority: + if top_wanted and application.applicant_priority > top_wanted.applicant_priority: has_priority = 2 + # Calculate applicant state based on recruiter priority and priority modifier application.applicant_state = application.recruiter_priority + 3 * has_priority + # Override for applications marked as NOT_WANTED if application.recruiter_priority == RecruitmentPriorityChoices.NOT_WANTED: application.applicant_state = RecruitmentApplicantStates.NOT_WANTED - application.save() + application.save(update_fields=['applicant_state']) - def clean(self, *args, **kwargs): - """Validate application constraints""" + def clean(self, *args: tuple, **kwargs: dict) -> None: + """Validates application constraints before saving.""" super().clean() - errors: dict[str, list[ValidationError]] = defaultdict(list) + errors: dict[str, list[str]] = defaultdict(list) - # Add priority validation if not self.withdrawn and self.applicant_priority: - active_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).exclude(pk=self.pk).count() - if self.applicant_priority > active_count + 1: - errors['applicant_priority'].append(f'Priority cannot be higher than the number of active applications ({active_count + 1})') - - # Validate max applications + self._validate_priority(errors) if self.recruitment.max_applications: - user_applications_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() - current_application = RecruitmentApplication.objects.filter(pk=self.pk).first() - - if user_applications_count >= self.recruitment.max_applications: - if not current_application: - errors['recruitment'].append(self.TOO_MANY_APPLICATIONS_ERROR) - elif current_application.withdrawn and not self.withdrawn: - errors['recruitment'].append(self.REAPPLY_TOO_MANY_APPLICATIONS_ERROR) + self._validate_application_limits(errors) if errors: raise ValidationError(errors) - # TODO: imrove comment Utility methods remain for compatibility + def _validate_priority(self, errors: dict[str, list[str]]) -> None: + active_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).exclude(pk=self.pk).count() + if self.applicant_priority > active_count + 1: + errors['applicant_priority'].append(f'Priority cannot exceed active applications ({active_count + 1}).') + + def _validate_application_limits(self, errors: dict[str, list[str]]) -> None: + user_applications_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() + current_application = RecruitmentApplication.objects.filter(pk=self.pk).first() + + if user_applications_count >= self.recruitment.max_applications: + if not current_application: + # New application attempting to exceed limit + errors['recruitment'].append(self.TOO_MANY_APPLICATIONS_ERROR) + elif current_application.withdrawn and not self.withdrawn: + # Attempting to reapply a withdrawn application when at limit + errors['recruitment'].append(self.REAPPLY_TOO_MANY_APPLICATIONS_ERROR) + def get_total_interviews(self) -> int: + """Returns the count of interviews for active applications.""" return RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).exclude(interview=None).count() def get_total_applications(self) -> int: + """Returns the count of active applications for a recruitment session.""" return RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 45355f037..7fbc66c94 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -856,18 +856,13 @@ def update(self, request: Request, pk: int) -> Response: if existing_application.withdrawn: existing_application.withdrawn = False existing_application.save() - status_code = status.HTTP_200_OK - else: - # Create new application - serializer.save() - status_code = status.HTTP_201_CREATED - - # Return all active applications - active_applications = RecruitmentApplication.objects.filter( - recruitment=recruitment_position.recruitment, user=request.user, withdrawn=False - ).order_by('applicant_priority') - return Response(self.get_serializer(active_applications, many=True).data, status=status_code) + # Get updated application for response + updated_application = RecruitmentApplication.objects.get(pk=existing_application.pk) + return Response(self.get_serializer(updated_application).data, status=status.HTTP_200_OK) + # Create new application + application = serializer.save() + return Response(self.get_serializer(application).data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) From c23f7dca92b21549b4c7621fbabe2a42380fd5a3 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 00:55:07 +0100 Subject: [PATCH 20/26] adds comments and doc strings --- backend/samfundet/models/recruitment.py | 180 ++++++++++++++++++++---- 1 file changed, 152 insertions(+), 28 deletions(-) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 4c43c9f2e..28440821c 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -272,27 +272,31 @@ def resolve_gang(self, *, return_id: bool = False) -> Gang | int: class RecruitmentApplication(CustomBaseModel): - """Represents an application to a recruitment position by a user.""" - - # TODO: DEAL WITH THIS IMPORTANT BLUNDER where the applicant is added to AUTOMATIC_REJECTION if they withdraw """ - Priority Reset: + Represents an application submitted by a user for a specific recruitment position. + + This model handles all aspects of a recruitment application including: + - Application content and priority management + - Interview assignments + - Status tracking for both recruiters and applicants + - Priority reordering when applications are withdrawn - When an applicant withdraws an application, the save method clears their priority by setting applicant_priority to None. - It also sets recruiter_priority to NOT_WANTED and recruiter_status to AUTOMATIC_REJECTION. - The _reorder_remaining_priorities method then reorders the priorities of the remaining active applications. + The model ensures that application priorities remain sequential and handles shared + interviews between certain recruitment positions (e.g., UKA and KSG positions). """ - # Unique Identifier, so that total recruitment application count cant be infered from id + # Unique Identifier to prevent inferring total application count from sequential IDs id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - # Application details + # Core application fields application_text = models.TextField(help_text='Motivation text submitted by the applicant') applicant_priority = models.PositiveIntegerField( null=True, blank=True, help_text='Applicant priority of the position(recruitment_position) to which this application related.' ) + withdrawn = models.BooleanField(default=False, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) - # Relationships + # Foreign key relationships recruitment = models.ForeignKey( Recruitment, on_delete=models.CASCADE, related_name='applications', help_text='Recruitment to which this application is related.' ) @@ -302,14 +306,17 @@ class RecruitmentApplication(CustomBaseModel): ) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='applications', help_text='User who submitted this application.') - # Foreign key because UKA and KSG have shared interviews (multiple applications share the same interview) + # Multiple applications can share the same interview (e.g., for UKA and KSG positions) interview = models.ForeignKey( Interview, on_delete=models.SET_NULL, null=True, blank=True, related_name='applications', help_text='Interview associated with this application.' ) - # Recruitment statuses and flags. IMPORTANT that recruiter_priority is not communicated to applicant + # Recruitment statuses and flags. + # IMPORTANT that recruiter_priority is not communicated to applicant recruiter_priority = models.IntegerField( - choices=RecruitmentPriorityChoices.choices, default=RecruitmentPriorityChoices.NOT_SET, help_text="Recruiter's priority for this application." + choices=RecruitmentPriorityChoices.choices, + default=RecruitmentPriorityChoices.NOT_SET, + help_text="Recruiter's priority for this application - should not be visible to applicant", ) recruiter_status = models.IntegerField( choices=RecruitmentStatusChoices.choices, default=RecruitmentStatusChoices.NOT_SET, help_text='Current status of this application.' @@ -317,27 +324,54 @@ class RecruitmentApplication(CustomBaseModel): applicant_state = models.IntegerField( choices=RecruitmentApplicantStates.choices, default=RecruitmentApplicantStates.NOT_SET, help_text="Recruiter's view of the applicant's status." ) - withdrawn = models.BooleanField(default=False, blank=True, null=True) - # Timestamp - created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) - - # Constants + # Error message constants REAPPLY_TOO_MANY_APPLICATIONS_ERROR = 'Cannot reapply; too many active applications.' TOO_MANY_APPLICATIONS_ERROR = 'Exceeds max allowed applications for recruitment.' def __init__(self, *args: tuple, **kwargs: dict): + """ + Initializes the RecruitmentApplication instance and tracks the original withdrawn state + for detecting status changes during save operations. + """ super().__init__(*args, **kwargs) + # Track original withdrawn state to detect changes self._original_withdrawn = self.withdrawn if self.pk else False def resolve_org(self, *, return_id: bool = False) -> Organization | int: + """ + Returns the organization associated with this application's recruitment. + + Args: + return_id: If True, returns the organization ID instead of the object + + Returns: + Organization or int: The organization object or its ID + """ return self.recruitment.resolve_org(return_id=return_id) def resolve_gang(self, *, return_id: bool = False) -> Gang | int: + """ + Returns the gang associated with this application's recruitment position. + + Args: + return_id: If True, returns the gang ID instead of the object + + Returns: + Gang or int: The gang object or its ID + """ return self.recruitment_position.resolve_gang(return_id=return_id) def save(self, *args: tuple, **kwargs: dict) -> None: - """Handles status updates, priorities, and interview assignment.""" + """ + Handles the complete save process for an application, including: + - Ensuring recruitment assignment + - Processing withdrawal status changes + - Managing shared interviews + - Reordering priorities after withdrawals + + All operations are performed within a transaction to maintain data integrity. + """ with transaction.atomic(): self._ensure_recruitment_assignment() self._handle_withdrawal() @@ -347,26 +381,48 @@ def save(self, *args: tuple, **kwargs: dict) -> None: self._reorder_remaining_priorities() def _ensure_recruitment_assignment(self) -> None: + """Ensures the application is linked to the correct recruitment if not already set.""" if not self.recruitment: self.recruitment = self.recruitment_position.recruitment def _handle_withdrawal(self) -> None: + """ + Manages the application state during withdrawal or new application: + - Clears priorities for withdrawn applications + - Assigns new priority for new applications + """ if self.withdrawn: self._clear_priorities() elif not self.applicant_priority: self.applicant_priority = self._calculate_new_priority() def _clear_priorities(self) -> None: + """ + Clears all priority-related fields when an application is withdrawn and + sets appropriate rejection statuses. + IMPORTANT: Rejection email logic ensures that applications + with withdrawn = true does not enter the automatic rejection email pool + """ self.applicant_priority = None self.recruiter_priority = RecruitmentPriorityChoices.NOT_WANTED self.recruiter_status = RecruitmentStatusChoices.AUTOMATIC_REJECTION def _calculate_new_priority(self) -> int: - """Calculates the priority for a new active application.""" + """ + Calculates the appropriate priority for a new active application based on + existing active applications. + + Returns: + int: The new priority number + """ active_app_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() return active_app_count + 1 def _assign_interview_if_shared(self) -> None: + """ + Attempts to assign a shared interview if the position is part of a shared + interview group and the applicant already has an interview scheduled. + """ if not self.interview and self.recruitment_position.shared_interview_group: shared_interview = ( RecruitmentApplication.objects.filter(user=self.user, recruitment_position__in=self.recruitment_position.shared_interview_group.positions.all()) @@ -377,10 +433,19 @@ def _assign_interview_if_shared(self) -> None: self.interview = shared_interview.interview def _is_withdrawal(self) -> bool: + """ + Determines if this save operation represents a new withdrawal. + + Returns: + bool: True if this is a new withdrawal, False otherwise + """ return self.pk and self.withdrawn and not self._original_withdrawn def _reorder_remaining_priorities(self) -> None: - """Reorders priorities among active applications after a withdrawal.""" + """ + Reorders priorities for remaining active applications after a withdrawal + to maintain sequential ordering without gaps. + """ active_applications = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).order_by( 'applicant_priority' ) @@ -391,7 +456,13 @@ def _reorder_remaining_priorities(self) -> None: application.save(update_fields=['applicant_priority']) def update_priority(self, direction: int) -> None: - """Updates the priority of the application based on the direction.""" + """ + Updates the priority of the application by moving it up or down in the priority list. + + Args: + direction: Positive number moves priority up, negative moves it down. + The absolute value determines how many positions to move. + """ with transaction.atomic(): applications_for_user = RecruitmentApplication.objects.filter(recruitment=self.recruitment, user=self.user, withdrawn=False).order_by( f"{'' if direction < 0 else '-'}applicant_priority" @@ -399,6 +470,13 @@ def update_priority(self, direction: int) -> None: self._reorder_priorities_by_direction(applications_for_user, abs(direction)) def _reorder_priorities_by_direction(self, applications: list[RecruitmentApplication], steps: int) -> None: + """ + Helper method that handles the actual priority reordering logic. + + Args: + applications: List of applications to reorder + steps: Number of positions to move the application + """ for i, app in enumerate(applications): if app.id == self.id: swap_index = max(0, min(len(applications) - 1, i + steps)) @@ -406,6 +484,14 @@ def _reorder_priorities_by_direction(self, applications: list[RecruitmentApplica break def _swap_priorities(self, applications: list[RecruitmentApplication], i: int, swap_index: int) -> None: + """ + Performs the priority swap operation between two applications. + + Args: + applications: List of applications being reordered + i: Index of the current application + swap_index: Index of the application to swap with + """ new_priority = applications[swap_index].applicant_priority for j in range(swap_index, i, -1): applications[j].applicant_priority = applications[j - 1].applicant_priority @@ -414,7 +500,14 @@ def _swap_priorities(self, applications: list[RecruitmentApplication], i: int, s applications[i].save(update_fields=['applicant_priority']) # type: ignore def update_applicant_state(self) -> None: - """Updates the applicant state based on recruiter priorities.""" + """ + Updates the applicant's state based on recruiter priorities and the relative + priority of their applications. This affects how the application appears to + recruiters in the system. + + The state is calculated using a matrix-like approach where the row is determined + by the relative priority position and the column by the recruiter's priority. + """ # Fetch all applications for this user and recruitment session applications = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment).order_by('applicant_priority') @@ -424,14 +517,14 @@ def update_applicant_state(self) -> None: with transaction.atomic(): for application in applications: - # Determine priority modifier + # Calculate priority modifier based on position relative to top applications has_priority = 0 if top_reserved and application.applicant_priority > top_reserved.applicant_priority: has_priority = 1 if top_wanted and application.applicant_priority > top_wanted.applicant_priority: has_priority = 2 - # Calculate applicant state based on recruiter priority and priority modifier + # Calculate state using matrix indexing formula application.applicant_state = application.recruiter_priority + 3 * has_priority # Override for applications marked as NOT_WANTED @@ -441,7 +534,14 @@ def update_applicant_state(self) -> None: application.save(update_fields=['applicant_state']) def clean(self, *args: tuple, **kwargs: dict) -> None: - """Validates application constraints before saving.""" + """ + Validates the application before saving, checking: + - Priority constraints for active applications + - Maximum application limits + + Raises: + ValidationError: If any validation constraints are violated + """ super().clean() errors: dict[str, list[str]] = defaultdict(list) @@ -454,11 +554,23 @@ def clean(self, *args: tuple, **kwargs: dict) -> None: raise ValidationError(errors) def _validate_priority(self, errors: dict[str, list[str]]) -> None: + """ + Validates that the application's priority is within allowed bounds. + + Args: + errors: Dictionary to collect validation errors + """ active_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).exclude(pk=self.pk).count() if self.applicant_priority > active_count + 1: errors['applicant_priority'].append(f'Priority cannot exceed active applications ({active_count + 1}).') def _validate_application_limits(self, errors: dict[str, list[str]]) -> None: + """ + Validates that the application doesn't exceed maximum application limits. + + Args: + errors: Dictionary to collect validation errors + """ user_applications_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() current_application = RecruitmentApplication.objects.filter(pk=self.pk).first() @@ -471,11 +583,23 @@ def _validate_application_limits(self, errors: dict[str, list[str]]) -> None: errors['recruitment'].append(self.REAPPLY_TOO_MANY_APPLICATIONS_ERROR) def get_total_interviews(self) -> int: - """Returns the count of interviews for active applications.""" + """ + Returns the total number of interviews scheduled for the user's active + applications in this recruitment. + + Returns: + int: Number of scheduled interviews + """ return RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).exclude(interview=None).count() def get_total_applications(self) -> int: - """Returns the count of active applications for a recruitment session.""" + """ + Returns the total number of active applications for the user in this + recruitment. + + Returns: + int: Number of active applications + """ return RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() From 45387c34582f7aa28b74b6f39dbeea36b75338cb Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 00:57:28 +0100 Subject: [PATCH 21/26] adds comments to update application state methode --- backend/samfundet/models/recruitment.py | 31 +++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 28440821c..b81e71a63 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -505,32 +505,49 @@ def update_applicant_state(self) -> None: priority of their applications. This affects how the application appears to recruiters in the system. - The state is calculated using a matrix-like approach where the row is determined - by the relative priority position and the column by the recruiter's priority. + The state is calculated using a matrix-like approach where: + - The row (0-2) is determined by the application's priority relative to top wanted/reserved apps + - The column (0-2) is determined by the recruiter's priority (NOT_SET, RESERVE, WANTED) + - The final state is calculated as: column + (3 * row) """ - # Fetch all applications for this user and recruitment session + # Fetch all applications for this user and recruitment session, ordered by their priority (1 is highest) applications = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment).order_by('applicant_priority') - # Identify the top applications for WANTED and RESERVE + # Find the highest priority (lowest number) applications marked as WANTED and RESERVE + # These serve as thresholds for determining if other applications should be deprioritized top_wanted = applications.filter(recruiter_priority=RecruitmentPriorityChoices.WANTED).order_by('applicant_priority').first() top_reserved = applications.filter(recruiter_priority=RecruitmentPriorityChoices.RESERVE).order_by('applicant_priority').first() with transaction.atomic(): for application in applications: - # Calculate priority modifier based on position relative to top applications + # has_priority acts as the row number (0-2) in our state matrix: + # 0 = application is above or at the same level as all top applications + # 1 = application is below a RESERVE application + # 2 = application is below a WANTED application has_priority = 0 + + # If there's a top reserved application and this application has lower priority + # (higher number) than it, mark it as below RESERVE (row 1) if top_reserved and application.applicant_priority > top_reserved.applicant_priority: has_priority = 1 + + # If there's a top wanted application and this application has lower priority + # (higher number) than it, mark it as below WANTED (row 2) if top_wanted and application.applicant_priority > top_wanted.applicant_priority: has_priority = 2 - # Calculate state using matrix indexing formula + # Calculate the final state using matrix indexing: + # - recruiter_priority (0,1,2) determines the column + # - has_priority (0,1,2) determines the row + # - multiply row by 3 (matrix width) and add column to get final state application.applicant_state = application.recruiter_priority + 3 * has_priority - # Override for applications marked as NOT_WANTED + # Special case: If recruiter marked as NOT_WANTED, override the calculated + # state to always show as NOT_WANTED regardless of priority if application.recruiter_priority == RecruitmentPriorityChoices.NOT_WANTED: application.applicant_state = RecruitmentApplicantStates.NOT_WANTED + # Save only the state field for efficiency application.save(update_fields=['applicant_state']) def clean(self, *args: tuple, **kwargs: dict) -> None: From 13ac009196525d7c4bf1c4e110bad909faef36c8 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 01:06:16 +0100 Subject: [PATCH 22/26] validates application deadline --- ...cruitmentapplication_recruiter_priority.py | 18 +++++++++++++++ backend/samfundet/models/recruitment.py | 22 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 backend/samfundet/migrations/0009_alter_recruitmentapplication_recruiter_priority.py diff --git a/backend/samfundet/migrations/0009_alter_recruitmentapplication_recruiter_priority.py b/backend/samfundet/migrations/0009_alter_recruitmentapplication_recruiter_priority.py new file mode 100644 index 000000000..a39532bfe --- /dev/null +++ b/backend/samfundet/migrations/0009_alter_recruitmentapplication_recruiter_priority.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2024-11-03 00:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0008_alter_recruitmentapplication_applicant_priority_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='recruitmentapplication', + name='recruiter_priority', + field=models.IntegerField(choices=[(0, 'Not Set'), (1, 'Reserve'), (2, 'Wanted'), (3, 'Not Wanted')], default=0, help_text="Recruiter's priority for this application - should not be visible to applicant"), + ), + ] diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index b81e71a63..46c09ba47 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -555,6 +555,7 @@ def clean(self, *args: tuple, **kwargs: dict) -> None: Validates the application before saving, checking: - Priority constraints for active applications - Maximum application limits + - Application deadline constraints Raises: ValidationError: If any validation constraints are violated @@ -562,11 +563,15 @@ def clean(self, *args: tuple, **kwargs: dict) -> None: super().clean() errors: dict[str, list[str]] = defaultdict(list) + # Don't validate withdrawn applications except for deadline if not self.withdrawn and self.applicant_priority: self._validate_priority(errors) if self.recruitment.max_applications: self._validate_application_limits(errors) + # Always validate deadline constraints + self._validate_deadline_constraints(errors) + if errors: raise ValidationError(errors) @@ -599,6 +604,23 @@ def _validate_application_limits(self, errors: dict[str, list[str]]) -> None: # Attempting to reapply a withdrawn application when at limit errors['recruitment'].append(self.REAPPLY_TOO_MANY_APPLICATIONS_ERROR) + def _validate_deadline_constraints(self, errors: dict[str, list[str]]) -> None: + """ + Validates that the application is being submitted within the allowed time window. + + Args: + errors: Dictionary to collect validation errors + """ + now = timezone.now() + + # Check if recruitment period has started + if now < self.recruitment.visible_from: + errors['recruitment'].append('Recruitment period has not started yet') + + # Check if deadline has passed + if now > self.recruitment.actual_application_deadline: + errors['recruitment'].append('Application deadline has passed') + def get_total_interviews(self) -> int: """ Returns the total number of interviews scheduled for the user's active From 843a1931e248564bd28b7c2b9e1b94c5047ee804 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 01:15:26 +0100 Subject: [PATCH 23/26] validates applicant priority deadline --- backend/samfundet/models/recruitment.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 46c09ba47..37e770096 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -463,6 +463,8 @@ def update_priority(self, direction: int) -> None: direction: Positive number moves priority up, negative moves it down. The absolute value determines how many positions to move. """ + if timezone.now() > self.recruitment.reprioritization_deadline_for_applicant: + raise ValidationError('Cannot reprioritize applications after the reprioritization deadline') with transaction.atomic(): applications_for_user = RecruitmentApplication.objects.filter(recruitment=self.recruitment, user=self.user, withdrawn=False).order_by( f"{'' if direction < 0 else '-'}applicant_priority" From 725c38cc1b82c563371a3a611d70b7bc6861ad0b Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 01:36:49 +0100 Subject: [PATCH 24/26] validates recruitment admin priority of applications --- backend/samfundet/models/recruitment.py | 21 ++++++++++++++++ backend/samfundet/views.py | 33 ++++++++++++++----------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 37e770096..bb7a12a1b 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -501,6 +501,27 @@ def _swap_priorities(self, applications: list[RecruitmentApplication], i: int, s applications[i].applicant_priority = new_priority applications[i].save(update_fields=['applicant_priority']) # type: ignore + def update_recruiter_priority(self, new_priority: int) -> None: + """ + Updates the recruiter's priority for this application. + Validates that the group reprioritization deadline hasn't passed. + + Args: + new_priority: New priority value from RecruitmentPriorityChoices + + Raises: + ValidationError: If deadline has passed + """ + + # Validate not past group reprioritization deadline + if timezone.now() > self.recruitment.reprioritization_deadline_for_groups: + raise ValidationError('Cannot change recruiter priority after the group reprioritization deadline') + + # Update priority and trigger state recalculation + self.recruiter_priority = new_priority + self.save() + self.update_applicant_state() + def update_applicant_state(self) -> None: """ Updates the applicant's state based on recruiter priorities and the relative diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 7fbc66c94..632874a38 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -5,6 +5,7 @@ import hmac import hashlib from typing import Any +from xml.dom import ValidationErr from guardian.shortcuts import get_objects_for_user @@ -929,6 +930,7 @@ def put(self, request: Request, pk: str) -> Response: return Response(serializer.data, status=status.HTTP_200_OK) +# TODO SIMPLIFY THIS class RecruitmentApplicationApplicantPriorityView(APIView): permission_classes = [IsAuthenticated] serializer_class = RecruitmentUpdateUserPrioritySerializer @@ -1044,23 +1046,26 @@ class RecruitmentApplicationForGangUpdateStateView(APIView): def put(self, request: Request, pk: int) -> Response: application = get_object_or_404(RecruitmentApplication, pk=pk) - # TODO add check if user has permission to update for GANG update_serializer = self.serializer_class(data=request.data) if update_serializer.is_valid(): - # Should return update list of applications on correct - if 'recruiter_priority' in update_serializer.data: - application.recruiter_priority = update_serializer.data['recruiter_priority'] - if 'recruiter_status' in update_serializer.data: - application.recruiter_status = update_serializer.data['recruiter_status'] - application.save() - applications = RecruitmentApplication.objects.filter( - recruitment_position__gang=application.recruitment_position.gang, - recruitment=application.recruitment, - ) - application.update_applicant_state() - serializer = RecruitmentApplicationForGangSerializer(applications, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + try: + # Should return update list of applications on correct + if 'recruiter_priority' in update_serializer.data: + application.update_recruiter_priority(update_serializer.data['recruiter_priority']) + if 'recruiter_status' in update_serializer.data: + application.recruiter_status = update_serializer.data['recruiter_status'] + application.save() + + applications = RecruitmentApplication.objects.filter( + recruitment_position__gang=application.recruitment_position.gang, + recruitment=application.recruitment, + ) + serializer = RecruitmentApplicationForGangSerializer(applications, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except ValidationErr as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(update_serializer.errors, status=status.HTTP_400_BAD_REQUEST) From 3672d88c8c5f389cb451c3f63af1192529da35b6 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 02:16:46 +0100 Subject: [PATCH 25/26] resolves migration file conflict, destructivly --- ...ecruitmentapplication_recruiter_priority.py | 18 ------------------ ...application_applicant_priority_and_more.py} | 6 +++--- 2 files changed, 3 insertions(+), 21 deletions(-) delete mode 100644 backend/samfundet/migrations/0009_alter_recruitmentapplication_recruiter_priority.py rename backend/samfundet/migrations/{0008_alter_recruitmentapplication_applicant_priority_and_more.py => 0011_alter_recruitmentapplication_applicant_priority_and_more.py} (94%) diff --git a/backend/samfundet/migrations/0009_alter_recruitmentapplication_recruiter_priority.py b/backend/samfundet/migrations/0009_alter_recruitmentapplication_recruiter_priority.py deleted file mode 100644 index a39532bfe..000000000 --- a/backend/samfundet/migrations/0009_alter_recruitmentapplication_recruiter_priority.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.1 on 2024-11-03 00:03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('samfundet', '0008_alter_recruitmentapplication_applicant_priority_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='recruitmentapplication', - name='recruiter_priority', - field=models.IntegerField(choices=[(0, 'Not Set'), (1, 'Reserve'), (2, 'Wanted'), (3, 'Not Wanted')], default=0, help_text="Recruiter's priority for this application - should not be visible to applicant"), - ), - ] diff --git a/backend/samfundet/migrations/0008_alter_recruitmentapplication_applicant_priority_and_more.py b/backend/samfundet/migrations/0011_alter_recruitmentapplication_applicant_priority_and_more.py similarity index 94% rename from backend/samfundet/migrations/0008_alter_recruitmentapplication_applicant_priority_and_more.py rename to backend/samfundet/migrations/0011_alter_recruitmentapplication_applicant_priority_and_more.py index 81459fe14..21adcc211 100644 --- a/backend/samfundet/migrations/0008_alter_recruitmentapplication_applicant_priority_and_more.py +++ b/backend/samfundet/migrations/0011_alter_recruitmentapplication_applicant_priority_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.1 on 2024-11-02 21:46 +# Generated by Django 5.1.1 on 2024-11-03 01:16 import django.db.models.deletion from django.conf import settings @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ - ('samfundet', '0007_alter_infobox_color_alter_infobox_image_and_more'), + ('samfundet', '0010_recruitment_promo_media'), ] operations = [ @@ -35,7 +35,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='recruitmentapplication', name='recruiter_priority', - field=models.IntegerField(choices=[(0, 'Not Set'), (1, 'Reserve'), (2, 'Wanted'), (3, 'Not Wanted')], default=0, help_text="Recruiter's priority for this application."), + field=models.IntegerField(choices=[(0, 'Not Set'), (1, 'Reserve'), (2, 'Wanted'), (3, 'Not Wanted')], default=0, help_text="Recruiter's priority for this application - should not be visible to applicant"), ), migrations.AlterField( model_name='recruitmentapplication', From 5709118c4f3f0597c88848f6830e98c85ca7cfc6 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 02:22:15 +0100 Subject: [PATCH 26/26] fixed id error from after resolving merge conflict --- .../components/ActiveApplications/ActiveApplications.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx index 179feae4a..a734c301b 100644 --- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx +++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/components/ActiveApplications/ActiveApplications.tsx @@ -144,8 +144,8 @@ export function ActiveApplications({ recruitmentId, queryKey }: ActiveApplicatio url={reverse({ pattern: ROUTES.frontend.recruitment_application, urlParams: { - positionID: application.recruitment_position.id, - gangID: application.recruitment_position.gang.id, + positionId: application.recruitment_position.id, + gangId: application.recruitment_position.gang.id, }, })} className={styles.positionName}