diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..9cfbef338 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Force all files to use LF line endings +* text=auto diff --git a/README.md b/README.md index 912135fd4..e24335d94 100644 --- a/README.md +++ b/README.md @@ -2,55 +2,10 @@ -## Documentation - -- **[Technical Documentation](./docs/technical/README.md)** -- [Work Methodology](./docs/work-methodology.md) -- [Useful Commands](./docs/useful-commands.md) -- [Technologies used on Samf4 🤖](./docs/technical/Samf4Tech.md) -- [Project Specific Commands](./docs/docker-project-specific-commands.md) -- [Useful Docker aliases](./docs/docker-project-specific-commands.md) -- [🌐 API documentation](./docs/api-docs.md) - -## Installation - -We have a script that handles all installation for you. To run the script, a Github Personal Access Token (PAT) is required. -You can make one here https://github.com/settings/tokens/new. Tick scopes `repo`, `read:org` and `admin:public_key`), -then store the token somewhere safe (Github will never show it again). - -Copy these commands (press button on the right-hand side of the block) -and run from the directory you would clone the project. - -```sh -# Interactive -read -s -p "Github PAT token: " TOKEN ; X_INTERACTIVE=y /bin/bash -c "$(curl -fsSL https://$TOKEN@raw.githubusercontent.com/Samfundet/Samfundet4/master/{bash_utils.sh,install.sh})" && . ~/.bash_profile && cd Samfundet4; unset TOKEN; unset X_INTERACTIVE; -``` +## Introduction -
-Non-interactive (show/hide) +Samfundet4 is the latest and greatest iteration of samfundet.no. It's built using Django and React. -```sh -# Non-interactive -read -s -p "Github PAT token: " TOKEN ; X_INTERACTIVE=n /bin/bash -c "$(curl -fsSL https://$TOKEN@raw.githubusercontent.com/Samfundet/Samfundet4/master/{bash_utils.sh,install.sh})" && . ~/.bash_profile && cd Samfundet4; unset TOKEN; unset X_INTERACTIVE; -``` - - -
- -
-Flags explained (show/hide) - -> - X_INTERACTIVE (y/n): determines how many prompts you receive before performing an action. -> curl: -> - -f: fail fast -> - -s: silent, no progress-meter -> - -S: show error on fail -> - -L: follow redirect - -
+## Documentation -
-
-
+Looking for install guides and technical documentation? Go to the [Documentation Overview](./docs/README.md)! diff --git a/backend/root/management/commands/seed_scripts/__init__.py b/backend/root/management/commands/seed_scripts/__init__.py index 820e79bc5..be8025b4a 100755 --- a/backend/root/management/commands/seed_scripts/__init__.py +++ b/backend/root/management/commands/seed_scripts/__init__.py @@ -24,6 +24,7 @@ recruitment_separate_position, recruitment_interviewavailability, recruitment_position_interviewers, + recruitment_sharedinterviewgroups, ) # Insert seed scripts here (in order of priority) @@ -50,6 +51,7 @@ ('merch', merch.seed), ('recruitment', recruitment.seed), ('recruitment_position', recruitment_position.seed), + ('recruitment_position_shared_interview', recruitment_sharedinterviewgroups.seed), ('recruitment_interviewavailability', recruitment_interviewavailability.seed), ('recruitment_separate_position', recruitment_separate_position.seed), ('recruitment_applications', recruitment_applications.seed), diff --git a/backend/root/management/commands/seed_scripts/recruitment_sharedinterviewgroups.py b/backend/root/management/commands/seed_scripts/recruitment_sharedinterviewgroups.py new file mode 100644 index 000000000..f424a3515 --- /dev/null +++ b/backend/root/management/commands/seed_scripts/recruitment_sharedinterviewgroups.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import random + +from root.utils.samfundet_random import words + +from samfundet.models.recruitment import Recruitment, RecruitmentPosition, RecruitmentPositionSharedInterviewGroup + + +def seed(): + yield 0, 'recruitment_positions_shared_interview' + RecruitmentPositionSharedInterviewGroup.objects.all().delete() + yield 0, 'Deleted old recruitmentpositionsharedgroup' + + recruitments = Recruitment.objects.all() + created_count = 0 + for recruitment in recruitments: + for i in range(3): + RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=recruitment, name_nb=f'{words(2)} {i}', name_en=f'{words(2)} {i}') + created_count += 1 + shared_groups = list(RecruitmentPositionSharedInterviewGroup.objects.filter(recruitment=recruitment)) + positions = random.sample(list(RecruitmentPosition.objects.filter(recruitment=recruitment)), 6) + for pos in positions: + pos.shared_interview_group = random.choice(shared_groups) + pos.save() + + yield 100, f'Created {created_count} recruitment_position_shared_groups' diff --git a/backend/root/utils/routes.py b/backend/root/utils/routes.py index 763e2a80a..2d1132ab8 100644 --- a/backend/root/utils/routes.py +++ b/backend/root/utils/routes.py @@ -391,6 +391,15 @@ admin__samfundet_recruitmentapplication_delete = 'admin:samfundet_recruitmentapplication_delete' admin__samfundet_recruitmentapplication_change = 'admin:samfundet_recruitmentapplication_change' adminsamfundetrecruitmentapplication__objectId = '' +admin__samfundet_recruitmentpositionsharedinterviewgroup_permissions = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_permissions' +admin__samfundet_recruitmentpositionsharedinterviewgroup_permissions_manage_user = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_permissions_manage_user' +admin__samfundet_recruitmentpositionsharedinterviewgroup_permissions_manage_group = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_permissions_manage_group' +admin__samfundet_recruitmentpositionsharedinterviewgroup_changelist = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_changelist' +admin__samfundet_recruitmentpositionsharedinterviewgroup_add = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_add' +admin__samfundet_recruitmentpositionsharedinterviewgroup_history = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_history' +admin__samfundet_recruitmentpositionsharedinterviewgroup_delete = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_delete' +admin__samfundet_recruitmentpositionsharedinterviewgroup_change = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_change' +adminsamfundetrecruitmentpositionsharedinterviewgroup__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' @@ -502,6 +511,8 @@ samfundet__saksdokument_detail = 'samfundet:saksdokument-detail' samfundet__profile_list = 'samfundet:profile-list' samfundet__profile_detail = 'samfundet:profile-detail' +samfundet__permissions_list = 'samfundet:permissions-list' +samfundet__permissions_detail = 'samfundet:permissions-detail' samfundet__menu_list = 'samfundet:menu-list' samfundet__menu_detail = 'samfundet:menu-detail' samfundet__menu_items_list = 'samfundet:menu_items-list' @@ -529,6 +540,7 @@ samfundet__merch_detail = 'samfundet:merch-detail' samfundet__role_list = 'samfundet:role-list' samfundet__role_detail = 'samfundet:role-detail' +samfundet__role_users = 'samfundet:role-users' samfundet__recruitment_list = 'samfundet:recruitment-list' samfundet__recruitment_detail = 'samfundet:recruitment-detail' samfundet__recruitment_gangs = 'samfundet:recruitment-gangs' @@ -553,7 +565,6 @@ 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' @@ -576,6 +587,7 @@ 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' +samfundet__recruitment_shared_interviews = 'samfundet:recruitment_shared_interviews' samfundet__recruitment_positions_gang_for_gangs = 'samfundet:recruitment_positions_gang_for_gangs' samfundet__recruitment_set_interview = 'samfundet:recruitment_set_interview' samfundet__recruitment_application_states_choices = 'samfundet:recruitment_application_states_choices' @@ -586,6 +598,7 @@ samfundet__recruitment_user_priority_update = 'samfundet:recruitment_user_priority_update' samfundet__recruitment_withdraw_application_recruiter = 'samfundet:recruitment_withdraw_application_recruiter' samfundet__active_recruitment_positions = 'samfundet:active_recruitment_positions' +samfundet__rejected_applicants = 'samfundet:rejected_applicants/' samfundet__applicants_without_interviews = 'samfundet:applicants_without_interviews' samfundet__applicants_without_three_interview_criteria = 'samfundet:applicants_without_three_interview_criteria' samfundet__recruitment_recruiter_dashboard = 'samfundet:recruitment_recruiter_dashboard' diff --git a/backend/samfundet/admin.py b/backend/samfundet/admin.py index 3df2c2de2..a29e3de55 100644 --- a/backend/samfundet/admin.py +++ b/backend/samfundet/admin.py @@ -63,6 +63,7 @@ RecruitmentApplication, RecruitmentSeparatePosition, RecruitmentInterviewAvailability, + RecruitmentPositionSharedInterviewGroup, ) # Common fields: @@ -715,6 +716,28 @@ class RecruitmentApplicationAdmin(CustomBaseAdmin): list_select_related = True +@admin.register(RecruitmentPositionSharedInterviewGroup) +class RecruitmentPositionSharedInterviewGroupAdmin(CustomBaseAdmin): + sortable_by = [ + 'recruitment', + 'name_en', + 'name_nb', + '__str__', + ] + list_display = [ + 'recruitment', + 'name_en', + 'name_nb', + '__str__', + ] + search_fields = [ + 'recruitment', + 'name_en', + 'name_nb', + '__str__', + ] + + @admin.register(Organization) class OrganizationAdmin(CustomBaseAdmin): sortable_by = ['id', 'name'] diff --git a/backend/samfundet/migrations/0008_event_entrance_en_event_entrance_nb.py b/backend/samfundet/migrations/0008_event_entrance_en_event_entrance_nb.py new file mode 100644 index 000000000..ca26b48f7 --- /dev/null +++ b/backend/samfundet/migrations/0008_event_entrance_en_event_entrance_nb.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.1 on 2024-10-25 12:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0007_alter_infobox_color_alter_infobox_image_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='entrance_en', + field=models.CharField(blank=True, max_length=140, null=True), + ), + migrations.AddField( + model_name='event', + name='entrance_nb', + field=models.CharField(blank=True, max_length=140, null=True), + ), + ] diff --git a/backend/samfundet/migrations/0009_recruitmentpositionsharedinterviewgroup_name_en_and_more.py b/backend/samfundet/migrations/0009_recruitmentpositionsharedinterviewgroup_name_en_and_more.py new file mode 100644 index 000000000..51dd8d85e --- /dev/null +++ b/backend/samfundet/migrations/0009_recruitmentpositionsharedinterviewgroup_name_en_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.1 on 2024-10-29 17:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("samfundet", "0008_event_entrance_en_event_entrance_nb"), + ] + + operations = [ + migrations.AddField( + model_name="recruitmentpositionsharedinterviewgroup", + name="name_en", + field=models.CharField( + default="hi", + help_text="Name of the recruitmentgroup (EN)", + max_length=100, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="recruitmentpositionsharedinterviewgroup", + name="name_nb", + field=models.CharField( + default="hi", + help_text="Name of the recruitmentgroup (NB)", + max_length=100, + ), + preserve_default=False, + ), + ] diff --git a/backend/samfundet/migrations/0010_recruitment_promo_media.py b/backend/samfundet/migrations/0010_recruitment_promo_media.py new file mode 100644 index 000000000..8aa5e3431 --- /dev/null +++ b/backend/samfundet/migrations/0010_recruitment_promo_media.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2024-10-31 19:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0009_recruitmentpositionsharedinterviewgroup_name_en_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='recruitment', + name='promo_media', + field=models.CharField(blank=True, default=None, help_text='Youtube video id', max_length=11, null=True), + ), + ] diff --git a/backend/samfundet/models/event.py b/backend/samfundet/models/event.py index e4ff4aae8..597e4b344 100644 --- a/backend/samfundet/models/event.py +++ b/backend/samfundet/models/event.py @@ -161,11 +161,17 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: description_long_en = models.TextField(blank=False, null=False) description_short_nb = models.TextField(blank=False, null=False) description_short_en = models.TextField(blank=False, null=False) - location = models.CharField(max_length=140, blank=False, null=False) image = models.ForeignKey(Image, on_delete=models.PROTECT, blank=False, null=False) host = models.CharField(max_length=140, blank=False, null=False) editors = models.ManyToManyField(Gang, blank=True) + # ======================== # + # Venue/Entrance # + # ======================== # + location = models.CharField(max_length=140, blank=False, null=False) # AKA venue + entrance_nb = models.CharField(max_length=140, blank=True, null=True) + entrance_en = models.CharField(max_length=140, blank=True, null=True) + age_restriction = models.CharField(max_length=30, choices=EventAgeRestriction.choices, blank=False, null=False, default=None) category = models.CharField(max_length=30, choices=EventCategory.choices, blank=False, null=False, default=EventCategory.OTHER) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index f4b9548b9..514fcda9d 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -31,6 +31,7 @@ class Recruitment(CustomBaseModel): organization = models.ForeignKey(null=False, blank=False, to=Organization, on_delete=models.CASCADE, help_text='The organization that is recruiting') max_applications = models.PositiveIntegerField(null=True, blank=True, verbose_name='Max applications per applicant') + promo_media = models.CharField(max_length=11, help_text='Youtube video id', null=True, default=None, blank=True) def resolve_org(self, *, return_id: bool = False) -> Organization | int: if return_id: @@ -115,8 +116,11 @@ class RecruitmentPositionSharedInterviewGroup(CustomBaseModel): blank=True, ) + name_nb = models.CharField(max_length=100, null=False, blank=False, help_text='Name of the recruitmentgroup (NB)') + name_en = models.CharField(max_length=100, null=False, blank=False, help_text='Name of the recruitmentgroup (EN)') + def __str__(self) -> str: - return f'{self.recruitment} Interviewgroup {self.id}' + return f'{self.recruitment} Interviewgroup {self.name_nb} {", ".join(list(self.positions.values_list("name_nb", flat=True)))}' class RecruitmentPosition(CustomBaseModel): @@ -261,6 +265,7 @@ class Interview(CustomBaseModel): help_text='Room where the interview is held', related_name='interviews', ) + interviewers = models.ManyToManyField(to='samfundet.User', help_text='Interviewers for this interview', blank=True, related_name='interviews') notes = models.TextField(help_text='Notes for the interview', null=True, blank=True) @@ -272,6 +277,7 @@ 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 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) application_text = models.TextField(help_text='Application text') recruitment_position = models.ForeignKey( @@ -283,6 +289,7 @@ class RecruitmentApplication(CustomBaseModel): created_at = models.DateTimeField(null=True, blank=True, auto_now_add=True) + # 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' ) diff --git a/backend/samfundet/models/tests/test_recruitment.py b/backend/samfundet/models/tests/test_recruitment.py index 392ed364b..5a754d239 100644 --- a/backend/samfundet/models/tests/test_recruitment.py +++ b/backend/samfundet/models/tests/test_recruitment.py @@ -248,7 +248,7 @@ def test_interview_group_autoadd_on_create( assert fixture_recruitment_position2.shared_interview_group is None # setup interview group - shared_group = RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=fixture_recruitment) + shared_group = RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=fixture_recruitment, name_en='name', name_nb='navn') fixture_recruitment_position.shared_interview_group = shared_group fixture_recruitment_position2.shared_interview_group = shared_group fixture_recruitment_position.save() @@ -288,7 +288,7 @@ def test_interview_group_autoset_on_set( assert fixture_recruitment_application2.recruitment_position == fixture_recruitment_position2 # setup interview group - shared_group = RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=fixture_recruitment) + shared_group = RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=fixture_recruitment, name_en='name', name_nb='navn') fixture_recruitment_position.shared_interview_group = shared_group fixture_recruitment_position2.shared_interview_group = shared_group fixture_recruitment_position.save() diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index 0c3502112..371910607 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import itertools from typing import TYPE_CHECKING from collections import defaultdict @@ -18,7 +19,7 @@ from root.constants import PHONE_NUMBER_REGEX from root.utils.mixins import CustomBaseSerializer -from .models.role import Role +from .models.role import Role, UserOrgRole, UserGangRole, UserGangSectionRole from .models.event import Event, EventGroup, EventCustomTicket, PurchaseFeedbackModel, PurchaseFeedbackQuestion, PurchaseFeedbackAlternative from .models.billig import BilligEvent, BilligPriceGroup, BilligTicketGroup from .models.general import ( @@ -39,6 +40,7 @@ KeyValue, MenuItem, TextItem, + GangSection, Reservation, ClosedPeriod, FoodCategory, @@ -64,6 +66,7 @@ RecruitmentApplication, RecruitmentSeparatePosition, RecruitmentInterviewAvailability, + RecruitmentPositionSharedInterviewGroup, ) from .models.model_choices import RecruitmentStatusChoices, RecruitmentPriorityChoices @@ -424,6 +427,12 @@ class Meta: fields = '__all__' +class GangSectionSerializer(CustomBaseSerializer): + class Meta: + model = GangSection + fields = '__all__' + + class RecruitmentGangSerializer(CustomBaseSerializer): recruitment_positions = serializers.SerializerMethodField(method_name='get_positions_count', read_only=True) @@ -497,6 +506,54 @@ class Meta: fields = '__all__' +class UserOrgRoleSerializer(CustomBaseSerializer): + user = UserSerializer() + org_role = serializers.SerializerMethodField() + + class Meta: + model = UserOrgRole + fields = ('user', 'org_role') + + def get_org_role(self, obj: UserOrgRole) -> dict: + return { + 'created_at': obj.created_at, + 'created_by': UserSerializer(obj.created_by).data, + 'organization': OrganizationSerializer(obj.obj).data, + } + + +class UserGangRoleSerializer(CustomBaseSerializer): + user = UserSerializer() + gang_role = serializers.SerializerMethodField() + + class Meta: + model = UserGangRole + fields = ('user', 'gang_role') + + def get_gang_role(self, obj: UserGangRole) -> dict: + return { + 'created_at': obj.created_at, + 'created_by': UserSerializer(obj.created_by).data, + 'gang': GangSerializer(obj.obj).data, + } + + +class UserGangSectionRoleSerializer(CustomBaseSerializer): + user = UserSerializer() + section_role = serializers.SerializerMethodField() + + class Meta: + model = UserGangSectionRole + fields = ('user', 'section_role') + + def get_section_role(self, obj: UserGangSectionRole) -> dict: + return { + 'created_at': obj.created_at, + 'created_by': UserSerializer(obj.created_by).data, + 'section': GangSectionSerializer(obj.obj).data, + } + + class SaksdokumentSerializer(CustomBaseSerializer): # Read only url file path used in frontend url = serializers.SerializerMethodField(method_name='get_url', read_only=True) @@ -738,11 +795,22 @@ class Meta: class RecruitmentSerializer(CustomBaseSerializer): separate_positions = RecruitmentSeparatePositionSerializer(many=True, read_only=True) + promo_media = serializers.CharField(max_length=100, allow_blank=True, allow_null=True) class Meta: model = Recruitment fields = '__all__' + def validate_promo_media(self, value: str | None) -> str | None: + if value is None or value == '': + return None + match = re.search(r'(youtu.*be.*)\/(watch\?v=|embed\/|v|shorts|)(.*?((?=[&#?])|$))', value) + if match: + return match.group(3) + if len(value) == 11: + return value + raise ValidationError('Invalid youtube url') + def to_representation(self, instance: Recruitment) -> dict: data = super().to_representation(instance) data['organization'] = OrganizationSerializer(instance.organization).data @@ -769,6 +837,7 @@ class RecruitmentPositionSerializer(CustomBaseSerializer): gang = GangSerializer(read_only=True) interviewers = InterviewerSerializer(many=True, read_only=True) + interviewer_ids = serializers.ListField(child=serializers.IntegerField(), write_only=True, required=False) class Meta: model = RecruitmentPosition @@ -778,28 +847,43 @@ def _update_interviewers( self, *, recruitment_position: RecruitmentPosition, - interviewer_objects: list[dict], + interviewer_ids: list[int], ) -> None: - try: - interviewers = [] - if interviewer_objects: - interviewer_ids = [interviewer.get('id') for interviewer in interviewer_objects] - if interviewer_ids: - interviewers = User.objects.filter(id__in=interviewer_ids) - recruitment_position.interviewers.set(interviewers) - except (TypeError, KeyError): - raise ValidationError('Invalid data for interviewers.') from None + if interviewer_ids: + try: + interviewers = User.objects.filter(id__in=interviewer_ids) + found_ids = set(interviewers.values_list('id', flat=True)) + invalid_ids = set(interviewer_ids) - found_ids + + if invalid_ids: + raise ValidationError(f'Invalid interviewer IDs: {invalid_ids}') + + recruitment_position.interviewers.set(interviewers) + except (TypeError, ValueError): + raise ValidationError('Invalid interviewer IDs format.') from None + else: + recruitment_position.interviewers.clear() + + def validate(self, data: dict) -> dict: + gang_id = self.initial_data.get('gang', {}).get('id') + if gang_id: + try: + data['gang'] = Gang.objects.get(id=gang_id) + except Gang.DoesNotExist: + raise serializers.ValidationError('Invalid gang id') from None + + self.interviewer_ids = data.pop('interviewer_ids', []) + + return super().validate(data) def create(self, validated_data: dict) -> RecruitmentPosition: recruitment_position = super().create(validated_data) - interviewer_objects = self.initial_data.get('interviewers', []) - self._update_interviewers(recruitment_position=recruitment_position, interviewer_objects=interviewer_objects) + self._update_interviewers(recruitment_position=recruitment_position, interviewer_ids=self.interviewer_ids) return recruitment_position def update(self, instance: RecruitmentPosition, validated_data: dict) -> RecruitmentPosition: updated_instance = super().update(instance, validated_data) - interviewer_objects = self.initial_data.get('interviewers', []) - self._update_interviewers(recruitment_position=updated_instance, interviewer_objects=interviewer_objects) + self._update_interviewers(recruitment_position=updated_instance, interviewer_ids=self.interviewer_ids) return updated_instance def get_total_applicants(self, recruitment_position: RecruitmentPosition) -> int: @@ -849,6 +933,20 @@ class Meta: ] +class RecruitmentPositionSharedInterviewGroupSerializer(serializers.ModelSerializer): + positions = RecruitmentPositionForApplicantSerializer(many=True, read_only=True) + + class Meta: + model = RecruitmentPositionSharedInterviewGroup + fields = [ + 'id', + 'recruitment', + 'positions', + 'name_en', + 'name_nb', + ] + + class RecruitmentApplicationForApplicantSerializer(CustomBaseSerializer): interview = ApplicantInterviewSerializer(read_only=True) diff --git a/backend/samfundet/tests/test_email.py b/backend/samfundet/tests/test_email.py index 34bda4855..d27d2fc9e 100644 --- a/backend/samfundet/tests/test_email.py +++ b/backend/samfundet/tests/test_email.py @@ -1,35 +1,140 @@ from __future__ import annotations +from datetime import timedelta + +from rest_framework.test import APIClient + from django.core import mail +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from root.utils import routes + +from samfundet.models.general import Gang, User +from samfundet.models.recruitment import ( + Recruitment, + Organization, + RecruitmentPosition, + RecruitmentApplication, +) +from samfundet.models.model_choices import RecruitmentStatusChoices + + +class SendRejectionMailViewTests(TestCase): + def setUp(self): + # Create an organization + self.organization = Organization.objects.create(name='Test org') + + # Create a gang + self.gang = Gang.objects.create(name_nb='Test Gang NB', name_en='Test Gang EN', abbreviation='TG', organization=self.organization) + + # Create a recruitment + self.recruitment = Recruitment.objects.create( + name_nb='Test Recruitment NB', + name_en='Test Recruitment EN', + visible_from=timezone.now() - timedelta(days=1), + actual_application_deadline=timezone.now() + timedelta(days=5), + shown_application_deadline=timezone.now() + timedelta(days=4), + reprioritization_deadline_for_applicant=timezone.now() + timedelta(days=6), + reprioritization_deadline_for_groups=timezone.now() + timedelta(days=7), + organization=self.organization, + ) + + # Create a recruitment position + self.position = RecruitmentPosition.objects.create( + name_nb='Test Position NB', + name_en='Test Position EN', + short_description_nb='Short description NB', + short_description_en='Short description EN', + long_description_nb='Long description NB', + long_description_en='Long description EN', + is_funksjonaer_position=False, + default_application_letter_nb='Default letter NB', + default_application_letter_en='Default letter EN', + norwegian_applicants_only=False, + recruitment=self.recruitment, + gang=self.gang, # Link the gang to the recruitment position + tags='some-tag', # Add the required 'tags' field + ) + + # Create users + self.user_rejected = User.objects.create(username='rejected-user', email='rejected@example.com') + self.user_withdrawn = User.objects.create(username='withdrawn-user', email='withdrawn@example.com') + self.user_contacted = User.objects.create(username='contacted-user', email='contacted@example.com') + self.user_rejected_but_contacted = User.objects.create(username='skurra-user', email='hard.to.get@example.com') + + self.admin_user = User.objects.create_superuser(username='admin', email='admin@example.com', password='adminpassword') + + # Initialize APIClient + self.client = APIClient() + + # Create applications with the recruitment position + # Rejected but not contacted (should receive rejection email) + RecruitmentApplication.objects.create( + user=self.user_rejected, + recruitment=self.recruitment, + recruiter_status=RecruitmentStatusChoices.REJECTION, + withdrawn=False, + recruitment_position=self.position, # Link to the position + application_text='Sample application text', # Add required application_text field + ) + + # Withdrawn application (should NOT receive rejection email) + RecruitmentApplication.objects.create( + user=self.user_withdrawn, + recruitment=self.recruitment, + recruiter_status=RecruitmentStatusChoices.REJECTION, + withdrawn=True, + recruitment_position=self.position, # Link to the position + application_text='Sample application text', # Add required application_text field + ) + + # Contacted with an offer (should NOT receive rejection email) + RecruitmentApplication.objects.create( + user=self.user_contacted, + recruitment=self.recruitment, + recruiter_status=RecruitmentStatusChoices.CALLED_AND_ACCEPTED, + withdrawn=False, + recruitment_position=self.position, # Link to the position + application_text='Sample application text', # Add required application_text field + ) + + # Rejected but also contacted (should NOT receive rejection email) + RecruitmentApplication.objects.create( + user=self.user_rejected_but_contacted, + recruitment=self.recruitment, + recruiter_status=RecruitmentStatusChoices.REJECTION, + withdrawn=False, + recruitment_position=self.position, # Link to the position + application_text='Sample application text', # Add required application_text field + ) + + RecruitmentApplication.objects.create( + user=self.user_rejected_but_contacted, + recruitment=self.recruitment, + recruiter_status=RecruitmentStatusChoices.CALLED_AND_ACCEPTED, + withdrawn=False, + recruitment_position=self.position, # Link to the position + application_text='Sample application text', # Add required application_text field + ) + + def test_rejection_email_sent_to_eligible_users(self): + # Authenticate the admin user + self.client.force_authenticate(user=self.admin_user) + + # Send the rejection email + response = self.client.post( + reverse(routes.samfundet__rejected_applicants), + {'subject': 'Rejection', 'text': 'You have been rejected. Womp womp', 'recruitment': self.recruitment.id}, + format='json', # Ensure data is sent as JSON if needed + ) -from root.settings.base import TEST_EMAIL_FILE - - -def test_send_email_and_save_to_file(): - subject = 'Subject here' - message = 'Here is the message.' - from_email = 'from@example.com' - recipients = ['to@example.com'] - - mail.send_mail( - subject, - message, - from_email, - recipients, - fail_silently=False, - ) - - assert len(mail.outbox) == 1 - email = mail.outbox[0] - - assert email.subject == subject - assert email.from_email == from_email - assert email.to == recipients - assert email.body == message - - # Writing email to a file for inspection - with open(TEST_EMAIL_FILE, 'w') as f: - f.write(f'Subject: {email.subject}\n') - f.write(f'From: {email.from_email}\n') - f.write(f"To: {', '.join(email.to)}\n") - f.write(f'Message: {email.body}\n') + # Verify response status + self.assertEqual(response.status_code, 200) # Verify that emails were sent to eligible users only + self.assertEqual(len(mail.outbox), 1) # Only one email should be sent + sent_emails = mail.outbox[0].bcc + self.assertIn('rejected@example.com', sent_emails) # Rejected but not contacted + self.assertNotIn('withdrawn@example.com', sent_emails) # Withdrawn should not receive + self.assertNotIn('contacted@example.com', sent_emails) # Contacted should not receive + self.assertNotIn('skurrabompapa@example.com', sent_emails) # Rejected but contacted should not receive diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index a0246ae45..3761d75c3 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -88,6 +88,11 @@ views.RecruitmentPositionsPerGangForApplicantView.as_view(), name='recruitment_positions_gang_for_applicants', ), + path( + 'recruitment-shared-interview-groups//', + views.RecruitmentInterviewGroupView.as_view(), + name='recruitment_shared_interviews', + ), path('recruitment-positions-gang-for-gangs/', views.RecruitmentPositionsPerGangForGangView.as_view(), name='recruitment_positions_gang_for_gangs'), path('recruitment-set-interview//', views.RecruitmentApplicationSetInterviewView.as_view(), name='recruitment_set_interview'), path( diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index f53726a25..e4099a47c 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -5,6 +5,7 @@ import hmac import hashlib from typing import Any +from itertools import chain from guardian.shortcuts import get_objects_for_user @@ -21,7 +22,7 @@ from django.conf import settings from django.http import QueryDict, HttpResponse from django.utils import timezone -from django.core.mail import send_mail +from django.core.mail import EmailMessage from django.db.models import Q, Count, QuerySet from django.shortcuts import get_object_or_404 from django.contrib.auth import login, logout @@ -40,7 +41,7 @@ from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request from .homepage import homepage -from .models.role import Role +from .models.role import Role, UserOrgRole, UserGangRole, UserGangSectionRole from .serializers import ( TagSerializer, GangSerializer, @@ -67,11 +68,13 @@ EventGroupSerializer, PermissionSerializer, RecruitmentSerializer, + UserOrgRoleSerializer, ClosedPeriodSerializer, FoodCategorySerializer, OrganizationSerializer, SaksdokumentSerializer, UserFeedbackSerializer, + UserGangRoleSerializer, InterviewRoomSerializer, FoodPreferenceSerializer, UserPreferenceSerializer, @@ -82,6 +85,7 @@ ReservationCheckSerializer, UserForRecruitmentSerializer, RecruitmentPositionSerializer, + UserGangSectionRoleSerializer, RecruitmentStatisticsSerializer, RecruitmentForRecruiterSerializer, RecruitmentSeparatePositionSerializer, @@ -93,6 +97,7 @@ RecruitmentApplicationForRecruiterSerializer, RecruitmentApplicationUpdateForGangSerializer, RecruitmentShowUnprocessedApplicationsSerializer, + RecruitmentPositionSharedInterviewGroupSerializer, ) from .models.event import ( Event, @@ -137,6 +142,7 @@ RecruitmentApplication, RecruitmentSeparatePosition, RecruitmentInterviewAvailability, + RecruitmentPositionSharedInterviewGroup, ) from .models.model_choices import RecruitmentStatusChoices, RecruitmentPriorityChoices @@ -322,6 +328,22 @@ class RoleView(ModelViewSet): serializer_class = RoleSerializer queryset = Role.objects.all() + @action(detail=True, methods=['get']) + def users(self, request: Request, pk: int) -> Response: + role = get_object_or_404(Role, id=pk) + + org_roles = UserOrgRole.objects.filter(role=role).select_related('user', 'obj') + gang_roles = UserGangRole.objects.filter(role=role).select_related('user', 'obj') + section_roles = UserGangSectionRole.objects.filter(role=role).select_related('user', 'obj') + + org_data = UserOrgRoleSerializer(org_roles, many=True).data + gang_data = UserGangRoleSerializer(gang_roles, many=True).data + section_data = UserGangSectionRoleSerializer(section_roles, many=True).data + + combined = list(chain(org_data, gang_data, section_data)) + + return Response(combined) + # =============================== # # Sulten # @@ -727,7 +749,6 @@ def post(self, request: Request) -> Response: try: subject = request.data.get('subject') text = request.data.get('text') - recruitment = request.data.get('recruitment') if recruitment is None: return Response(status=status.HTTP_400_BAD_REQUEST) @@ -735,29 +756,34 @@ def post(self, request: Request) -> Response: # Only users who have never been contacted with an offer should get a rejection mail # Retrieve all users who has a non-withdrawn rejected application in current recruitment rejected_users = User.objects.filter( - recruitmentapplication__recruitment=recruitment, - recruitmentapplication__recruiter_status=RecruitmentStatusChoices.REJECTION, - recruitmentapplication__withdrawn=False, + applications__recruitment=recruitment, + applications__recruiter_status=RecruitmentStatusChoices.REJECTION, + applications__withdrawn=False, ) # Retrieve all users who have been contacted with an offer contacted_users = User.objects.filter( - recruitmentapplication__recruitment=recruitment, - recruitmentapplication__recruiter_status__in=[RecruitmentStatusChoices.CALLED_AND_ACCEPTED, RecruitmentStatusChoices.CALLED_AND_REJECTED], + applications__recruitment=recruitment, + applications__recruiter_status__in=[ + RecruitmentStatusChoices.CALLED_AND_ACCEPTED, + RecruitmentStatusChoices.CALLED_AND_REJECTED, + ], ) # Remove users who have been contacted with an offer from the rejected users list final_rejected_users = rejected_users.exclude(id__in=contacted_users.values('id')) - rejected_user_mails = list(final_rejected_users.values_list('email', flat=True)) + rejected_user_emails = list(final_rejected_users.values_list('email', flat=True)) - send_mail( - subject, - text, - settings.EMAIL_HOST_USER, - rejected_user_mails, - fail_silently=False, + email = EmailMessage( + subject=subject, + body=text, + from_email=settings.EMAIL_HOST_USER, + to=[], # Empty 'To' field since we're using BCC + bcc=rejected_user_emails, ) + + email.send(fail_silently=False) return Response(status=status.HTTP_200_OK) except Exception as e: return Response(str(e), status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -1105,6 +1131,20 @@ def get_queryset(self) -> Response: return Recruitment.objects.filter(visible_from__lte=timezone.now(), actual_application_deadline__gte=timezone.now()) +class RecruitmentInterviewGroupView(APIView): + permission_classes = [IsAuthenticated] + + def get( + self, + request: Request, + recruitment_id: int, + ) -> HttpResponse: + recruitment = get_object_or_404(Recruitment, id=recruitment_id) + interview_groups = RecruitmentPositionSharedInterviewGroup.objects.filter(recruitment=recruitment) + + return Response(data=RecruitmentPositionSharedInterviewGroupSerializer(interview_groups, many=True).data, status=status.HTTP_200_OK) + + class DownloadRecruitmentApplicationGangCSV(APIView): permission_classes = [IsAuthenticated] diff --git a/biome.jsonc b/biome.jsonc index 8df80d742..f8edeb4c7 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -33,8 +33,7 @@ }, "formatter": { "indentStyle": "space", - "lineWidth": 120, - "lineEnding": "lf" + "lineWidth": 120 }, "javascript": { "formatter": { diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..6c13d881c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,52 @@ +[**← 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/api-docs.md b/docs/api-docs.md index 963faf179..e3162ed87 100644 --- a/docs/api-docs.md +++ b/docs/api-docs.md @@ -1,3 +1,5 @@ +[**← Back: Documentation Overview**](./README.md) + # API docs API docs are generated by [drf-spectacular](https://drf-spectacular.readthedocs.io/en/latest/readme.html). @@ -6,14 +8,16 @@ API documentation is available as two different interfaces: [Swagger-UI](http://localhost:8000/schema/swagger-ui/#/) or [Redoc](http://localhost:8000/schema/redoc/) - - 🐋 _When backend server is running_ ## API schema file If you want a schema file for the API you can go to [http://localhost:8000/schema/](http://localhost:8000/schema/). -A schema file will be downloaded which can be used for multiple purposes, like sharing API documentation, or to generate code for recreating or testing the API. +A schema file will be downloaded which can be used for multiple purposes, like sharing API documentation, or to generate +code for recreating or testing the API. -> 💡 Note: You might encounter some error messages during this process. These errors are typically related to drf-spectacular not being able to parse certain views in views.py. However, the tool will still attempt to generate the documentation, though the results might not be fully comprehensive. +> [!NOTE] +> You might encounter some error messages during this process. These errors are typically related to drf-spectacular not +> being able to parse certain views in views.py. However, the tool will still attempt to generate the documentation, +> though the results might not be fully comprehensive. diff --git a/docs/common-errors.md b/docs/common-errors.md index 140e6662e..99deef8b1 100644 --- a/docs/common-errors.md +++ b/docs/common-errors.md @@ -1,3 +1,5 @@ +[**← Back: Documentation Overview**](./README.md) + # Common error messages ## Rule of thumb @@ -20,4 +22,4 @@ exec /app/entrypoint.sh: no such file or directory Cannot connect to the Docker daemon at ../../.../default/docker.sock. Is the docker daemon running? ``` ### Fix: -Make sure docker desktop is running (Windows) or run `colima start`on Mac. +Make sure docker desktop is running (Windows) or run `colima start` (or start Docker Desktop) on Mac. diff --git a/docs/dependencies.md b/docs/dependencies.md deleted file mode 100644 index 4ba44c4ae..000000000 --- a/docs/dependencies.md +++ /dev/null @@ -1,3 +0,0 @@ -## i18next - -Used for language translation diff --git a/docs/docker-project-specific-commands.md b/docs/docker-project-specific-commands.md index 4b2014d66..0b81ff628 100644 --- a/docs/docker-project-specific-commands.md +++ b/docs/docker-project-specific-commands.md @@ -1,6 +1,9 @@ -# Useful project spesific Docker actions -### For frontend actions -All commands has to be run inside a shell in a container. +[**← Back: Documentation Overview**](./README.md) + +# Useful project specific Docker actions + +## Frontend +All commands have to be run inside a shell in a container. ```bash docker compose exec frontend bash #Command to open the frontend container in a shell @@ -25,9 +28,11 @@ yarn run tsc:check #runs TypeScript Compiler check, like in GitHub Actions pipeline, but in Docker ``` -## For backend actions: +--- + +## Backend -All commands has to be run inside a shell in a container. +All commands have to be run inside a shell in a container. ```bash docker compose exec backend bash #Command to open container in a shell diff --git a/docs/editors/assets/biome_config.png b/docs/editors/assets/biome_config.png new file mode 100644 index 000000000..22dfea774 Binary files /dev/null and b/docs/editors/assets/biome_config.png differ diff --git a/docs/editors/assets/pycharm_add_interpreter.png b/docs/editors/assets/pycharm_add_interpreter.png new file mode 100644 index 000000000..797b396e2 Binary files /dev/null and b/docs/editors/assets/pycharm_add_interpreter.png differ diff --git a/docs/editors/assets/pycharm_interpreter_bar.png b/docs/editors/assets/pycharm_interpreter_bar.png new file mode 100644 index 000000000..17b6408f9 Binary files /dev/null and b/docs/editors/assets/pycharm_interpreter_bar.png differ diff --git a/docs/editors/emacs.md b/docs/editors/emacs.md new file mode 100644 index 000000000..22c80203e --- /dev/null +++ b/docs/editors/emacs.md @@ -0,0 +1,5 @@ +[**← Back: Getting started**](../introduction.md) + +# Emacs setup + +This guide hasn't been written yet. Maybe you want to? :-) diff --git a/docs/editors/jetbrains.md b/docs/editors/jetbrains.md new file mode 100644 index 000000000..5402037e9 --- /dev/null +++ b/docs/editors/jetbrains.md @@ -0,0 +1,90 @@ +[**← Back: Getting started**](../introduction.md) + +# JetBrains setup + +This will give some pointers on how to set up your JetBrains IDE (WebStorm/PyCharm). Luckily for you, there's not much +to be done, since JetBrains IDEs require very little configuration to be productive. + +Keep in mind that all the mentioned plugins are just recommended, they're not required to develop on the project. The +linters and formatters can be run through the terminal, but having them integrated in your IDE does make life a bit +easier. + +Contents: + +* [PyCharm](#pycharm) + * [Plugins](#plugins) + * [Python Interpreter](#python-interpreter) +* [WebStorm](#webstorm) + * [Plugins](#plugins-1) + * [Dependencies](#dependencies) + +--- + +## PyCharm + +### Plugins + +* [IdeaVim](https://plugins.jetbrains.com/plugin/164-ideavim) + * The most important one + * Add `set relativenumber` to `~/.ideavimrc` to get relative line numbering in your editor! + * You can open this file by clicking the "V" logo in the bottom right of your editor then `Open ~/.ideavimrc` +* [ruff](https://plugins.jetbrains.com/plugin/20574-ruff) + * Formatter/linter for Python + +### Python Interpreter + +Not having the correct interpreter selected in PyCharm can cause the IDE to not understand what Python version the +project is using, and it'll fail to resolve dependencies, causing a lot of red lines! If you are running the project in +Docker, you will also need to install dependencies locally, since the IDE doesn't check files inside the Docker +container. + +You can see what interpreter is currently selected in the bottom toolbar: + +![Currently selected interpreter](./assets/pycharm_interpreter_bar.png) + +To create an interpreter, click the button on the toolbar shown above, +then `Add new interpreter -> Add Local Interpreter...`. + +Select `Samfundet4/backend/.venv` as the location, and select the correct Python version as the Base interpreter. If +your system's Python version differs from what Samfundet4 expects (3.11 at the time of writing this), then you can +use [pyenv](https://github.com/pyenv/pyenv) to easily download another version. Then click OK to add it. + +![Add interpreter](./assets/pycharm_add_interpreter.png) + +After the interpreter has been created and selected, you can then install the dependencies inside the virtual +environment: + +```bash +~/Samfundet4 » source .venv/bin/activate +(.venv) ~/Samfundet4 » poetry install +``` + +--- + +## WebStorm + +### Plugins + +* [IdeaVim](https://plugins.jetbrains.com/plugin/164-ideavim) + * The most important one + * Add `set relativenumber` to `~/.ideavimrc` to get relative line numbering in your editor! + * You can open this file by clicking the "V" logo in the bottom right of your editor then `Open ~/.ideavimrc` +* [Biome](https://plugins.jetbrains.com/plugin/22761-biome) + * Formatter/linter for frontend code. + * Below is the recommended configuration (`Settings -> Language & Frameworks -> Biome`). It'll automatically format + and apply safe fixes on save (which in the + JetBrains world means when you tab/switch windows) + ![Biome configuration](./assets/biome_config.png) + +### Dependencies + +If you are running the project in Docker, you will also need to install dependencies locally, since the IDE doesn't +check files inside the Docker container. + +To do so, ensure you have [node](https://nodejs.org/en) and [yarn](https://classic.yarnpkg.com/lang/en/docs/install/) +installed. Then simply run yarn to install the dependencies. + +```bash +~/Samfundet4 » cd frontend +~/Samfundet4/frontend » yarn +``` diff --git a/docs/editors/vim.md b/docs/editors/vim.md new file mode 100644 index 000000000..ea79eb65e --- /dev/null +++ b/docs/editors/vim.md @@ -0,0 +1,5 @@ +[**← Back: Getting started**](../introduction.md) + +# Vim setup + +This guide hasn't been written yet. Maybe you want to? :-) diff --git a/docs/editors/vscode.md b/docs/editors/vscode.md new file mode 100644 index 000000000..a300794e4 --- /dev/null +++ b/docs/editors/vscode.md @@ -0,0 +1,5 @@ +[**← Back: Getting started**](../introduction.md) + +# VS Code setup + +This guide hasn't been written yet. Maybe you want to? :-) diff --git a/docs/install/git-setup.md b/docs/install/git-setup.md new file mode 100644 index 000000000..ef1054dc0 --- /dev/null +++ b/docs/install/git-setup.md @@ -0,0 +1,41 @@ +[**← Back: Getting started**](../introduction.md) + +> [!WARNING] +> This guide is not complete! Feel free to submit a PR to improve it :-) + +# Git setup + +Git is a Version Control System (VCS). You're required to set up Git in order to be able to pull and push to the +Samfundet4 project. + +## Creating an SSH key + +
+Windows +
+ +
+Linux/MacOS/WSL + +In your terminal, run `ssh-keygen` + +This will generate two files: `~/.ssh/id_rsa` and `~/.ssh/id_rsa.pub`. +
+ +## Adding it to GitHub + +Copy the contents of the `id_rsa.pub` file and go to the [SSH and GPG keys](https://github.com/settings/keys) GitHub +settings page. Click the green "New SSH key" button, paste the file contents in the big text box, and click "Add SSH +key". + +> [!WARNING] +> Ensure you copy the right file. `id_rsa` is a private key, never meant to be shared with anyone, unlike `id_rsa.pub`. + +## Configuring Git + +You can configure Git both locally and globally. Locally meaning your configuration only applies to a specific +directory (i.e. project), or globally for all directories. Local configuration overrides global configuration. + +## Further reading + +Want to git gud to become a git god? diff --git a/docs/install/install-script.md b/docs/install/install-script.md new file mode 100644 index 000000000..f6297fb1c --- /dev/null +++ b/docs/install/install-script.md @@ -0,0 +1,44 @@ +[**← Back: Getting started**](../introduction.md) + +> [!WARNING] +> This script has not been maintained in a while and may not work. + +# Install script + +We have a script that handles all installation for you. To run the script, a Github Personal Access Token (PAT) is +required. +You can make one here https://github.com/settings/tokens/new. Tick scopes `repo`, `read:org` and `admin:public_key`), +then store the token somewhere safe (Github will never show it again). + +Copy these commands (press button on the right-hand side of the block) +and run from the directory you would clone the project. + +```sh +# Interactive +read -s -p "Github PAT token: " TOKEN ; X_INTERACTIVE=y /bin/bash -c "$(curl -fsSL https://$TOKEN@raw.githubusercontent.com/Samfundet/Samfundet4/master/{bash_utils.sh,install.sh})" && . ~/.bash_profile && cd Samfundet4; unset TOKEN; unset X_INTERACTIVE; +``` + +
+Non-interactive (show/hide) + +```sh +# Non-interactive +read -s -p "Github PAT token: " TOKEN ; X_INTERACTIVE=n /bin/bash -c "$(curl -fsSL https://$TOKEN@raw.githubusercontent.com/Samfundet/Samfundet4/master/{bash_utils.sh,install.sh})" && . ~/.bash_profile && cd Samfundet4; unset TOKEN; unset X_INTERACTIVE; +``` + + +
+ +
+Flags explained (show/hide) + +> - X_INTERACTIVE (y/n): determines how many prompts you receive before performing an action. + > curl: +> - -f: fail fast +> - -s: silent, no progress-meter +> - -S: show error on fail +> - -L: follow redirect + +
diff --git a/docs/install/linux-docker.md b/docs/install/linux-docker.md new file mode 100644 index 000000000..0cdd96296 --- /dev/null +++ b/docs/install/linux-docker.md @@ -0,0 +1,14 @@ +[**← Back: Getting started**](../introduction.md) + +> [!WARNING] +> This guide is not complete! Feel free to submit a PR to improve it :-) + +# Installing on Linux (Docker) + +## Post-install + +Now that you've got the project up and running, check out the post-install instructions: + +

