diff --git a/backend/Pipfile b/backend/Pipfile index b2cbb7c72..5c2d3550d 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -3,6 +3,28 @@ url = "https://pypi.org/simple" verify_ssl = true name = "pypi" +[scripts] +# See '/docs/pipenv.md'. +"pipenv:install" = "pipenv install" +"pipenv:update" = "pipenv update" +"pipenv:sync" = "bash -c \"pipenv clean; pipenv sync --dev\"" +"pipenv:docker-install-dev" = "pipenv install --deploy --ignore-pipfile --dev" # 'deploy' means abort if outdated lock file. 'ignore-pipfile' means only install using the lock file. 'dev' means install dev dependencies. +"pipenv:docker-install-prod" = "pipenv install --deploy --ignore-pipfile" # 'deploy' means abort if outdated lock file. 'ignore-pipfile' means only install using the lock file. +"pipenv:outdated" = "pipenv update --outdated" # Show outdated dependencies. +"pipenv:graph" = "pipenv graph" # Show dependency graph. +"pipenv:where" = "pipenv --where" # Show location of virtual environment. +"pipenv:rm" = "pipenv --rm" # Completely remove virtual environment. +"pipenv:shell" = "pipenv shell" # Opens a shell within the virtual environment. +"mypy:run" = "pipenv run mypy --config-file mypy.ini ." +"migrations:verify" = "pipenv run python manage.py makemigrations --check --dry-run --noinput --verbosity 2" +"bandit:run" = "pipenv run bandit --recursive --ini .bandit ." +"flake8:run" = "pipenv run flake8 --config=.flake8 ." +"yapf:diff" = "pipenv run yapf --parallel --recursive --diff ." # Dry-run yapf on all files in the project. +"yapf:apply" = "pipenv run yapf --parallel --recursive -i ." # Applies yapf to all files in the project. +"pytest:run" = "pipenv run pytest" +"pipeline:run" = "bash -c \"pipenv run mypy:run && pipenv run yapf:diff && pipenv run migrations:verify && pipenv run bandit:run && pipenv run flake8:run && pipenv run pytest:run\"" +"seed:run" = "pipenv run python manage.py seed" + [packages] django = "*" pytest = "*" diff --git a/backend/root/management/commands/seed_scripts/__init__.py b/backend/root/management/commands/seed_scripts/__init__.py index e80702a9a..ced9e69b5 100755 --- a/backend/root/management/commands/seed_scripts/__init__.py +++ b/backend/root/management/commands/seed_scripts/__init__.py @@ -8,6 +8,7 @@ billig, menu, documents, + textitems, example, samf3, recruitment, @@ -32,6 +33,7 @@ ('menu', menu.seed), ('documents', documents.seed), ('information_page', information_pages.seed), + ('textitems', textitems.seed), ('blogposts', blogposts.seed), ('organization', oganizations.seed), ('recruitment', recruitment.seed), diff --git a/backend/root/management/commands/seed_scripts/textitems.py b/backend/root/management/commands/seed_scripts/textitems.py new file mode 100644 index 000000000..9fa2bd21b --- /dev/null +++ b/backend/root/management/commands/seed_scripts/textitems.py @@ -0,0 +1,44 @@ +from samfundet.models.general import TextItem + + +def seed(): + text_items = [ + { + 'key': 'welcome_message', + 'text_nb': 'Velkommen til Studentersamfundet i Trondhjem!', + 'text_en': 'Welcome to the Student Society in Trondheim!', + }, + { + 'key': 'upcoming_events', + 'text_nb': 'Sjekk ut våre kommende arrangementer og konsertene vi har planlagt!', + 'text_en': 'Check out our upcoming events and concerts we have planned!', + }, + { + 'key': 'join_us', + 'text_nb': 'Bli medlem av Studentersamfundet og nyt godt av medlemsfordelene!', + 'text_en': 'Join the Student Society and enjoy the benefits of membership!', + }, + { + 'key': 'volunteer', + 'text_nb': 'Vil du bli frivillig? Bli med i vårt fantastiske team og bidra til studentkulturen i Trondheim!', + 'text_en': 'Want to volunteer? Join our amazing team and contribute to the student culture in Trondheim!', + }, + { + 'key': 'about_us', + 'text_nb': 'Studentersamfundet i Trondhjem er et kulturelt senter for studenter og en viktig del av studentlivet i Trondheim.', + 'text_en': 'The Student Society in Trondheim is a cultural center for students and an essential part of student life in Trondheim.', + }, + { + 'key': 'contact_us', + 'text_nb': 'Har du spørsmål eller ønsker å komme i kontakt med oss? Ikke nøl med å ta kontakt!', + 'text_en': 'Do you have any questions or want to get in touch with us? Don"t hesitate to contact us!', + }, + ] + + TextItem.objects.all().delete() + yield 0, 'Deleted old textitems' + + for i, item in enumerate(text_items): + text_item, created = TextItem.objects.get_or_create(key=item['key'], text_nb=item['text_nb'], text_en=item['text_en']) + if created: + yield (100 * (i + 1) // len(text_items), f'Created {len(TextItem.objects.all())} textitems') diff --git a/backend/root/utils/routes.py b/backend/root/utils/routes.py index 56d96342b..9755f9833 100644 --- a/backend/root/utils/routes.py +++ b/backend/root/utils/routes.py @@ -5,7 +5,7 @@ DO NOT WRITE IN THIS FILE, AS IT WILL BE OVERWRITTEN ON NEXT UPDATE. THIS FILE WAS GENERATED BY: root.management.commands.generate_routes -LAST UPDATE: 2023-08-17 17:48:38.961443+00:00 +LAST UPDATE: 2023-09-04 15:22:16.450169+00:00 """ ############################################################ @@ -308,18 +308,36 @@ admin__samfundet_keyvalue_delete = 'admin:samfundet_keyvalue_delete' admin__samfundet_keyvalue_change = 'admin:samfundet_keyvalue_change' adminsamfundetkeyvalue__objectId = '' +admin__samfundet_recruitment_permissions = 'admin:samfundet_recruitment_permissions' +admin__samfundet_recruitment_permissions_manage_user = 'admin:samfundet_recruitment_permissions_manage_user' +admin__samfundet_recruitment_permissions_manage_group = 'admin:samfundet_recruitment_permissions_manage_group' admin__samfundet_recruitment_changelist = 'admin:samfundet_recruitment_changelist' admin__samfundet_recruitment_add = 'admin:samfundet_recruitment_add' admin__samfundet_recruitment_history = 'admin:samfundet_recruitment_history' admin__samfundet_recruitment_delete = 'admin:samfundet_recruitment_delete' admin__samfundet_recruitment_change = 'admin:samfundet_recruitment_change' adminsamfundetrecruitment__objectId = '' +admin__samfundet_recruitmentposition_permissions = 'admin:samfundet_recruitmentposition_permissions' +admin__samfundet_recruitmentposition_permissions_manage_user = 'admin:samfundet_recruitmentposition_permissions_manage_user' +admin__samfundet_recruitmentposition_permissions_manage_group = 'admin:samfundet_recruitmentposition_permissions_manage_group' admin__samfundet_recruitmentposition_changelist = 'admin:samfundet_recruitmentposition_changelist' admin__samfundet_recruitmentposition_add = 'admin:samfundet_recruitmentposition_add' admin__samfundet_recruitmentposition_history = 'admin:samfundet_recruitmentposition_history' admin__samfundet_recruitmentposition_delete = 'admin:samfundet_recruitmentposition_delete' admin__samfundet_recruitmentposition_change = 'admin:samfundet_recruitmentposition_change' adminsamfundetrecruitmentposition__objectId = '' +admin__samfundet_recruitmentadmission_permissions = 'admin:samfundet_recruitmentadmission_permissions' +admin__samfundet_recruitmentadmission_permissions_manage_user = 'admin:samfundet_recruitmentadmission_permissions_manage_user' +admin__samfundet_recruitmentadmission_permissions_manage_group = 'admin:samfundet_recruitmentadmission_permissions_manage_group' +admin__samfundet_recruitmentadmission_changelist = 'admin:samfundet_recruitmentadmission_changelist' +admin__samfundet_recruitmentadmission_add = 'admin:samfundet_recruitmentadmission_add' +admin__samfundet_recruitmentadmission_history = 'admin:samfundet_recruitmentadmission_history' +admin__samfundet_recruitmentadmission_delete = 'admin:samfundet_recruitmentadmission_delete' +admin__samfundet_recruitmentadmission_change = 'admin:samfundet_recruitmentadmission_change' +adminsamfundetrecruitmentadmission__objectId = '' +admin__samfundet_organization_permissions = 'admin:samfundet_organization_permissions' +admin__samfundet_organization_permissions_manage_user = 'admin:samfundet_organization_permissions_manage_user' +admin__samfundet_organization_permissions_manage_group = 'admin:samfundet_organization_permissions_manage_group' admin__samfundet_organization_changelist = 'admin:samfundet_organization_changelist' admin__samfundet_organization_add = 'admin:samfundet_organization_add' admin__samfundet_organization_history = 'admin:samfundet_organization_history' @@ -386,6 +404,8 @@ samfundet__table_detail = 'samfundet:table-detail' samfundet__text_item_list = 'samfundet:text_item-list' samfundet__text_item_detail = 'samfundet:text_item-detail' +samfundet__infobox_list = 'samfundet:infobox-list' +samfundet__infobox_detail = 'samfundet:infobox-detail' samfundet__key_value_list = 'samfundet:key_value-list' samfundet__key_value_detail = 'samfundet:key_value-detail' samfundet__organizations_list = 'samfundet:organizations-list' @@ -413,5 +433,6 @@ samfundet__assign_group = 'samfundet:assign_group' samfundet__recruitment_positions = 'samfundet:recruitment_positions' samfundet__active_recruitment_positions = 'samfundet:active_recruitment_positions' +samfundet__applicants_without_interviews = 'samfundet:applicants_without_interviews/' static__path = '' media__path = '' diff --git a/backend/samfundet/admin.py b/backend/samfundet/admin.py index 267eb3929..9f5c1e650 100644 --- a/backend/samfundet/admin.py +++ b/backend/samfundet/admin.py @@ -1,9 +1,12 @@ +from django.urls import reverse from django.contrib import admin +from django.utils.html import format_html from django.contrib.admin.models import LogEntry from django.contrib.auth.models import Permission, Group from django.contrib.contenttypes.models import ContentType from django.contrib.sessions.models import Session from guardian import models as guardian_models +from root.utils.routes import admin__samfundet_recruitmentadmission_change from root.custom_classes.admin_classes import ( CustomGuardedUserAdmin, @@ -488,6 +491,25 @@ class RecruitmentAdmin(CustomGuardedModelAdmin): list_select_related = True +class RecruitmentAdmissionInline(admin.TabularInline): + """ + Inline admin interface for RecruitmentAdmission. + + Displays a link to the detailed admin page of each admission along with its user and applicant priority. + """ + model = RecruitmentAdmission + extra = 0 + readonly_fields = ['linked_admission_text', 'user', 'applicant_priority'] + fields = ['linked_admission_text', 'user', 'applicant_priority'] + + def linked_admission_text(self, obj: RecruitmentAdmission) -> str: + """ + Returns a clickable link leading to the admin change page of the RecruitmentAdmission instance. + """ + url = reverse(admin__samfundet_recruitmentadmission_change, args=[obj.pk]) + return format_html('{obj}', url=url, obj=obj.admission_text) + + @admin.register(RecruitmentPosition) class RecruitmentPositionAdmin(CustomGuardedModelAdmin): sortable_by = [ @@ -496,11 +518,17 @@ class RecruitmentPositionAdmin(CustomGuardedModelAdmin): 'gang', 'id', ] - list_display = ['name_nb', 'is_funksjonaer_position', 'gang', 'id'] + list_display = ['name_nb', 'is_funksjonaer_position', 'gang', 'id', 'admissions_count'] search_fields = ['name_nb', 'is_funksjonaer_position', 'gang', 'id'] filter_horizontal = ['interviewers'] list_select_related = True + inlines = [RecruitmentAdmissionInline] + + def admissions_count(self, obj: RecruitmentPosition) -> int: + count = obj.admissions.all().count() + return count + @admin.register(RecruitmentAdmission) class RecruitmentAdmissionAdmin(CustomGuardedModelAdmin): diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index 8f5df7969..7a5c3338a 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -504,6 +504,25 @@ class Meta: fields = '__all__' +class UserForRecruitmentSerializer(serializers.ModelSerializer): + recruitment_admission_ids = serializers.SerializerMethodField() + + class Meta: + model = User + fields = [ + 'id', + 'first_name', + 'last_name', + 'username', + 'email', + 'recruitment_admission_ids', # Add this to the fields list + ] + + def get_recruitment_admission_ids(self, obj: User) -> list[int]: + """Return list of recruitment admission IDs for the user.""" + return RecruitmentAdmission.objects.filter(user=obj).values_list('id', flat=True) + + class RecruitmentPositionSerializer(serializers.ModelSerializer): class Meta: @@ -518,14 +537,38 @@ class Meta: fields = [ 'admission_text', 'recruitment_position', - 'user', - 'applicant_priority', 'interview_time', 'interview_location', ] + def create(self, validated_data: dict) -> RecruitmentAdmission: + recruitment_position = validated_data['recruitment_position'] + recruitment = recruitment_position.recruitment + user = self.context['request'].user + applicant_priority = 1 + + recruitment_admission = RecruitmentAdmission.objects.create( + admission_text=validated_data.get('admission_text'), + recruitment_position=recruitment_position, + recruitment=recruitment, + user=user, + applicant_priority=applicant_priority, + interview_time=validated_data.get('interview_time'), + interview_location=validated_data.get('interview_location') + ) + + return recruitment_admission + + +class ApplicantInfoSerializer(serializers.ModelSerializer): + + class Meta: + model = User + fields = ['id', 'first_name', 'last_name', 'email'] + class RecruitmentAdmissionForGangSerializer(serializers.ModelSerializer): + user = ApplicantInfoSerializer(read_only=True) class Meta: model = RecruitmentAdmission diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index a84a4dadd..2985c5d94 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -57,4 +57,5 @@ ########## Recruitment ########## path('recruitment-positions/', views.RecruitmentPositionsPerRecruitmentView.as_view(), name='recruitment_positions'), path('active-recruitment-positions/', views.ActiveRecruitmentPositionsView.as_view(), name='active_recruitment_positions'), + path('applicants-without-interviews/', views.ApplicantsWithoutInterviewsView.as_view(), name='applicants_without_interviews/'), ] diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index c354babfa..8b422b47e 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -1,5 +1,6 @@ from typing import Type +from django.db.models import Count, Case, When from django.contrib.auth import login, logout from django.contrib.auth.models import Group from django.db.models import QuerySet @@ -78,6 +79,7 @@ FoodPreferenceSerializer, UserPreferenceSerializer, InformationPageSerializer, + UserForRecruitmentSerializer, RecruitmentPositionSerializer, RecruitmentAdmissionForGangSerializer, RecruitmentAdmissionForApplicantSerializer, @@ -448,6 +450,13 @@ class RecruitmentPositionView(ModelViewSet): queryset = RecruitmentPosition.objects.all() +@method_decorator(ensure_csrf_cookie, 'dispatch') +class RecruitmentAdmissionView(ModelViewSet): + permission_classes = [AllowAny] + serializer_class = RecruitmentAdmissionForGangSerializer + queryset = RecruitmentAdmission.objects.all() + + @method_decorator(ensure_csrf_cookie, 'dispatch') class RecruitmentPositionsPerRecruitmentView(ListAPIView): permission_classes = [AllowAny] @@ -465,9 +474,30 @@ def get_queryset(self) -> Response: return None +class ApplicantsWithoutInterviewsView(ListAPIView): + permission_classes = [AllowAny] + serializer_class = UserForRecruitmentSerializer + + def get_queryset(self) -> QuerySet[User]: + """ + Optionally restricts the returned positions to a given recruitment, + by filtering against a `recruitment` query parameter in the URL. + """ + recruitment = self.request.query_params.get('recruitment', None) + if recruitment is None: + return User.objects.none() # Return an empty queryset instead of None + + # Exclude users who have any admissions for the given recruitment that have an interview_time + users_without_interviews = User.objects.filter(admissions__recruitment=recruitment).annotate( + num_interviews=Count(Case(When(admissions__recruitment=recruitment, then='admissions__interview_time'), default=None, output_field=None)) + ).filter(num_interviews=0) + return users_without_interviews + + class RecruitmentAdmissionForApplicantView(ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = RecruitmentAdmissionForApplicantSerializer + queryset = RecruitmentAdmission.objects.all() def list(self, request: Request) -> Response: """ @@ -495,6 +525,7 @@ def list(self, request: Request) -> Response: class RecruitmentAdmissionForGangView(ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = RecruitmentAdmissionForGangSerializer + queryset = RecruitmentAdmission.objects.all() # TODO: User should only be able to edit the fields that are allowed diff --git a/frontend/src/AppRoutes.tsx b/frontend/src/AppRoutes.tsx index dc0f84a5a..2a735f5ff 100644 --- a/frontend/src/AppRoutes.tsx +++ b/frontend/src/AppRoutes.tsx @@ -14,6 +14,7 @@ import { LoginPage, LychePage, NotFoundPage, + RecruitmentAdmissionFormPage, RecruitmentPage, RouteOverviewPage, SaksdokumenterPage, @@ -35,6 +36,7 @@ import { RecruitmentGangAdminPage, RecruitmentGangOverviewPage, RecruitmentPositionFormAdminPage, + RecruitmentUsersWithoutInterview, SaksdokumentFormAdminPage, } from '~/PagesAdmin'; import { useGoatCounter } from '~/hooks'; @@ -47,6 +49,7 @@ import { RecruitmentFormAdminPage } from './PagesAdmin/RecruitmentFormAdminPage' import { SaksdokumentAdminPage } from './PagesAdmin/SaksdokumentAdminPage'; import { PERM } from './permissions'; import { ROUTES } from './routes'; +import { RecruitmentPositionOverviewPage } from './PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage'; export function AppRoutes() { // Must be called within because it uses hook useLocation(). @@ -74,6 +77,7 @@ export function AppRoutes() { } /> } /> } /> + } /> {/* ADMIN ROUTES @@ -170,6 +174,10 @@ export function AppRoutes() { path={ROUTES.frontend.admin_recruitment_edit} element={} /> + } + /> {/* TODO ADD PERMISSIONS */} } /> + } + /> } diff --git a/frontend/src/Components/Dropdown/Dropdown.tsx b/frontend/src/Components/Dropdown/Dropdown.tsx index 77e321e14..1b62660db 100644 --- a/frontend/src/Components/Dropdown/Dropdown.tsx +++ b/frontend/src/Components/Dropdown/Dropdown.tsx @@ -12,6 +12,7 @@ export type DropDownOption = { type DropdownProps = { className?: string; defaultValue?: DropDownOption; + initialValue?: T; options?: DropDownOption[]; label?: string | ReactElement; disabled?: boolean; @@ -22,6 +23,7 @@ type DropdownProps = { export function Dropdown({ options = [], defaultValue, + initialValue, onChange, className, label, @@ -50,9 +52,9 @@ export function Dropdown({ className={classNames(styles.samf_select, error && styles.error)} onChange={handleChange} disabled={disabled} - defaultValue={-1} + defaultValue={initialValue !== undefined ? options.map((e) => e.value).indexOf(initialValue) : -1} > - {defaultValue ? : } + {defaultValue ? : } {options.map((opt, index) => { return (