diff --git a/.vscode/extensions.json b/.vscode/extensions.json index c1843a2c0..d93c29e5e 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -14,6 +14,7 @@ "yzhang.markdown-all-in-one", "stylelint.vscode-stylelint", "ms-python.mypy-type-checker", - "visualstudioexptteam.vscodeintellicode" + "visualstudioexptteam.vscodeintellicode", + "ms-vscode-remote.vscode-remote-extensionpack" ] } diff --git a/README.md b/README.md index e24335d94..949710e1c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,54 @@ Samfundet4 is the latest and greatest iteration of samfundet.no. It's built using Django and React. -## Documentation -Looking for install guides and technical documentation? Go to the [Documentation Overview](./docs/README.md)! +## Documentation Overview + +> [!TIP] +> If you're new, start by going through the [Introduction to Samfundet4](./docs/introduction.md) guide. + +### Frontend + +- [Creating react components (conventions)](./docs/technical/frontend/components.md) +- [Forms and schemas](./docs/technical/frontend/forms.md) + - [*Deprecated: SamfForm*](./docs/technical/frontend/samfform.md) +- [Cypress Setup Documentation](./docs/technical/frontend/cypress.md) +- [Data fetching and State management](./docs/technical/frontend/data-fetching.md) + +### Backend + +- [🌐 API documentation](./docs/api-docs.md) +- [Billig (payment system)](./docs/technical/backend/billig.md) +- [Seed scripts](./docs/technical/backend/seed.md) +- [Role system](./docs/technical/backend/rolesystem.md) + +### Other + +- [Automatic Interview Scheduling](./docs/intervew-scheduling.md) + +### Workflow + +- [Work Methodology](./docs/work-methodology.md) + - How to contribute to the project +- [Useful Commands](./docs/useful-commands.md) +- [Useful Docker aliases](./docs/docker-project-specific-commands.md) +- [Common error messages](./docs/common-errors.md) + +### Pipelines & Deployment + +- [Pipeline (mypy, Biome, tsc, ...)](./docs/technical/pipeline.md) + +### Install + +- Linux: [Docker](./docs/install/linux-docker.md) – [Native](./docs/install/linux-native.md) +- MacOS: [Docker](./docs/install/mac-docker.md) – [Native](./docs/install/mac-native.md) +- Windows: [Docker](./docs/install/windows-docker.md) – [WSL](./docs/install/windows-wsl.md) +- [Install script](./docs/install/install-script.md) +- [Post-install instructions](./docs/install/post-install.md) + +### Editor configuration + +* [JetBrains (WebStorm, PyCharm, etc...)](./docs/editors/jetbrains.md) +* [VS Code](./docs/editors/vscode.md) +* [Vim/Neovim](./docs/editors/vim.md) +* [Emacs](./docs/editors/emacs.md) diff --git a/backend/root/utils/routes.py b/backend/root/utils/routes.py index 2d1132ab8..30079e0fb 100644 --- a/backend/root/utils/routes.py +++ b/backend/root/utils/routes.py @@ -565,6 +565,7 @@ samfundet__interview_list = 'samfundet:interview-list' samfundet__interview_detail = 'samfundet:interview-detail' samfundet__api_root = 'samfundet:api-root' +samfundet__api_root = 'samfundet:api-root' samfundet__schema = 'samfundet:schema' samfundet__swagger_ui = 'samfundet:swagger_ui' samfundet__redoc = 'samfundet:redoc' @@ -608,5 +609,6 @@ samfundet__recruitment_availability = 'samfundet:recruitment_availability' samfundet__feedback = 'samfundet:feedback' samfundet__purchase_feedback = 'samfundet:purchase_feedback' +samfundet__gang_application_stats = 'samfundet:gang-application-stats' static__path = '' media__path = '' diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index 3761d75c3..0fab9f6d5 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -145,4 +145,5 @@ path('recruitment//availability/', views.RecruitmentAvailabilityView.as_view(), name='recruitment_availability'), path('feedback/', views.UserFeedbackView.as_view(), name='feedback'), path('purchase-feedback/', views.PurchaseFeedbackView.as_view(), name='purchase_feedback'), + path('recruitment//gang//stats/', views.GangApplicationCountView.as_view(), name='gang-application-stats'), ] diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index 6755ad78d..6964f4003 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -42,6 +42,15 @@ def event_query(*, query: QueryDict, events: QuerySet[Event] = None) -> QuerySet return events +def user_query(*, query: QueryDict, users: QuerySet[User] = None) -> QuerySet[User]: + if not users: + users = User.objects.all() + search = query.get('search', None) + if search: + users = users.filter(Q(username__icontains=search) | Q(first_name__icontains=search) | Q(last_name__icontains=search)) + return users + + def generate_timeslots(start_time: datetime.time, end_time: datetime.time, interval_minutes: int) -> list[str]: # Convert from datetime.time objects to datetime.datetime start_datetime = datetime.datetime.combine(datetime.datetime.today(), start_time) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index e4099a47c..a566688b0 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -39,7 +39,7 @@ REQUESTED_IMPERSONATE_USER, ) -from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request +from .utils import user_query, event_query, generate_timeslots, get_occupied_timeslots_from_request from .homepage import homepage from .models.role import Role, UserOrgRole, UserGangRole, UserGangSectionRole from .serializers import ( @@ -137,6 +137,7 @@ Recruitment, InterviewRoom, OccupiedTimeslot, + RecruitmentGangStat, RecruitmentPosition, RecruitmentStatistics, RecruitmentApplication, @@ -492,6 +493,10 @@ class AllUsersView(ListAPIView): serializer_class = UserSerializer queryset = User.objects.all() + def get(self, request: Request) -> Response: + users = user_query(query=request.query_params) + return Response(data=UserSerializer(users, many=True).data) + class ImpersonateView(APIView): permission_classes = [IsAuthenticated] # TODO: Permission check. @@ -1362,3 +1367,21 @@ def post(self, request: Request) -> Response: form=purchase_model, ) return Response(status=status.HTTP_201_CREATED, data={'message': 'Feedback submitted successfully!'}) + + +class GangApplicationCountView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request: Request, recruitment_id: int, gang_id: int) -> Response: + # Get total applications from RecruitmentGangStat + gang_stat = get_object_or_404(RecruitmentGangStat, gang_id=gang_id, recruitment_stats__recruitment_id=recruitment_id) + + return Response( + { + 'total_applications': gang_stat.application_count, + 'total_applicants': gang_stat.applicant_count, + 'average_priority': gang_stat.average_priority, + 'total_accepted': gang_stat.total_accepted, + 'total_rejected': gang_stat.total_rejected, + } + ) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 6c13d881c..000000000 --- a/docs/README.md +++ /dev/null @@ -1,52 +0,0 @@ -[**← Back: Samfundet4**](../) - -# Documentation Overview - -> [!TIP] -> If you're new, start by going through the [Introduction to Samfundet4](./introduction.md) guide. - -## Frontend - -- [Creating react components (conventions)](./technical/frontend/components.md) -- [Forms and schemas](./technical/frontend/forms.md) - - [*Deprecated: SamfForm*](./technical/frontend/samfform.md) -- [Cypress Setup Documentation](./technical/frontend/cypress.md) -- [Data fetching and State management](./technical/frontend/data-fetching.md) - -## Backend - -- [🌐 API documentation](./api-docs.md) -- [Billig (payment system)](./technical/backend/billig.md) -- [Seed scripts](./technical/backend/seed.md) -- [Role system](./technical/backend/rolesystem.md) - -## Other - -- [Automatic Interview Scheduling](./intervew-scheduling.md) - -## Workflow - -- [Work Methodology](./work-methodology.md) - - How to contribute to the project -- [Useful Commands](./useful-commands.md) -- [Useful Docker aliases](./docker-project-specific-commands.md) -- [Common error messages](./common-errors.md) - -## Pipelines & Deployment - -- [Pipeline (mypy, Biome, tsc, ...)](./technical/pipeline.md) - -## Install - -- Linux: [Docker](./install/linux-docker.md) – [Native](./install/linux-native.md) -- MacOS: [Docker](./install/mac-docker.md) – [Native](./install/mac-native.md) -- Windows: [Docker](./install/windows-docker.md) – [WSL](./install/windows-wsl.md) -- [Install script](./install/install-script.md) -- [Post-install instructions](./install/post-install.md) - -## Editor configuration - -* [JetBrains (WebStorm, PyCharm, etc...)](./editors/jetbrains.md) -* [VS Code](./editors/vscode.md) -* [Vim/Neovim](./editors/vim.md) -* [Emacs](./editors/emacs.md) diff --git a/docs/install/windows-docker.md b/docs/install/windows-docker.md index 14347c924..aa44d4c91 100644 --- a/docs/install/windows-docker.md +++ b/docs/install/windows-docker.md @@ -3,10 +3,6 @@ > [!WARNING] > This guide is not complete! Feel free to submit a PR to improve it :-) -> [!NOTE] -> We do not recommend running the project this way. This is essentially running nested virtualization, which will lead -> to poor performance. Prefer running [directly in WSL](./windows-wsl.md). - # Installing on Windows (Docker in WSL) ## Install WSL diff --git a/frontend/src/Components/RejectionMail/RejectionMail.tsx b/frontend/src/Components/RejectionMail/RejectionMail.tsx index 7109290e1..a97215347 100644 --- a/frontend/src/Components/RejectionMail/RejectionMail.tsx +++ b/frontend/src/Components/RejectionMail/RejectionMail.tsx @@ -2,7 +2,7 @@ import { t } from 'i18next'; import { useState } from 'react'; import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; -import { postRejectionMail } from '~/api'; +//import { postRejectionMail } from '~/api'; import { KEY } from '~/i18n/constants'; import { Button } from '../Button'; import { InputField } from '../InputField'; @@ -15,7 +15,7 @@ export function RejectionMail() { function handleSubmit() { if (recruitmentId) { - postRejectionMail(recruitmentId, { subject, text }); + //postRejectionMail(recruitmentId, { subject, text }); toast.success(t(KEY.common_save_successful)); } else { toast.error(t(KEY.common_something_went_wrong)); diff --git a/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.tsx index 07b7abd8f..1585bb699 100644 --- a/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.tsx @@ -1,5 +1,5 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; import classNames from 'classnames'; -import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; @@ -7,7 +7,7 @@ import { BackButton, Button, Link, SamfundetLogoSpinner } from '~/Components'; import { Table } from '~/Components/Table'; import { Text } from '~/Components/Text/Text'; import { getRecruitmentApplicationsForRecruiter, withdrawRecruitmentApplicationRecruiter } from '~/api'; -import type { RecruitmentApplicationDto, RecruitmentUserDto } from '~/dto'; +import type { InterviewDto } from '~/dto'; import { STATUS } from '~/http_status_codes'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; @@ -15,50 +15,39 @@ import { ROUTES } from '~/routes'; import { dbT } from '~/utils'; import { AdminPage } from '../AdminPageLayout'; import styles from './RecruitmentApplicantAdminPage.module.scss'; +import { RecruitmentInterviewNotesForm } from './RecruitmentInterviewNotesForm'; export function RecruitmentApplicantAdminPage() { const { t } = useTranslation(); const navigate = useNavigate(); - const [recruitmentApplication, setRecruitmentApplication] = useState(); - const [otherRecruitmentApplication, setOtherRecruitmentApplication] = useState([]); - const [applicant, setApplicant] = useState(); - - const [loading, setLoading] = useState(true); - const { applicationID } = useParams(); + const { data, isLoading, error } = useQuery({ + queryKey: ['recruitmentapplicationpage', applicationID], + queryFn: () => getRecruitmentApplicationsForRecruiter(applicationID as string), + }); - useEffect(() => { - getRecruitmentApplicationsForRecruiter(applicationID as string) - .then((res) => { - setRecruitmentApplication(res.data.application); - setApplicant(res.data.user); - setOtherRecruitmentApplication(res.data.other_applications); - setLoading(false); - }) - .catch((data) => { - if (data.request.status === STATUS.HTTP_404_NOT_FOUND) { - navigate(ROUTES.frontend.not_found, { replace: true }); - } - toast.error(t(KEY.common_something_went_wrong)); - }); - }, [applicationID, t, navigate]); - - const adminWithdraw = () => { - if (recruitmentApplication) { - if (window.confirm(t(KEY.recruitment_confirm_withdraw_application))) { - withdrawRecruitmentApplicationRecruiter(recruitmentApplication.id) - .then((response) => { - setRecruitmentApplication(response.data); - toast.success(t(KEY.common_update_successful)); - }) - .catch(() => { - toast.error(t(KEY.common_something_went_wrong)); - }); - } + if (error) { + if (data?.request.status === STATUS.HTTP_404_NOT_FOUND) { + navigate(ROUTES.frontend.not_found, { replace: true }); } - }; + toast.error(t(KEY.common_something_went_wrong)); + } + + const recruitmentApplication = data?.data.application; + const applicant = data?.data.user; + const otherRecruitmentApplications = data?.data.other_applications; + const interviewNotes = recruitmentApplication?.interview?.notes; + + const adminWithdraw = useMutation({ + mutationFn: (id: string) => { + return withdrawRecruitmentApplicationRecruiter(id); + }, + onSuccess: () => { + toast.success(t(KEY.common_update_successful)); + }, + }); - if (loading) { + if (isLoading) { return (
@@ -66,6 +55,10 @@ export function RecruitmentApplicantAdminPage() { ); } + const initialData: Partial = { + notes: interviewNotes || '', + }; + return (
@@ -97,11 +90,22 @@ export function RecruitmentApplicantAdminPage() { {t(KEY.recruitment_withdrawn)} ) : ( - )}
+
+ +
+
{t(KEY.recruitment_all_applications)} @@ -114,57 +118,61 @@ export function RecruitmentApplicantAdminPage() { t(KEY.recruitment_recruiter_status), t(KEY.recruitment_interview_time), ]} - data={otherRecruitmentApplication.map((element) => { - return { - cells: [ - { - sortable: true, - content: ( - - {element.applicant_priority} - - ), - }, - { - content: ( - - {dbT(element.recruitment_position, 'name')} - - ), - }, - { - content: ( - - {dbT(element.recruitment_position.gang, 'name')} - - ), - }, - element.recruiter_priority ? element.recruiter_priority : t(KEY.common_not_set), - element.interview_time ? element.interview_time : t(KEY.common_not_set), - ], - }; - })} + data={ + otherRecruitmentApplications + ? otherRecruitmentApplications.map((element) => { + return { + cells: [ + { + sortable: true, + content: ( + + {element.applicant_priority} + + ), + }, + { + content: ( + + {dbT(element.recruitment_position, 'name')} + + ), + }, + { + content: ( + + {dbT(element.recruitment_position.gang, 'name')} + + ), + }, + element.recruiter_priority ? element.recruiter_priority : t(KEY.common_not_set), + element.interview_time ? element.interview_time : t(KEY.common_not_set), + ], + }; + }) + : [] + } />
diff --git a/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentInterviewNotesForm.tsx b/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentInterviewNotesForm.tsx new file mode 100644 index 000000000..39b9de9d4 --- /dev/null +++ b/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentInterviewNotesForm.tsx @@ -0,0 +1,58 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Textarea } from '~/Components'; +import { KEY } from '~/i18n/constants'; + +const recruitmentNotesSchema = z.object({ + notes: z.string(), +}); + +type RecruitmentInterviewNotesFormType = z.infer; + +interface RecruitmentInterviewNotesFormProps { + initialData: Partial; +} + +export function RecruitmentInterviewNotesForm({ initialData }: RecruitmentInterviewNotesFormProps) { + const { t } = useTranslation(); + + const form = useForm({ + resolver: zodResolver(recruitmentNotesSchema), + defaultValues: initialData, + }); + + function handleUpdateNotes(value: string) { + // TODO: Update notes using a put request + console.log(value); + } + + return ( +
+ +
+ ( + + {t(KEY.recruitment_interview_notes)} + +