+→ Next: Post-install +

diff --git a/docs/install/linux-native.md b/docs/install/linux-native.md new file mode 100644 index 000000000..272d30990 --- /dev/null +++ b/docs/install/linux-native.md @@ -0,0 +1,14 @@ +[**← Back: Getting started**](../introduction.md) + +> [!WARNING] +> This guide is not complete! Feel free to submit a PR to improve it :-) + +# Installing on Linux (Native) + +## Post-install + +Now that you've got the project up and running, check out the post-install instructions: + +

+→ Next: Post-install +

diff --git a/docs/install/mac-docker.md b/docs/install/mac-docker.md new file mode 100644 index 000000000..51d814545 --- /dev/null +++ b/docs/install/mac-docker.md @@ -0,0 +1,60 @@ +[**← Back: Getting started**](../introduction.md) + +# Installing on MacOS (Docker) + +## Requirements + +* [Homebrew](https://docs.brew.sh/Installation) +* [colima](https://github.com/abiosoft/colima?tab=readme-ov-file#getting-started) + or [Docker Desktop](https://www.docker.com/products/docker-desktop/) + * colima can be more performant than Docker Desktop, but is less easy to use + +## Installing + +First clone the Samfundet4 repository. + +```bash +git clone git@github.com:Samfundet/Samfundet4.git +``` + +If you use colima, run `colima start` to start the engine. + +## Environment files + +Both the `frontend` and `backend` directories contain a `.docker.example.env` file. Copy these files to `.docker.env` +and adjust any values as needed. You may for example want to change the default Django superuser username and +password (`DJANGO_SUPERUSER_USERNAME` and `DJANGO_SUPERUSER_USERNAME`). + +## Building + +This builds all the Samfundet4 containers: + +```bash +cd Samfundet4 +docker compose build + +# You can also choose to build only specific containers if you want: +docker compose build frontend backend +``` + +## Running + +This will start the `backend` and `frontend` containers: + +```bash +docker compose up backend frontend +``` + +## Dependency issues? + +Editors/IDEs typically don't have access to installed dependencies which lie inside Docker containers. This means you +may have to install dependencies locally too, in order for your editor/IDE to resolve them. See +the [Editor configuration](../introduction.md#editor-configuration) guide for more information. + +## Post-install + +Now that you've got the project up and running, check out the post-install instructions: + +

