Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/root/utils/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
6 changes: 6 additions & 0 deletions backend/samfundet/models/model_choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
2 changes: 1 addition & 1 deletion backend/samfundet/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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/',
Expand Down
37 changes: 35 additions & 2 deletions backend/samfundet/view/recruitment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 #
Expand All @@ -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 #
# =============================== #
Expand Down
12 changes: 1 addition & 11 deletions backend/samfundet/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]

Expand Down
30 changes: 20 additions & 10 deletions frontend/src/Components/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,7 +25,6 @@ export function Navbar() {
const { isMobileNavigation, setIsMobileNavigation } = useGlobalContext();
const { t, i18n } = useTranslation();
const { user, setUser } = useAuthContext();
const [activeRecruitments, setActiveRecruitments] = useState<RecruitmentDto[]>();
const navigate = useNavigate();
const isDesktop = useDesktop();
const [cookies, setCookie, removeCookie] = useCookies();
Expand All @@ -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 = (
Expand Down Expand Up @@ -133,7 +143,7 @@ export function Navbar() {
<NavbarItem
setExpandedDropdown={setExpandedDropdown}
expandedDropdown={expandedDropdown}
route={ROUTES.frontend.recruitment}
route={navigateToSamfRecruitment()}
label={t(KEY.common_volunteer)}
labelClassName={showActiveRecruitments ? styles.active_recruitment : ''}
/>
Expand Down
29 changes: 12 additions & 17 deletions frontend/src/Pages/RecruitmentPage/RecruitmentPage.tsx
Original file line number Diff line number Diff line change
@@ -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<RecruitmentDto[]>([]);
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 (
<Page>
<div className={styles.container}>
<div className={styles.cardContainer}>
{loading ? (
{isLoading ? (
<SamfundetLogoSpinner />
) : recruitments && recruitments.length > 0 ? (
recruitments.map((recruitment: RecruitmentDto) => (
) : activeSamfRecruitments && activeSamfRecruitments.length > 0 ? (
activeSamfRecruitments.map((recruitment: RecruitmentDto) => (
<RecruitmentCard recruitment={recruitment} key={recruitment.id} />
))
) : (
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -974,13 +974,20 @@ export async function getActiveRecruitmentPositions(): Promise<AxiosResponse<Rec
return response;
}

export async function getActiveRecruitments(): Promise<AxiosResponse<RecruitmentDto[]>> {
const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__active_recruitments;
export async function getAllActiveRecruitments(): Promise<AxiosResponse<RecruitmentDto[]>> {
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<RecruitmentDto[]> {
const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__active_recruitment_get_active_samf_recruitments;
const response = await axios.get<RecruitmentDto[]>(url, { withCredentials: true });

return response.data;
}

export async function getApplicantsWithoutInterviews(
recruitmentId: string,
gangId: string | null = null,
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
35 changes: 33 additions & 2 deletions frontend/src/router/loaders.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,6 +35,28 @@ export async function roleLoader({ params }: LoaderFunctionArgs): Promise<RoleLo
return { role: await getRole(Number.parseInt(params.roleId as string)) };
}

export async function samfRecruitmentLoader() {
try {
const activeSamfRecruitments = await getActiveSamfRecruitments();
Comment on lines +39 to +40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tanstack?

Copy link
Contributor Author

@Snorre98 Snorre98 Apr 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Det er ikke tsx, altså, kan ikke kjøre en hook her


// Check if there's only one recruitment and redirect if needed
if (activeSamfRecruitments?.length === 1) {
return redirect(
reverse({
pattern: ROUTES.frontend.recruitment_application_overview,
urlParams: { recruitmentId: activeSamfRecruitments[0].id },
}),
);
}

// Otherwise, return the recruitments data
return { activeSamfRecruitments };
} catch (error) {
console.error('Error fetching recruitments:', error);
return { activeSamfRecruitments: [] };
}
}

export async function recruitmentLoader({ params }: LoaderFunctionArgs): Promise<RecruitmentLoader> {
return { recruitment: (await getRecruitment(params.recruitmentId as string)).data };
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/router/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import {
recruitmentGangPositionLoader,
recruitmentLoader,
roleLoader,
samfRecruitmentLoader,
separatePositionLoader,
} from '~/router/loaders';
import { dbT, lowerCapitalize } from '~/utils';
Expand Down Expand Up @@ -125,7 +126,7 @@ export const router = createBrowserRouter(
<Route path={ROUTES.frontend.contact} element={<div />} />
<Route path={ROUTES.frontend.luka} element={<div />} />
{/* Recruitment */}
<Route path={ROUTES.frontend.recruitment} element={<RecruitmentPage />} />
<Route path={ROUTES.frontend.recruitment} loader={samfRecruitmentLoader} element={<RecruitmentPage />} />
</Route>
</Route>
{/* Specific recruitment */}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/routes/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
Expand Down Expand Up @@ -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/',
Expand Down