diff --git a/backend/root/utils/routes.py b/backend/root/utils/routes.py index 15d8fa72a..92362b545 100644 --- a/backend/root/utils/routes.py +++ b/backend/root/utils/routes.py @@ -544,6 +544,9 @@ samfundet__recruitment_list = 'samfundet:recruitment-list' samfundet__recruitment_detail = 'samfundet:recruitment-detail' samfundet__recruitment_gangs = 'samfundet:recruitment-gangs' +samfundet__active_recruitment_list = 'samfundet:active_recruitment-list' +samfundet__active_recruitment_get_active_samf_recruitments = 'samfundet:active_recruitment-get-active-samf-recruitments' +samfundet__active_recruitment_detail = 'samfundet:active_recruitment-detail' samfundet__recruitment_for_recruiter_list = 'samfundet:recruitment_for_recruiter-list' samfundet__recruitment_for_recruiter_detail = 'samfundet:recruitment_for_recruiter-detail' samfundet__recruitment_stats_list = 'samfundet:recruitment_stats-list' @@ -586,7 +589,6 @@ samfundet__gangsorganized = 'samfundet:gangsorganized' samfundet__check_reservation = 'samfundet:check_reservation' samfundet__reservation_create = 'samfundet:reservation-create' -samfundet__active_recruitments = 'samfundet:active_recruitments' samfundet__recruitment_positions = 'samfundet:recruitment_positions' samfundet__recruitment_show_unprocessed_applicants = 'samfundet:recruitment_show_unprocessed_applicants' samfundet__recruitment_positions_gang_for_applicants = 'samfundet:recruitment_positions_gang_for_applicants' diff --git a/backend/samfundet/models/model_choices.py b/backend/samfundet/models/model_choices.py index 4475599de..15a10ad7f 100644 --- a/backend/samfundet/models/model_choices.py +++ b/backend/samfundet/models/model_choices.py @@ -101,3 +101,9 @@ class RecruitmentApplicantStates(models.IntegerChoices): WANTED_ELSEWHERE_RESERVE_HERE = 7, _('Wanted for another position, and reserve here.') WANTED_ELSEWHERE_WANTED_HERE = 8, _('Wanted for another position and wanted here.') NOT_WANTED = 10, _('The applicant is not wanted for this position.') + + +class OrganizationNames(models.TextChoices): + SAMFUNDET = 'Samfundet', _('Samfundet') + UKA = 'UKA', _('UKA') + ISFIT = 'ISFiT', _('ISFiT') diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index 359db6132..30ec61c72 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -47,6 +47,7 @@ ########## Recruitment ########## router.register('recruitment', recruitment_views.RecruitmentView, 'recruitment') +router.register('active-recruitment', recruitment_views.ActiveRecruitmentsView, 'active_recruitment') router.register('recruitment-for-recruiter', recruitment_views.RecruitmentForRecruiterView, 'recruitment_for_recruiter') router.register('recruitment-stats', views.RecruitmentStatisticsView, 'recruitment_stats') router.register('recruitment-separateposition', views.RecruitmentSeparatePositionView, 'recruitment_separateposition') @@ -88,7 +89,6 @@ path('check-reservation/', samfundet.view.sulten_views.ReservationCheckAvailabilityView.as_view(), name='check_reservation'), path('reservations/', samfundet.view.sulten_views.ReservationCreateView.as_view(), name='reservation-create'), ########## Recruitment ########## - path('active-recruitments/', views.ActiveRecruitmentsView.as_view(), name='active_recruitments'), path('recruitment-positions/', views.RecruitmentPositionsPerRecruitmentView.as_view(), name='recruitment_positions'), path( 'recruitment-show-unprocessed-applicants/', diff --git a/backend/samfundet/view/recruitment_views.py b/backend/samfundet/view/recruitment_views.py index bdd3d7eb0..b3b60548a 100644 --- a/backend/samfundet/view/recruitment_views.py +++ b/backend/samfundet/view/recruitment_views.py @@ -7,10 +7,11 @@ from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated, DjangoModelPermissionsOrAnonReadOnly +from django.utils import timezone from django.db.models import QuerySet from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator @@ -20,8 +21,9 @@ from root.custom_classes.permission_classes import RoleProtectedObjectPermissions, filter_queryset_by_permissions from samfundet.serializers import RecruitmentSerializer, RecruitmentGangSerializer, RecruitmentForRecruiterSerializer, RecruitmentApplicationForGangSerializer -from samfundet.models.general import Gang +from samfundet.models.general import Gang, Organization from samfundet.models.recruitment import Recruitment, RecruitmentApplication +from samfundet.models.model_choices import OrganizationNames # =============================== # # Public views # @@ -46,6 +48,37 @@ def gangs(self, request: Request, **kwargs: Any) -> Response: return Response(serializer.data) +@method_decorator(ensure_csrf_cookie, 'dispatch') +class ActiveRecruitmentsView(ReadOnlyModelViewSet): + permission_classes = [DjangoModelPermissionsOrAnonReadOnly] + serializer_class = RecruitmentSerializer + queryset = Recruitment.objects.all() + + def get_queryset(self) -> QuerySet[Recruitment]: + """Default queryset to show only active recruitments""" + now = timezone.now() + return Recruitment.objects.filter( + visible_from__lte=now, # __lte: less than or equal to (Django lookup type) + actual_application_deadline__gte=now, # __gte: greater than or equal to (Django lookup type) + ) + + @action(detail=False, methods=['get'], url_path='samfundet') + def get_active_samf_recruitments(self, request: Request, **kwargs: Any) -> Response: + samfundet_org = Organization.objects.get(name=OrganizationNames.SAMFUNDET) + + if not samfundet_org: + return Response({'message': f'No active recruitment for {OrganizationNames.SAMFUNDET}'}, status=status.HTTP_404_NOT_FOUND) + + # Get active recruitments for Samfundet, using the overriden get_queryset method + active_samfundet_recruitments = self.get_queryset().filter(organization=samfundet_org) + + if not active_samfundet_recruitments: + return Response({'message': 'No active recruitment for Samfundet'}, status=status.HTTP_404_NOT_FOUND) + + serializer = self.get_serializer(active_samfundet_recruitments, many=True) + return Response(serializer.data) + + # =============================== # # Auth protected views # # =============================== # diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 9af97c636..f3f40dec4 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -18,7 +18,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from rest_framework.exceptions import PermissionDenied -from rest_framework.permissions import AllowAny, IsAuthenticated, DjangoModelPermissions, DjangoModelPermissionsOrAnonReadOnly +from rest_framework.permissions import AllowAny, IsAuthenticated, DjangoModelPermissions from django.conf import settings from django.http import QueryDict, HttpResponse @@ -581,16 +581,6 @@ def get_queryset(self) -> Response: return RecruitmentPosition.objects.filter(recruitment__visible_from__lte=timezone.now(), recruitment__actual_application_deadline__gte=timezone.now()) -class ActiveRecruitmentsView(ListAPIView): - permission_classes = [DjangoModelPermissionsOrAnonReadOnly] - serializer_class = RecruitmentSerializer - - def get_queryset(self) -> Response: - """Returns all active recruitments""" - # TODO Use is not completed instead of actual_application_deadline__gte - return Recruitment.objects.filter(visible_from__lte=timezone.now(), actual_application_deadline__gte=timezone.now()) - - class RecruitmentInterviewGroupView(APIView): permission_classes = [IsAuthenticated] diff --git a/frontend/src/Components/Navbar/Navbar.tsx b/frontend/src/Components/Navbar/Navbar.tsx index ee9360a81..444a72bee 100644 --- a/frontend/src/Components/Navbar/Navbar.tsx +++ b/frontend/src/Components/Navbar/Navbar.tsx @@ -1,18 +1,20 @@ import { Icon } from '@iconify/react'; +import { useQuery } from '@tanstack/react-query'; import { default as classNames } from 'classnames'; import { useEffect, useState } from 'react'; import { useCookies } from 'react-cookie'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router'; import { Button, Link, ThemeSwitch } from '~/Components'; -import { getActiveRecruitments, logout, stopImpersonatingUser } from '~/api'; +import { getActiveSamfRecruitments, logout, stopImpersonatingUser } from '~/api'; import { logoWhite } from '~/assets'; import { useAuthContext } from '~/context/AuthContext'; import { useGlobalContext } from '~/context/GlobalContextProvider'; -import type { RecruitmentDto } from '~/dto'; import { useDesktop, useScrollY } from '~/hooks'; import { STATUS } from '~/http_status_codes'; import { KEY } from '~/i18n/constants'; +import { reverse } from '~/named-urls'; +import { recruitmentKeys } from '~/queryKeys'; import { ROUTES } from '~/routes'; import styles from './Navbar.module.scss'; import { HamburgerMenu, LanguageButton, NavbarItem } from './components'; @@ -23,7 +25,6 @@ export function Navbar() { const { isMobileNavigation, setIsMobileNavigation } = useGlobalContext(); const { t, i18n } = useTranslation(); const { user, setUser } = useAuthContext(); - const [activeRecruitments, setActiveRecruitments] = useState(); const navigate = useNavigate(); const isDesktop = useDesktop(); const [cookies, setCookie, removeCookie] = useCookies(); @@ -50,13 +51,22 @@ export function Navbar() { } }, [isMobileNavigation, isDesktop]); - useEffect(() => { - getActiveRecruitments().then((response) => { - setActiveRecruitments(response.data); - }); - }, []); + const { data: activeSamfRecruitments } = useQuery({ + queryKey: recruitmentKeys.all, + queryFn: getActiveSamfRecruitments, + }); + + const showActiveRecruitments = activeSamfRecruitments !== undefined && activeSamfRecruitments?.length > 0; - const showActiveRecruitments = activeRecruitments !== undefined && activeRecruitments?.length > 0; + const navigateToSamfRecruitment = () => { + if (activeSamfRecruitments?.length === 1) { + return reverse({ + pattern: ROUTES.frontend.organization_recruitment, + urlParams: { recruitmentId: activeSamfRecruitments[0].id }, + }); + } + return ROUTES.frontend.recruitment; + }; // Return profile button for navbar if logged in. const mobileProfileButton = ( @@ -133,7 +143,7 @@ export function Navbar() { diff --git a/frontend/src/Pages/RecruitmentPage/RecruitmentPage.tsx b/frontend/src/Pages/RecruitmentPage/RecruitmentPage.tsx index 27673b99f..e9469197f 100644 --- a/frontend/src/Pages/RecruitmentPage/RecruitmentPage.tsx +++ b/frontend/src/Pages/RecruitmentPage/RecruitmentPage.tsx @@ -1,38 +1,33 @@ -import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Page, SamfundetLogoSpinner } from '~/Components'; -import { getActiveRecruitments } from '~/api'; +import { getActiveSamfRecruitments } from '~/api'; import type { RecruitmentDto } from '~/dto'; import { useTitle } from '~/hooks'; import { KEY } from '~/i18n/constants'; +import { recruitmentKeys } from '~/queryKeys'; import { NoPositions, RecruitmentCard } from './Components'; import styles from './RecruitmentPage.module.scss'; export function RecruitmentPage() { - const [recruitments, setRecruitments] = useState([]); - const [loading, setLoading] = useState(true); const { t } = useTranslation(); useTitle(t(KEY.common_recruitment)); - useEffect(() => { - getActiveRecruitments() - .then((response) => { - setRecruitments(response.data); - }) - .catch((error) => { - console.log('Error fetching data:', error); - }); - setLoading(false); - }, []); + const { data: activeSamfRecruitments, isLoading } = useQuery({ + /* Important! Navbar navigates to this page if there are multiple active Samf recruitments! */ + /* Take it into consideration if recruitments for all organizations should be fetched here in the future */ + queryKey: recruitmentKeys.all, + queryFn: getActiveSamfRecruitments, + }); return (
- {loading ? ( + {isLoading ? ( - ) : recruitments && recruitments.length > 0 ? ( - recruitments.map((recruitment: RecruitmentDto) => ( + ) : activeSamfRecruitments && activeSamfRecruitments.length > 0 ? ( + activeSamfRecruitments.map((recruitment: RecruitmentDto) => ( )) ) : ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 21f8257e6..c66819c10 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -974,13 +974,20 @@ export async function getActiveRecruitmentPositions(): Promise> { - const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__active_recruitments; +export async function getAllActiveRecruitments(): Promise> { + const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__active_recruitment_list; const response = await axios.get(url, { withCredentials: true }); return response; } +export async function getActiveSamfRecruitments(): Promise { + const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__active_recruitment_get_active_samf_recruitments; + const response = await axios.get(url, { withCredentials: true }); + + return response.data; +} + export async function getApplicantsWithoutInterviews( recruitmentId: string, gangId: string | null = null, diff --git a/frontend/src/queryKeys.ts b/frontend/src/queryKeys.ts index 0f6b60575..313f81204 100644 --- a/frontend/src/queryKeys.ts +++ b/frontend/src/queryKeys.ts @@ -30,3 +30,11 @@ export const eventKeys = { details: () => [...eventKeys.all, 'detail'] as const, detail: (id: number) => [...eventKeys.details(), id] as const, }; + +export const recruitmentKeys = { + all: ['recruitments'] as const, + lists: () => [...recruitmentKeys.all, 'list'] as const, + list: (filters: unknown[]) => [...recruitmentKeys.lists(), { filters }] as const, + details: () => [...recruitmentKeys.all, 'detail'] as const, + detail: (id: number) => [...recruitmentKeys.details(), id] as const, +}; diff --git a/frontend/src/router/loaders.ts b/frontend/src/router/loaders.ts index eb088c9b2..005ec4636 100644 --- a/frontend/src/router/loaders.ts +++ b/frontend/src/router/loaders.ts @@ -1,6 +1,15 @@ -import type { LoaderFunctionArgs } from 'react-router'; -import { getGang, getRecruitment, getRecruitmentPosition, getRecruitmentSeparatePosition, getRole } from '~/api'; +import { type LoaderFunctionArgs, redirect } from 'react-router'; +import { + getActiveSamfRecruitments, + getGang, + getRecruitment, + getRecruitmentPosition, + getRecruitmentSeparatePosition, + getRole, +} from '~/api'; import type { GangDto, RecruitmentDto, RecruitmentPositionDto, RecruitmentSeparatePositionDto, RoleDto } from '~/dto'; +import { reverse } from '~/named-urls'; +import { ROUTES } from '~/routes'; export type RecruitmentLoader = { recruitment: RecruitmentDto | undefined; @@ -26,6 +35,28 @@ export async function roleLoader({ params }: LoaderFunctionArgs): Promise { return { recruitment: (await getRecruitment(params.recruitmentId as string)).data }; } diff --git a/frontend/src/router/router.tsx b/frontend/src/router/router.tsx index aae6eb29c..ba690b75b 100644 --- a/frontend/src/router/router.tsx +++ b/frontend/src/router/router.tsx @@ -91,6 +91,7 @@ import { recruitmentGangPositionLoader, recruitmentLoader, roleLoader, + samfRecruitmentLoader, separatePositionLoader, } from '~/router/loaders'; import { dbT, lowerCapitalize } from '~/utils'; @@ -125,7 +126,7 @@ export const router = createBrowserRouter( } /> } /> {/* Recruitment */} - } /> + } /> {/* Specific recruitment */} diff --git a/frontend/src/routes/backend.ts b/frontend/src/routes/backend.ts index ddc8e84af..a3c89e189 100644 --- a/frontend/src/routes/backend.ts +++ b/frontend/src/routes/backend.ts @@ -543,6 +543,9 @@ export const ROUTES_BACKEND = { samfundet__recruitment_list: '/api/recruitment/', samfundet__recruitment_detail: '/api/recruitment/:pk/', samfundet__recruitment_gangs: '/api/recruitment/:pk/gangs/', + samfundet__active_recruitment_list: '/api/active-recruitment/', + samfundet__active_recruitment_get_active_samf_recruitments: '/api/active-recruitment/samfundet/', + samfundet__active_recruitment_detail: '/api/active-recruitment/:pk/', samfundet__recruitment_for_recruiter_list: '/api/recruitment-for-recruiter/', samfundet__recruitment_for_recruiter_detail: '/api/recruitment-for-recruiter/:pk/', samfundet__recruitment_stats_list: '/api/recruitment-stats/', @@ -585,7 +588,6 @@ export const ROUTES_BACKEND = { samfundet__gangsorganized: '/gangtypes/:organization/', samfundet__check_reservation: '/check-reservation/', samfundet__reservation_create: '/reservations/', - samfundet__active_recruitments: '/active-recruitments/', samfundet__recruitment_positions: '/recruitment-positions/', samfundet__recruitment_show_unprocessed_applicants: '/recruitment-show-unprocessed-applicants/', samfundet__recruitment_positions_gang_for_applicants: '/recruitment-positions-gang-for-applicant/',