+→ Next: Post-install +

diff --git a/docs/install/mac-native.md b/docs/install/mac-native.md new file mode 100644 index 000000000..256f79059 --- /dev/null +++ b/docs/install/mac-native.md @@ -0,0 +1,73 @@ +[**← Back: Getting started**](../introduction.md) + +# Installing on MacOS (Native) + +## Requirements + +* [Homebrew](https://docs.brew.sh/Installation) + * MacOS package manager +* [Poetry](https://python-poetry.org/docs/) + * Backend dependency manager +* [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable) + * Frontend dependency manager +* [pyenv](https://github.com/pyenv/pyenv) + * Python version manager. Lets you easily install the same Python version that Samfundet4 expects. + +## Installing + +First clone the Samfundet4 repository. + +```bash +git clone git@github.com:Samfundet/Samfundet4.git +``` + +Install the frontend dependencies + +```bash +cd Samfundet4/frontend +yarn +``` + +Install the backend dependencies + +```bash +cd ../backend +poetry install +``` + +Then apply migrations and run seed script (the seed script adds test data to our database) + +```bash +poetry run python3 manage.py migrate +poetry run python3 manage.py seed +``` + +## Environment files + +Both the `backend` and `frontend` directories have an `.env.example` file. In each directory, copy this file to `.env` +and adjust any values as needed. You may for example want to change the default Django superuser username and +password (`DJANGO_SUPERUSER_USERNAME` and `DJANGO_SUPERUSER_USERNAME`). + +## Running + +Start backend: + +```bash +cd backend +poetry run python3 manage.py runserver +``` + +Start frontend: + +```bash +cd frontend +yarn start +``` + +## Post-install + +Now that you've got the project up and running, check out the post-install instructions: + +

+→ Next: Post-install +

diff --git a/docs/install/post-install.md b/docs/install/post-install.md new file mode 100644 index 000000000..c20b51c15 --- /dev/null +++ b/docs/install/post-install.md @@ -0,0 +1,36 @@ +[**← Back: Getting started**](../introduction.md) + +# Post-install + +You've now (hopefully) successfully installed and started the Samfundet4 project! What now? + +We recommend spending some time ensuring your editor/IDE is properly configured. This is of course a very subjective +topic, but we give some pointers in the [Editor configuration](../introduction.md#editor-configuration) section. + +After you've set up your editor/IDE, we recommend diving in head-first and just picking +an [issue](https://github.com/Samfundet/Samfundet4/issues) you'd like to solve. Be sure to have +the [Documentation Overview](../README.md) open and ready for *when* you get stuck :-) If you find that some parts of +the documentation are lacking, don't be afraid to create a PR to fix it! + +## Resetting the database + +You'll likely encounter a situation where you'd like to "reset" the database, by deleting all its data and seeding it +again. It's quite easy to do this, the first step is to stop the backend server. Then in the `backend/database` +directory, delete either the `db.sqlite3` if you're running native (or WSL), or the `docker.db.sqlite3` file if you're +running in Docker. + +Then simply start the backend server again. This will automatically create the database file and seed it automatically. + +## Resetting migrations + +If you've done some work in backend and changed/created any models, you'll also have created migration files. You'll +occasionally encounter a situation where you and another developer have both commited migrations with the same number. +Typically the other developer will have gotten their migration file merged to master, resulting in a number conflict in +your branch. + +The easiest way to fix this is to simply delete the migration file you have created, and running the `makemigrations` +command again: + +* Docker: `docker compose exec backend bash` + * Then run the same Poetry command as in the line below +* Native: `poetry run python3 manage.py makemigrations` diff --git a/docs/install/windows-docker.md b/docs/install/windows-docker.md new file mode 100644 index 000000000..14347c924 --- /dev/null +++ b/docs/install/windows-docker.md @@ -0,0 +1,34 @@ +[**← Back: Getting started**](../introduction.md) + +> [!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 + +To run the project in WSL, you obviousy first need WSL. +Follow [this guide](https://learn.microsoft.com/en-us/windows/wsl/install) by Microsoft. The main step is running the +following in an administrator PowerShell or command prompt: + +```shell +wsl --install +``` + +From this point on, any commands you are instructed to run, are meant to be run inside WSL unless otherwise specified. + +## Install Docker + +Next, install docker. Follow [this guide](https://docs.docker.com/desktop/install/windows-install/). + +## Post-install + +Now that you've got the project up and running, check out the post-install instructions: + +

+→ Next: Post-install +

diff --git a/docs/install/windows-wsl.md b/docs/install/windows-wsl.md new file mode 100644 index 000000000..20a69c3e5 --- /dev/null +++ b/docs/install/windows-wsl.md @@ -0,0 +1,32 @@ +[**← Back: Getting started**](../introduction.md) + +# Installing on Windows (WSL) + +> [!WARNING] +> When cloning the project, please ensure you do **not** do it inside OneDrive. Working with the project inside OneDrive +> will be incredibly slow in all aspects. + +## Introduction + +The Windows Subsystem of Linux (WSL) lets you run programs which traditionally only run in Linux. + +## Install WSL + +To run the project in WSL, you obviousy first need WSL. +Follow [this guide](https://learn.microsoft.com/en-us/windows/wsl/install) by Microsoft. The main step is running the +following in an administrator PowerShell or command prompt: + +```shell +wsl --install +``` + +This should install WSL with the Ubuntu distribution. + +## Next steps + +Since you now have a working Linux environment, the remaining steps of setting up the project are identical with the +Linux guide, so click the link below to continue the process. + +

+→ Next: Installing on Linux (Native) +

diff --git a/docs/intervew-scheduling.md b/docs/intervew-scheduling.md new file mode 100644 index 000000000..2e2af558d --- /dev/null +++ b/docs/intervew-scheduling.md @@ -0,0 +1,68 @@ +[**← Back: Documentation Overview**](./README.md) + +> [!NOTE] +> This document is a work in progress. + +# Automatic Interview Scheduling + +The aim of this document is to describe the Automatic Interview Scheduling system of Samfundet4. + +It's hard to describe concisely how the system works, so this document contains a few different user stories, describing +how each user role would interact with the system. This will hopefully help you get a better understanding of how the +system works. There's probably going to be a bit of unnecessary rambling in this document, feel free to submit a PR to +fix that:) + +First off, this system is **not** meant to be fully automatic, but semi-automatic. We don't want there to be a lot of +magic happening in the background which nobody understands, and we don't want a system that makes changes without user +interaction. Having a system that automatically schedules interviews without any supervision sounds like a recipe for +disaster. + +The goal is to help recruitment admins save time, by +automatically scheduling interviews, something which notoriously takes a long time to do manually. There are so many +edge cases in scheduling interviews, so it's important that the system is intuitive and easy to use. + +The system must also allow each gang to use the scheduling algorithm of their choosing. + +## Admin's perspective + +1. Navigate to my gang's recruitment overview page and click on a position. +2. Hit the "Automatically schedule interviews" button. +3. We are shown a dialog (or page), with a list of interviews the algorithm has suggested. + 1. Each interview suggestion shows the date, time, and partaking interviewers + 2. We are able to manually edit these interviews if we wish. This will allow us to manually edit the date, time, + location and partaking interviewers. +4. We click the submit button, which saves the interviews and sends out emails to affected applicants and interviewers, + notifying them of the upcoming interview. + 1. The email must not contain sensitive information, it should only contain the name of the gang/section/position + the applicant has applied for, as well as the date, time, and location of the interview. + +## Interviewer's perspective + +## User's perspective + +## Algorithms + +Owner refers to either the Gang or Section which owns the position. + +### Samfundet + +1. Fetch all unscheduled interviews for this position. +2. Fetch all rooms booked by Owner (if any) +3. Fetch unavailability data for all interviewers and applicants +4. For each unscheduled interview, do: + 1. + +### UKA/ISFiT + +1. + +### ISFiT + +1. + +## Race conditions and conflict + +The scheduling algorithms described above are very prone to race conditions and conflict if running them at the same +time, which isn't unthinkable since we want to allow multiple people to work on the recruitment system simultaneously. +We solve this by only allowing the interview scheduling to be run by a single process. To run the scheduler, we send a +request to add scheduling for a given position to the queue. The process fetches tasks from the queue and executes them. diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 000000000..5d5f5d90c --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,36 @@ +[**← Back: Documentation Overview**](./README.md) + +# Introduction to Samfundet4 + +Welcome to Samfundet4! This guide will introduce you to the technologies used in the project, and guide you through +installing the project on your machine. + +## Technologies + +Samfundet4 is built using [Django](https://www.djangoproject.com/) and [React](https://react.dev/). Django is a Python +framework, which we use as our backend. React is a library for building frontend applications, and we use it with +the [TypeScript](https://www.typescriptlang.org/) language. + +For a more in-depth introduction to all the technologies we use, check out this +document: [Technologies used in Samfundet4](./technical/Samf4Tech.md) + +## Installation + +There are multiple ways of running this project, all with their own pros and cons. Running with +Docker is likely the easiest, but will require some initial setup and tweaking depending on your system. + +Below is a set of install guides for the various methods of installing and running the project, depending on your OS. If +you're not sure which to pick, just ask someone in MG::Web! + +- 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/technical/README.md b/docs/technical/README.md deleted file mode 100644 index 75f09d871..000000000 --- a/docs/technical/README.md +++ /dev/null @@ -1,22 +0,0 @@ - -[👈 back](/README.md) - -# Samfundet4 - Technical Documentation - - - -### Frontend - -- [Creating react components (conventions)](/docs/technical/frontend/components.md) -- [Forms and schemas](/docs/technical/frontend/forms.md) -- [Cypress Setup Documentation](/docs/technical/frontend/cypress.md) -- [Data fetching](./frontend/data-fetching.md) - -### Backend - -- [Billig (payment system)](/docs/technical/backend/billig.md) -- [Seed scripts](/docs/technical/backend/seed.md) -- [Role System](/docs/technical/backend/rolesystem.md) - -### Pipelines & Deployment -- [Pipeline (mypy, biome, tsc, ...)](/docs/technical/pipeline.md) diff --git a/docs/technical/Samf4Tech.md b/docs/technical/Samf4Tech.md index defd33130..ecbf8ec2d 100644 --- a/docs/technical/Samf4Tech.md +++ b/docs/technical/Samf4Tech.md @@ -1,6 +1,6 @@ -[👈 back](/README.md) +[**← Back: Introduction to Samfundet4**](../introduction.md) -# Technologies used on Samfundet4 +# Technologies used in Samfundet4 This text aims to both sum up the main technologies used to develop on Samfundet4 and to get a person with minimal webdev experience up to speed on the most important concepts. There is a lot this text does not cover, which might be found in other docs linked to in the [README](/README.md). diff --git a/docs/technical/backend/billig.md b/docs/technical/backend/billig.md index 321743951..26701c339 100644 --- a/docs/technical/backend/billig.md +++ b/docs/technical/backend/billig.md @@ -1,4 +1,4 @@ -[👈 back](/docs/technical/README.md) +[**← Back: Documentation Overview**](../../README.md) # Billig Integration diff --git a/docs/technical/backend/rolesystem.md b/docs/technical/backend/rolesystem.md index 833e73633..1b94d38a6 100644 --- a/docs/technical/backend/rolesystem.md +++ b/docs/technical/backend/rolesystem.md @@ -1,4 +1,6 @@ -# Role System +[**← Back: Documentation Overview**](../../README.md) + +# Role system The role system in Samfundet4 builds on the Django "authentication backend" concept. Our system adds a [custom auth backend](https://docs.djangoproject.com/en/5.0/topics/auth/customizing/). The goal of the system is to diff --git a/docs/technical/backend/seed.md b/docs/technical/backend/seed.md index 23c084427..c8bb318f5 100644 --- a/docs/technical/backend/seed.md +++ b/docs/technical/backend/seed.md @@ -1,4 +1,4 @@ -[👈 back](/docs/technical/README.md) +[**← Back: Documentation Overview**](../../README.md) # Seeding diff --git a/docs/technical/frontend/components.md b/docs/technical/frontend/components.md index 16edd34ff..90d23b7a6 100644 --- a/docs/technical/frontend/components.md +++ b/docs/technical/frontend/components.md @@ -1,4 +1,4 @@ -[👈 back](/docs/technical/README.md) +[**← Back: Documentation Overview**](../../README.md) # Components diff --git a/docs/technical/frontend/cypress.md b/docs/technical/frontend/cypress.md index fcdc7e0f2..41f17a61e 100644 --- a/docs/technical/frontend/cypress.md +++ b/docs/technical/frontend/cypress.md @@ -1,3 +1,5 @@ +[**← Back: Documentation Overview**](../../README.md) + # Cypress Setup Documentation This document outlines the steps for setting up Cypress in your project. Cypress is an end-to-end testing framework designed to make it easy to write and run tests for web applications. This guide will cover how to set up Cypress both in a Docker container and locally on your machine. diff --git a/docs/technical/frontend/data-fetching.md b/docs/technical/frontend/data-fetching.md index ebb5b9245..d402a06e2 100644 --- a/docs/technical/frontend/data-fetching.md +++ b/docs/technical/frontend/data-fetching.md @@ -1,19 +1,130 @@ -# Data fetching +[**← Back: Documentation Overview**](../../README.md) -We use the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) (built into browsers) to fetch data. -The `fetch` function is quite simple to use. It returns -a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that resolves to -the response of the request. +# Data fetching and State management + +We use the [Axios HTTP client](https://axios-http.com/docs/intro) to fetch data. It's quite simple to use, returning a +[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) resolving to the +response of the request. We use [React Query](https://tanstack.com/query/v3) for state management. State management in React is notoriously hard to get 100% right and safe, which is why using a library such as RQ is a good idea. It saves us from a lot of potential common bugs and headaches with managing state all by ourselves. -If you're not convinced, read this [great article](https://tkdodo.eu/blog/why-you-want-react-query) by TkDodo on Why You -~~Want~~ Need React Query. It explores a lot of common pitfalls/bugs. At the time of me writing this, these +If you're not convinced, read this [great article](https://tkdodo.eu/blog/why-you-want-react-query) by TkDodo on *Why +You ~~Want~~ Need React Query*. It explores a lot of common pitfalls/bugs. At the time of me writing this, these pitfalls/bugs are found absolutely everywhere we do data fetching in Samfundet4. Hopefully over time, we will replace these instances with safe state management using RQ. ## Getting started +So how do these two libraries hang together in our project? Well, we typically start by writing a very simple function +which only contains an Axios call to send a request and return the data from the response. We do this +in `frontend/src/api.ts`. Then in our components and pages and +whatnot, we use React Query to be responsible for actually calling this function when needed. + +To get started, in our component/page, call the `useQuery` hook like so, providing a query key and the query function: + +```ts +const { data, isLoading, isError } = useQuery({ + queryKey: ['informationpages'], + queryFn: getInformationPages, +}); +``` + +The project has a single global *Query Client*. This acts kind of like a global request and response cache. It contains +all the data fetched by the `useQuery` hook. You can think of it as a simple Key-Value store. In the above example, the +data fetched by the `getInformationPages` function is stored by the Query Client using the query key. The query key +therefore needs to be unique for the data we're fetching. + +## Query key factory + +Since it's extremely important that the query keys are unique, we employ a "query key factory" to generate them for us. +The factories are all defined in one place ([queryKeys.ts](../../../frontend/src/queryKeys.ts)), which should eliminate +the possibility of key collisions. Here are some examples of how to use the factories: + +```ts +// Get all information pages +const { data } = useQuery({ + queryKey: infoPageKeys.all, + queryFn: getInformationPages, +}); +``` + +```ts +// Get specific user +const { data } = useQuery({ + queryKey: userKeys.detail(userId), + queryFn: () => getUser(userId), +}); +``` + +```ts +// Get information pages with filter +const { data } = useQuery({ + queryKey: infoPageKeys.list([search, page]), + queryFn: () => getInformationPages(search, page), +}); +``` + +## Invalidating data + +Sometimes we need to invalidate data which has been fetched. An example case is when we create and save a new object, an +information page for instance, we want to clear our "information pages" cache, so that when we request all information +pages again, the data returned includes the new object. + +Doing this is quite simple with the query key factory, and we are able to invalidate on multiple levels of granularity: + +```ts +// Invalidate absolutely all information page data +queryClient.invalidateQueries({ + queryKey: infoPageKeys.all +}); + +// Invalidate all information page lists +queryClient.invalidateQueries({ + queryKey: infoPageKeys.lists() +}); + +// Invalidate a specific information page's data +queryClient.invalidateQueries({ + queryKey: infoPageKeys.detail(id) +}); +``` + +## Error handling + +We have a very simple error handler defined in the *Query Client*. If the query function returns an error (for instance, +HTTP 500), we will log it as an error to the console, and display a toast with an error message. This error message is +by default generic (i.e. "Something went wrong!"), but it can be overwritten by the useQuery-caller if desired. We do +this using the `meta` and `errorMsg` options in the useQuery hook. + +```ts +const { data, isLoading, isError } = useQuery({ + queryKey: infoPageKeys.all, + queryFn: getInformationPages, + meta: { + errorMsg: "We couldn't find the pages!" + } +}); +``` + +In the example above, if `getInformationPages` raises an error, we'll get a toast with "We couldn't find the pages!". +Note that you can (and should) use translations here as well: + +```ts +const { data, isLoading, isError } = useQuery({ + queryKey: infoPageKeys.all, + queryFn: getInformationPages, + meta: { + errorMsg: t(KEY.something_something) + } +}); +``` + +## Further reading + +React Query doesn't only have to be used for API calls, we also use it for other async tasks. + Please check out the [RQ docs Quick Start](https://tanstack.com/query/latest/docs/framework/react/quick-start). + +Also check out TkDodo's (RQ maintainer) [blog posts](https://tkdodo.eu/blog/practical-react-query) diff --git a/docs/technical/frontend/forms.md b/docs/technical/frontend/forms.md index 4be7686dd..fca7ee760 100644 --- a/docs/technical/frontend/forms.md +++ b/docs/technical/frontend/forms.md @@ -1,4 +1,4 @@ -[👈 back](/docs/technical/README.md) +[**← Back: Documentation Overview**](../../README.md) # Forms @@ -39,7 +39,7 @@ To get started, create a new file, for example `YourForm.tsx`. This file will co itself. Define a schema using zod. Remember to reuse fields when possible as mentioned in the section above (we won't do this here for example's sake). -```typescript jsx +```ts import { z } from 'zod'; const schema = z.object({ @@ -51,16 +51,16 @@ Create your form component, and use the `useForm` hook to create the form. Create the form component, and use the `useForm` hook with your schema,. -```typescript jsx +```jsx export function YourForm() { // 1. Define the form - const form = useForm>({ + const form = useForm < z.infer < typeof schema >> ({ resolver: zodResolver(schema), defaultValues: { username: '', }, }); - + // 2. Define the submit handler function onSubmit(values: z.infer) { // These values are type-safe and validated @@ -71,7 +71,7 @@ export function YourForm() { Now use the `Form` wrapper components to build our form. -```typescript jsx +```jsx export function YourForm() { // ... @@ -84,6 +84,7 @@ export function YourForm() { render={({ field }) => ( Username + Pick wisely, this cannot be changed later! @@ -97,6 +98,143 @@ export function YourForm() { } ``` +## Files + +Defining a schema type for files is a bit more complicated. Below is an example which defines a schema with an +optional `avatar` file field. + +```jsx +const schema = z.object({ + image_file: z + .instanceof(File) + .refine((file) => file.size < 1024 * 1024 * 2, { + message: "File can't be larger than 2 MB" + }) + .nullable(), +}); +``` + +And in the form below. Please note that this input must +be [uncontrolled](https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components), so +we do not set `value` on it. We must also extract relevant information from the `onChange` event. In the example below, +we only want a single file, so we return the first item in the `FileList`. + +```jsx + ( + + + onChange(event.target.files?.[0])} + {...fieldProps} + /> + + + + )} +/> +``` + +## Numbers + +All HTML input values are strings. If we require a number type from an input, we must therefore convert it, as well as +deal with all non-numeric input. This can quickly become cumbersome using just the Input component. Luckily we have the +NumberInput component which does all this for us. + +```jsx + ( + + Duration + + + + + + )} +/> +``` + +## Dropdown + +Dropdowns can be used +either [controlled or uncontrolled](https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components). +If you provide `value` to Dropdown, it'll be controlled. If you don't, it will be uncontrolled. + +```ts +const options: DropdownOption[] = [ + { label: 'Samfundet', value: 'samfundet' }, + { label: 'UKA', value: 'uka' }, + { label: 'ISFiT', value: 'isfit' }, +]; +``` + +Controlled: + +```jsx + ( + + Organization + Which organization does this object belong to? + + + + + )} +> + +``` + +Uncontrolled: + +```jsx + ( + + Organization + Which organization does this object belong to? + + + + + )} +> + +``` + +You can also add a "null option". This is a blank option which is added to the top of the dropdown list. This is useful +if you need the Dropdown to be optional. The label of the null option can be customized, and it can also be disabled in +order to force users to select another option. If the null option is selected, an italic font style is applied to the +dropdown, to further indicate that a special option is selected. Examples of some possibilities below: + +```jsx +// Add a simple blank null option + +``` + +```jsx +// Null option with custom label + +``` + +```jsx +// Disabled null option with custom label + +``` + + ## Example To see an example form in action, check out the form on the [components page](http://localhost:3000/components), diff --git a/docs/technical/frontend/samfform.md b/docs/technical/frontend/samfform.md index 0132e1e3b..75ed8c0cf 100644 --- a/docs/technical/frontend/samfform.md +++ b/docs/technical/frontend/samfform.md @@ -1,4 +1,7 @@ -[👈 back](/docs/technical/README.md) +[**← Back: Documentation Overview**](../../README.md) + +> [!WARNING] +> SamfForm is deprecated, and will slowly be replaced with [our wrappers](./forms.md) around React Hook Form. # SamfForm diff --git a/docs/technical/meme.jpeg b/docs/technical/meme.jpeg deleted file mode 100644 index 533857b25..000000000 Binary files a/docs/technical/meme.jpeg and /dev/null differ diff --git a/docs/technical/pipeline.md b/docs/technical/pipeline.md index cc32770c0..265c01305 100644 --- a/docs/technical/pipeline.md +++ b/docs/technical/pipeline.md @@ -1,3 +1,5 @@ +[**← Back: Documentation Overview**](../README.md) + # Pipelines Is your PR not passing the pipeline checks? Look no further. @@ -18,7 +20,7 @@ _Run Biome_ yarn biome:ci ``` -_fix biome_ +_Run Biome fix_ ``` yarn biome:fix diff --git a/docs/useful-commands.md b/docs/useful-commands.md index e43aef7eb..5433adaea 100644 --- a/docs/useful-commands.md +++ b/docs/useful-commands.md @@ -1,4 +1,4 @@ -[👈 back](/README.md) +[**← Back: Documentation Overview**](./README.md) # Useful commands @@ -206,6 +206,8 @@ python -m poetry run python manage.py collectstatic
+Be sure to check out the documentation for [Docker command aliases](./docker-project-specific-commands.md). + ### 🐳 Docker: Run command inside container > `` is defined under `services` in [docker-compose.yml](/docker-compose.yml). ```bash diff --git a/docs/work-methodology.md b/docs/work-methodology.md index 1894e3f7c..43d66d013 100644 --- a/docs/work-methodology.md +++ b/docs/work-methodology.md @@ -1,4 +1,4 @@ -[👈 back](/README.md) +[**← Back: Documentation Overview**](./README.md) # Work methodology diff --git a/frontend/.stylelintrc b/frontend/.stylelintrc index f21a2335d..74d014423 100644 --- a/frontend/.stylelintrc +++ b/frontend/.stylelintrc @@ -80,7 +80,7 @@ "declaration-block-single-line-max-declarations": null, "declaration-property-max-values": null, "max-nesting-depth": [ - 1, + 2, { "ignoreAtRules": [""], "ignore": ["pseudo-classes", "blockless-at-rules"] diff --git a/frontend/src/Components/Button/Button.tsx b/frontend/src/Components/Button/Button.tsx index 012bbef08..d259b547f 100644 --- a/frontend/src/Components/Button/Button.tsx +++ b/frontend/src/Components/Button/Button.tsx @@ -5,7 +5,7 @@ import styles from './Button.module.scss'; import type { ButtonDisplay, ButtonTheme } from './types'; import { displayToStyleMap, themeToStyleMap } from './utils'; -type ButtonProps = { +export type ButtonProps = { name?: string; theme?: ButtonTheme; display?: ButtonDisplay; diff --git a/frontend/src/Components/Button/index.ts b/frontend/src/Components/Button/index.ts index b8ca3aaac..c7cf1a21a 100644 --- a/frontend/src/Components/Button/index.ts +++ b/frontend/src/Components/Button/index.ts @@ -1,2 +1,3 @@ export { Button } from './Button'; +export type { ButtonProps } from './Button'; export type { ButtonDisplay, ButtonTheme } from './types'; diff --git a/frontend/src/Components/Carousel/Carousel.module.scss b/frontend/src/Components/Carousel/Carousel.module.scss index 3c1f6d60e..05626f115 100644 --- a/frontend/src/Components/Carousel/Carousel.module.scss +++ b/frontend/src/Components/Carousel/Carousel.module.scss @@ -74,11 +74,11 @@ transition: 0.2s; cursor: pointer; - /* stylelint-disable-next-line selector-max-class, max-nesting-depth */ + /* stylelint-disable-next-line selector-max-class */ &.left { left: 1em; } - /* stylelint-disable-next-line selector-max-class, max-nesting-depth */ + /* stylelint-disable-next-line selector-max-class */ &.right { right: 3em; } diff --git a/frontend/src/Components/Checkbox/Checkbox.tsx b/frontend/src/Components/Checkbox/Checkbox.tsx index 6f6cbc158..69c0a65bb 100644 --- a/frontend/src/Components/Checkbox/Checkbox.tsx +++ b/frontend/src/Components/Checkbox/Checkbox.tsx @@ -8,6 +8,7 @@ export type CheckboxProps = { checked?: boolean; className?: string; onChange?: (...event: unknown[]) => void; + readOnly?: boolean; }; export const Checkbox = React.forwardRef(({ className, ...props }, ref) => { diff --git a/frontend/src/Components/CommandMenu/CommandMenu.scss b/frontend/src/Components/CommandMenu/CommandMenu.scss index 7b3e02660..b5fbc994b 100644 --- a/frontend/src/Components/CommandMenu/CommandMenu.scss +++ b/frontend/src/Components/CommandMenu/CommandMenu.scss @@ -1,6 +1,5 @@ /* stylelint-disable selector-max-compound-selectors */ /* stylelint-disable selector-max-combinators */ -/* stylelint-disable max-nesting-depth */ @import 'src/constants'; @import 'src/mixins'; diff --git a/frontend/src/Components/DatePicker/DatePicker.module.scss b/frontend/src/Components/DatePicker/DatePicker.module.scss new file mode 100644 index 000000000..3cd9f31c3 --- /dev/null +++ b/frontend/src/Components/DatePicker/DatePicker.module.scss @@ -0,0 +1,78 @@ +@use 'sass:color'; + +/* stylelint-disable-next-line no-invalid-position-at-import-rule */ +@import 'src/constants'; + +/* stylelint-disable-next-line no-invalid-position-at-import-rule */ +@import 'src/mixins'; + +.container { + position: relative; + width: 260px; +} + +.button { + @include rounded-lighter; + display: flex; + gap: 0.25rem; + align-items: center; + width: 100%; + justify-content: flex-start; + font-size: 0.875rem; + padding: 0.75rem 2.5rem 0.75rem 1rem; + color: $black; + cursor: pointer; + margin-top: 0.5em; // Make sure this is the same for all inputs that should be used together + border: 1px solid $grey-35; + background-color: $white; + font-weight: initial; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + background-color: $grey-4; + } + + &:focus { + border-color: $grey-3; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1); + outline: 1px solid rgba(0, 0, 0, 0.1); + } + + @include theme-dark { + background-color: $theme-dark-input-bg; + color: white; + border-color: $grey-0; + &:focus { + border-color: $grey-1; + outline: 1px solid rgba(255, 255, 255, 0.6); + } + &:hover { + background-color: color.scale($theme-dark-input-bg, $lightness: 8%); + } + } +} + +.popover { + position: absolute; + left: 0; + top: 100%; + margin-top: 4px; + padding: 0.25rem; + background: $white; + border-radius: 0.5rem; + z-index: 100; + //box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.1); + //box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.1) 0px 2px 4px -2px; + border: 1px solid $grey-35; + + @include theme-dark { + background: $black-1; + border-color: $grey-0; + } +} + +.hidden { + display: none; +} diff --git a/frontend/src/Components/DatePicker/DatePicker.stories.tsx b/frontend/src/Components/DatePicker/DatePicker.stories.tsx new file mode 100644 index 000000000..ecac4211b --- /dev/null +++ b/frontend/src/Components/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DatePicker } from './DatePicker'; + +// Local component config. +const meta = { + title: 'Components/DatePicker', + component: DatePicker, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const onChange = (value: unknown) => console.log('Selected:', value); + +export const Basic: Story = { + args: { + onChange, + }, +}; diff --git a/frontend/src/Components/DatePicker/DatePicker.tsx b/frontend/src/Components/DatePicker/DatePicker.tsx new file mode 100644 index 000000000..4e6e9c7b9 --- /dev/null +++ b/frontend/src/Components/DatePicker/DatePicker.tsx @@ -0,0 +1,76 @@ +import { Icon } from '@iconify/react'; +import classNames from 'classnames'; +import { format } from 'date-fns'; +import React, { useMemo } from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, MiniCalendar } from '~/Components'; +import { useClickOutside } from '~/hooks'; +import { KEY } from '~/i18n/constants'; +import styles from './DatePicker.module.scss'; + +type DatePickerProps = { + label?: string; + disabled?: boolean; + value?: Date | null; + buttonClassName?: string; + onChange?: (date: Date | null) => void; + className?: string; + + minDate?: Date; + maxDate?: Date; +}; + +export function DatePicker({ + value: initialValue, + onChange, + disabled, + label, + buttonClassName, + minDate, + maxDate, +}: DatePickerProps) { + const isControlled = initialValue !== undefined; + + const [date, setDate] = useState(null); + const [open, setOpen] = useState(false); + + const { t } = useTranslation(); + + const clickOutsideRef = useClickOutside(() => setOpen(false)); + + const value = useMemo(() => { + if (isControlled) { + return initialValue; + } + return date; + }, [isControlled, initialValue, date]); + + function handleChange(d: Date | null) { + setDate(d); + onChange?.(d); + } + + return ( +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/Components/DatePicker/index.ts b/frontend/src/Components/DatePicker/index.ts new file mode 100644 index 000000000..a4eb7f5cd --- /dev/null +++ b/frontend/src/Components/DatePicker/index.ts @@ -0,0 +1 @@ +export { DatePicker } from './DatePicker'; diff --git a/frontend/src/Components/Dropdown/Dropdown.module.scss b/frontend/src/Components/Dropdown/Dropdown.module.scss index 99a3ed5b1..aba01c1e6 100644 --- a/frontend/src/Components/Dropdown/Dropdown.module.scss +++ b/frontend/src/Components/Dropdown/Dropdown.module.scss @@ -2,6 +2,10 @@ @import 'src/mixins'; +.italic { + font-style: italic; +} + // label som wrapper .select_wrapper { display: flex; diff --git a/frontend/src/Components/Dropdown/Dropdown.stories.tsx b/frontend/src/Components/Dropdown/Dropdown.stories.tsx index 933f471c6..efa82ab29 100644 --- a/frontend/src/Components/Dropdown/Dropdown.stories.tsx +++ b/frontend/src/Components/Dropdown/Dropdown.stories.tsx @@ -1,42 +1,74 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory, Meta, StoryObj } from '@storybook/react'; import { Dropdown } from './Dropdown'; // Local component config. -export default { +const meta = { title: 'Components/Dropdown', component: Dropdown, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const options = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Orange', value: 'orange' }, + { label: 'Mango', value: 'mango' }, +]; + +const onChange = (value: unknown) => console.log('Selected:', value); + +// Basic uncontrolled dropdown +export const Basic: Story = { + args: { + options, + onChange, + }, +}; + +// With default value +export const WithDefaultValue: Story = { + args: { + options, + defaultValue: 'banana', + onChange, + }, +}; + +// With null option +export const WithNullOption: Story = { args: { - name: 'name', - label: 'Choose option', + options, + nullOption: true, + onChange, }, -} as ComponentMeta; +}; -const Template: ComponentStory = (args) => ; +// With custom null option +export const WithCustomNullOption: Story = { + args: { + options, + nullOption: { label: 'Select a fruit...', disabled: false }, + onChange, + }, +}; -export const Basic = Template.bind({}); -Basic.args = { - options: [ - { label: 'alternativ 1', value: 1 }, - { label: 'alternativ 2', value: 2 }, - ], +// With disabled null option (can't reselect null after choosing a value) +export const WithDisabledNullOption: Story = { + args: { + options, + nullOption: { label: 'Select a fruit...', disabled: true }, + onChange, + }, }; -export const Many = Template.bind({}); -Many.args = { - options: [ - { label: 'alternativ 1', value: 1 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - ], + +// With label +export const WithLabel: Story = { + args: { + options, + label: 'Favorite Fruit', + onChange, + }, }; diff --git a/frontend/src/Components/Dropdown/Dropdown.tsx b/frontend/src/Components/Dropdown/Dropdown.tsx index e0e4e6986..8be044f1e 100644 --- a/frontend/src/Components/Dropdown/Dropdown.tsx +++ b/frontend/src/Components/Dropdown/Dropdown.tsx @@ -1,62 +1,94 @@ import { Icon } from '@iconify/react'; import { default as classNames, default as classnames } from 'classnames'; -import React, { type ChangeEvent, type ReactElement } from 'react'; +import React, { type ChangeEvent, type ReactNode, useMemo } from 'react'; import styles from './Dropdown.module.scss'; -export type DropDownOption = { +export type DropdownOption = { label: string; value: T; + disabled?: boolean; +}; + +type NullOption = { + label: string; + disabled?: boolean; }; -export type DropdownProps = { +type PrimitiveDropdownProps = { className?: string; classNameSelect?: string; - defaultValue?: DropDownOption; - initialValue?: T; - disableIcon?: boolean; - options?: DropDownOption[]; - label?: string | ReactElement; + options?: DropdownOption[]; + label?: string | ReactNode; disabled?: boolean; error?: boolean; - onChange?: (value?: T) => void; + disableIcon?: boolean; + nullOption?: boolean | NullOption; + onChange?: (value: T) => void; +}; + +type ControlledDropdownProps = PrimitiveDropdownProps & { + value: T | null; + defaultValue?: never; +}; + +type UncontrolledDropdownProps = PrimitiveDropdownProps & { + value?: never; + defaultValue?: T | null; }; +export type DropdownProps = ControlledDropdownProps | UncontrolledDropdownProps; + function DropdownInner( { options = [], defaultValue, - initialValue, + value, onChange, className, classNameSelect, label, disabled = false, disableIcon = false, + nullOption = false, error, }: DropdownProps, ref: React.Ref, ) { - /** - * Handles the raw change event from