From f3728f49f39e13296d51af3032c19fdebe87a8df Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 03:19:04 +0100 Subject: [PATCH 01/15] adds refactorof RecruitmentApplication --- backend/samfundet/models/recruitment.py | 418 ++++++++++++++++++------ 1 file changed, 320 insertions(+), 98 deletions(-) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 514fcda9d..065ae8de6 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -277,119 +277,149 @@ 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 + """ + Represents an application submitted by a user for a specific recruitment position. + This model handles all aspects of a recruitment application including: + - Application content and priority management + - Interview assignments + - Status tracking for both recruiters and applicants + - Priority reordering when applications are withdrawn + The model ensures that application priorities remain sequential and handles shared + interviews between certain recruitment positions (e.g., UKA and KSG positions). + """ + + # Unique Identifier to prevent inferring total application count from sequential IDs id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - application_text = models.TextField(help_text='Application text') - recruitment_position = models.ForeignKey( - RecruitmentPosition, on_delete=models.CASCADE, help_text='The position which is recruiting', related_name='applications' + + # Core application fields + application_text = models.TextField(help_text='Motivation text submitted by the applicant') + applicant_priority = models.PositiveIntegerField( + null=True, blank=True, help_text='Applicant priority of the position(recruitment_position) to which this application related.' ) - recruitment = models.ForeignKey(Recruitment, on_delete=models.CASCADE, help_text='The recruitment that is recruiting', related_name='applications') - user = models.ForeignKey(User, on_delete=models.CASCADE, help_text='The user that is applying', related_name='applications') - applicant_priority = models.PositiveIntegerField(null=True, blank=True, help_text='The priority of the application') + withdrawn = models.BooleanField(default=False, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) - created_at = models.DateTimeField(null=True, blank=True, auto_now_add=True) + # Foreign key relationships + recruitment = models.ForeignKey( + Recruitment, on_delete=models.CASCADE, related_name='applications', help_text='Recruitment to which this application is related.' + ) - # Foreign Key because UKA and KSG have shared interviews (multiple applications share the same interview) + recruitment_position = models.ForeignKey( + RecruitmentPosition, on_delete=models.CASCADE, related_name='applications', help_text='Position to which this application is related.' + ) + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='applications', help_text='User who submitted this application.') + # Multiple applications can share the same interview (e.g., for UKA and KSG positions) interview = models.ForeignKey( - Interview, on_delete=models.SET_NULL, null=True, blank=True, help_text='The interview for the application', related_name='applications' + Interview, on_delete=models.SET_NULL, null=True, blank=True, related_name='applications', help_text='Interview associated with this application.' ) - withdrawn = models.BooleanField(default=False, blank=True, null=True) - # TODO: Important that the following is not sent along with the rest of the object whenever a user retrieves its application + + # Recruitment statuses and flags. + # IMPORTANT that recruiter_priority is not communicated to applicant recruiter_priority = models.IntegerField( - choices=RecruitmentPriorityChoices.choices, default=RecruitmentPriorityChoices.NOT_SET, help_text='The priority of the application' + choices=RecruitmentPriorityChoices.choices, + default=RecruitmentPriorityChoices.NOT_SET, + help_text="Recruiter's priority for this application - should not be visible to applicant", ) - recruiter_status = models.IntegerField( - choices=RecruitmentStatusChoices.choices, default=RecruitmentStatusChoices.NOT_SET, help_text='The status of the application' + choices=RecruitmentStatusChoices.choices, default=RecruitmentStatusChoices.NOT_SET, help_text='Current status of this application.' ) - applicant_state = models.IntegerField( - choices=RecruitmentApplicantStates.choices, default=RecruitmentApplicantStates.NOT_SET, help_text='The state of the applicant for the recruiter' + choices=RecruitmentApplicantStates.choices, default=RecruitmentApplicantStates.NOT_SET, help_text="Recruiter's view of the applicant's status." ) + # Error message constants + REAPPLY_TOO_MANY_APPLICATIONS_ERROR = 'Cannot reapply; too many active applications.' + TOO_MANY_APPLICATIONS_ERROR = 'Exceeds max allowed applications for recruitment.' + + def __init__(self, *args: tuple, **kwargs: dict): + """ + Initializes the RecruitmentApplication instance and tracks the original withdrawn state + for detecting status changes during save operations. + """ + super().__init__(*args, **kwargs) + # Track original withdrawn state to detect changes + self._original_withdrawn = self.withdrawn if self.pk else False + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + """ + Returns the organization associated with this application's recruitment. + Args: + return_id: If True, returns the organization ID instead of the object + Returns: + Organization or int: The organization object or its ID + """ return self.recruitment.resolve_org(return_id=return_id) def resolve_gang(self, *, return_id: bool = False) -> Gang | int: + """ + Returns the gang associated with this application's recruitment position. + Args: + return_id: If True, returns the gang ID instead of the object + Returns: + Gang or int: The gang object or its ID + """ return self.recruitment_position.resolve_gang(return_id=return_id) - def organize_priorities(self) -> None: - """Organizes priorites from 1 to n, so that it is sequential with no gaps""" - applications_for_user = RecruitmentApplication.objects.filter(recruitment=self.recruitment, user=self.user).order_by('applicant_priority') - for i in range(len(applications_for_user)): - correct_position = i + 1 - if applications_for_user[i].applicant_priority != correct_position: - applications_for_user[i].applicant_priority = correct_position - applications_for_user[i].save() - - def update_priority(self, direction: int) -> None: + def save(self, *args: tuple, **kwargs: dict) -> None: """ - Method for moving priorites up or down, - positive direction indicates moving it to higher priority, - negative direction indicates moving it to lower priority, - can move n positions up or down - - """ - # Use order for more simple an unified for direction - ordering = f"{'' if direction < 0 else '-' }applicant_priority" - applications_for_user = RecruitmentApplication.objects.filter(recruitment=self.recruitment, user=self.user).order_by(ordering) - direction = abs(direction) # convert to absolute - for i in range(len(applications_for_user)): - if applications_for_user[i].id == self.id: # find current - # Find index of which to switch priority with - switch = len(applications_for_user) - 1 if i + direction >= len(applications_for_user) else i + direction - new_priority = applications_for_user[switch].applicant_priority - # Move priorites down in direction - for ii in range(switch, i, -1): - applications_for_user[ii].applicant_priority = applications_for_user[ii - 1].applicant_priority - applications_for_user[ii].save() - # update priority - applications_for_user[i].applicant_priority = new_priority - applications_for_user[i].save() - break - self.organize_priorities() - - REAPPLY_TOO_MANY_APPLICATIONS_ERROR = 'Can not reapply application, too many active application' - TOO_MANY_APPLICATIONS_ERROR = 'Too many applications for recruitment' + Handles the complete save process for an application, including: + - Ensuring recruitment assignment + - Processing withdrawal status changes + - Managing shared interviews + - Reordering priorities after withdrawals + All operations are performed within a transaction to maintain data integrity. + """ + with transaction.atomic(): + self._ensure_recruitment_assignment() + self._handle_withdrawal() + self._assign_interview_if_shared() + super().save(*args, **kwargs) + if self._is_withdrawal(): + self._reorder_remaining_priorities() + + def _ensure_recruitment_assignment(self) -> None: + """Ensures the application is linked to the correct recruitment if not already set.""" + if not self.recruitment: + self.recruitment = self.recruitment_position.recruitment - def clean(self, *args: tuple, **kwargs: dict) -> None: - super().clean() - errors: dict[str, list[ValidationError]] = defaultdict(list) + def _handle_withdrawal(self) -> None: + """ + Manages the application state during withdrawal or new application: + - Clears priorities for withdrawn applications + - Assigns new priority for new applications + """ + if self.withdrawn: + self._clear_priorities() + elif not self.applicant_priority: + self.applicant_priority = self._calculate_new_priority() - # If there is max applications, check if applicant have applied to not to many - # Cant use not self.pk, due to UUID generating it before save. - if self.recruitment.max_applications: - user_applications_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() - current_application = RecruitmentApplication.objects.filter(pk=self.pk).first() - if user_applications_count >= self.recruitment.max_applications: - if not current_application: - # attempts to create new application when too many applications - errors['recruitment'].append(self.TOO_MANY_APPLICATIONS_ERROR) - elif current_application.withdrawn and not self.withdrawn: - # If it attempts to withdraw, when to many active applications - errors['recruitment'].append(self.REAPPLY_TOO_MANY_APPLICATIONS_ERROR) - raise ValidationError(errors) + def _clear_priorities(self) -> None: + """ + Clears all priority-related fields when an application is withdrawn and + sets appropriate rejection statuses. + IMPORTANT: Rejection email logic ensures that applications + with withdrawn = true does not enter the automatic rejection email pool + """ + self.applicant_priority = None + self.recruiter_priority = RecruitmentPriorityChoices.NOT_WANTED + self.recruiter_status = RecruitmentStatusChoices.AUTOMATIC_REJECTION - def __str__(self) -> str: - return f'Application: {self.user} for {self.recruitment_position} in {self.recruitment}' + def _calculate_new_priority(self) -> int: + """ + Calculates the appropriate priority for a new active application based on + existing active applications. + Returns: + int: The new priority number + """ + active_app_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() + return active_app_count + 1 - def save(self, *args: tuple, **kwargs: dict) -> None: # noqa: C901 + def _assign_interview_if_shared(self) -> None: """ - If the application is saved without an interview, - try to find an interview from a shared position. + Attempts to assign a shared interview if the position is part of a shared + interview group and the applicant already has an interview scheduled. """ - if not self.recruitment: - self.recruitment = self.recruitment_position.recruitment - # If the application is saved without an interview, try to find an interview from a shared position. - if not self.applicant_priority: - self.organize_priorities() - current_applications_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment).count() - # Set the applicant_priority to the number of applications + 1 (for the current application) - self.applicant_priority = current_applications_count + 1 - # If the application is saved without an interview, try to find an interview from a shared position. - if self.withdrawn: - self.recruiter_priority = RecruitmentPriorityChoices.NOT_WANTED - self.recruiter_status = RecruitmentStatusChoices.AUTOMATIC_REJECTION if not self.interview and self.recruitment_position.shared_interview_group: shared_interview = ( RecruitmentApplication.objects.filter(user=self.user, recruitment_position__in=self.recruitment_position.shared_interview_group.positions.all()) @@ -399,32 +429,224 @@ def save(self, *args: tuple, **kwargs: dict) -> None: # noqa: C901 if shared_interview: self.interview = shared_interview.interview - super().save(*args, **kwargs) + def _is_withdrawal(self) -> bool: + """ + Determines if this save operation represents a new withdrawal. + Returns: + bool: True if this is a new withdrawal, False otherwise + """ + return self.pk and self.withdrawn and not self._original_withdrawn - def get_total_interviews(self) -> int: - return RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).exclude(interview=None).count() + def _reorder_remaining_priorities(self) -> None: + """ + Reorders priorities for remaining active applications after a withdrawal + to maintain sequential ordering without gaps. + """ + active_applications = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).order_by( + 'applicant_priority' + ) - def get_total_applications(self) -> int: - return RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() + for index, application in enumerate(active_applications, start=1): + if application.applicant_priority != index: + application.applicant_priority = index + application.save(update_fields=['applicant_priority']) + + def update_priority(self, direction: int) -> None: + """ + Updates the priority of the application by moving it up or down in the priority list. + Args: + direction: Positive number moves priority up, negative moves it down. + The absolute value determines how many positions to move. + """ + if timezone.now() > self.recruitment.reprioritization_deadline_for_applicant: + raise ValidationError('Cannot reprioritize applications after the reprioritization deadline') + with transaction.atomic(): + applications_for_user = RecruitmentApplication.objects.filter(recruitment=self.recruitment, user=self.user, withdrawn=False).order_by( + f"{'' if direction < 0 else '-'}applicant_priority" + ) + self._reorder_priorities_by_direction(applications_for_user, abs(direction)) + + def _reorder_priorities_by_direction(self, applications: list[RecruitmentApplication], steps: int) -> None: + """ + Helper method that handles the actual priority reordering logic. + Args: + applications: List of applications to reorder + steps: Number of positions to move the application + """ + for i, app in enumerate(applications): + if app.id == self.id: + swap_index = max(0, min(len(applications) - 1, i + steps)) + self._swap_priorities(applications, i, swap_index) + break + + def _swap_priorities(self, applications: list[RecruitmentApplication], i: int, swap_index: int) -> None: + """ + Performs the priority swap operation between two applications. + Args: + applications: List of applications being reordered + i: Index of the current application + swap_index: Index of the application to swap with + """ + new_priority = applications[swap_index].applicant_priority + for j in range(swap_index, i, -1): + applications[j].applicant_priority = applications[j - 1].applicant_priority + applications[j].save(update_fields=['applicant_priority']) # type: ignore + applications[i].applicant_priority = new_priority + applications[i].save(update_fields=['applicant_priority']) # type: ignore + + def update_recruiter_priority(self, new_priority: int) -> None: + """ + Updates the recruiter's priority for this application. + Validates that the group reprioritization deadline hasn't passed. + Args: + new_priority: New priority value from RecruitmentPriorityChoices + Raises: + ValidationError: If deadline has passed + """ + + # Validate not past group reprioritization deadline + if timezone.now() > self.recruitment.reprioritization_deadline_for_groups: + raise ValidationError('Cannot change recruiter priority after the group reprioritization deadline') + + # Update priority and trigger state recalculation + self.recruiter_priority = new_priority + self.save() + self.update_applicant_state() def update_applicant_state(self) -> None: + """ + Updates the applicant's state based on recruiter priorities and the relative + priority of their applications. This affects how the application appears to + recruiters in the system. + The state is calculated using a matrix-like approach where: + - The row (0-2) is determined by the application's priority relative to top wanted/reserved apps + - The column (0-2) is determined by the recruiter's priority (NOT_SET, RESERVE, WANTED) + - The final state is calculated as: column + (3 * row) + """ + # Fetch all applications for this user and recruitment session, ordered by their priority (1 is highest) applications = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment).order_by('applicant_priority') - # Get top priority + + # Find the highest priority (lowest number) applications marked as WANTED and RESERVE + # These serve as thresholds for determining if other applications should be deprioritized top_wanted = applications.filter(recruiter_priority=RecruitmentPriorityChoices.WANTED).order_by('applicant_priority').first() top_reserved = applications.filter(recruiter_priority=RecruitmentPriorityChoices.RESERVE).order_by('applicant_priority').first() + with transaction.atomic(): for application in applications: - # I hate conditionals, so instead of checking all forms of condtions - # I use memory array indexing formula (col+row_size*row) for matrixes, to index into state + # has_priority acts as the row number (0-2) in our state matrix: + # 0 = application is above or at the same level as all top applications + # 1 = application is below a RESERVE application + # 2 = application is below a WANTED application has_priority = 0 - if top_reserved and top_reserved.applicant_priority < application.applicant_priority: + + # If there's a top reserved application and this application has lower priority + # (higher number) than it, mark it as below RESERVE (row 1) + if top_reserved and application.applicant_priority > top_reserved.applicant_priority: has_priority = 1 - if top_wanted and top_wanted.applicant_priority < application.applicant_priority: + + # If there's a top wanted application and this application has lower priority + # (higher number) than it, mark it as below WANTED (row 2) + if top_wanted and application.applicant_priority > top_wanted.applicant_priority: has_priority = 2 + + # Calculate the final state using matrix indexing: + # - recruiter_priority (0,1,2) determines the column + # - has_priority (0,1,2) determines the row + # - multiply row by 3 (matrix width) and add column to get final state application.applicant_state = application.recruiter_priority + 3 * has_priority + + # Special case: If recruiter marked as NOT_WANTED, override the calculated + # state to always show as NOT_WANTED regardless of priority if application.recruiter_priority == RecruitmentPriorityChoices.NOT_WANTED: application.applicant_state = RecruitmentApplicantStates.NOT_WANTED - application.save() + + # Save only the state field for efficiency + application.save(update_fields=['applicant_state']) + + def clean(self, *args: tuple, **kwargs: dict) -> None: + """ + Validates the application before saving, checking: + - Priority constraints for active applications + - Maximum application limits + - Application deadline constraints + Raises: + ValidationError: If any validation constraints are violated + """ + super().clean() + errors: dict[str, list[str]] = defaultdict(list) + + # Don't validate withdrawn applications except for deadline + if not self.withdrawn and self.applicant_priority: + self._validate_priority(errors) + if self.recruitment.max_applications: + self._validate_application_limits(errors) + + # Always validate deadline constraints + self._validate_deadline_constraints(errors) + + if errors: + raise ValidationError(errors) + + def _validate_priority(self, errors: dict[str, list[str]]) -> None: + """ + Validates that the application's priority is within allowed bounds. + Args: + errors: Dictionary to collect validation errors + """ + active_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).exclude(pk=self.pk).count() + if self.applicant_priority > active_count + 1: + errors['applicant_priority'].append(f'Priority cannot exceed active applications ({active_count + 1}).') + + def _validate_application_limits(self, errors: dict[str, list[str]]) -> None: + """ + Validates that the application doesn't exceed maximum application limits. + Args: + errors: Dictionary to collect validation errors + """ + user_applications_count = RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() + current_application = RecruitmentApplication.objects.filter(pk=self.pk).first() + + if user_applications_count >= self.recruitment.max_applications: + if not current_application: + # New application attempting to exceed limit + errors['recruitment'].append(self.TOO_MANY_APPLICATIONS_ERROR) + elif current_application.withdrawn and not self.withdrawn: + # Attempting to reapply a withdrawn application when at limit + errors['recruitment'].append(self.REAPPLY_TOO_MANY_APPLICATIONS_ERROR) + + def _validate_deadline_constraints(self, errors: dict[str, list[str]]) -> None: + """ + Validates that the application is being submitted within the allowed time window. + Args: + errors: Dictionary to collect validation errors + """ + now = timezone.now() + + # Check if recruitment period has started + if now < self.recruitment.visible_from: + errors['recruitment'].append('Recruitment period has not started yet') + + # Check if deadline has passed + if now > self.recruitment.actual_application_deadline: + errors['recruitment'].append('Application deadline has passed') + + def get_total_interviews(self) -> int: + """ + Returns the total number of interviews scheduled for the user's active + applications in this recruitment. + Returns: + int: Number of scheduled interviews + """ + return RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).exclude(interview=None).count() + + def get_total_applications(self) -> int: + """ + Returns the total number of active applications for the user in this + recruitment. + Returns: + int: Number of active applications + """ + return RecruitmentApplication.objects.filter(user=self.user, recruitment=self.recruitment, withdrawn=False).count() class RecruitmentInterviewAvailability(CustomBaseModel): From 48a0768e297327b391c2a5efd1a16b3c1e680097 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 03:20:44 +0100 Subject: [PATCH 02/15] adds withdrawn application tests --- .../models/tests/test_recruitment.py | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/backend/samfundet/models/tests/test_recruitment.py b/backend/samfundet/models/tests/test_recruitment.py index 5a754d239..4161a4142 100644 --- a/backend/samfundet/models/tests/test_recruitment.py +++ b/backend/samfundet/models/tests/test_recruitment.py @@ -836,6 +836,134 @@ def test_recruitment_progress_applications_multiple_new_updates_progress( assert fixture_recruitment.recruitment_progress() == 1 +@pytest.mark.django_db +def test_withdrawn_application_priority_handling( + fixture_recruitment_application: RecruitmentApplication, + fixture_recruitment_application2: RecruitmentApplication, + fixture_recruitment_position2: RecruitmentPosition, +): + """Test that priorities are properly managed when applications are withdrawn""" + # Initial state - two applications with priorities 1 and 2 + assert fixture_recruitment_application.applicant_priority == 1 + assert fixture_recruitment_application2.applicant_priority == 2 + + # When withdrawing application 1, application 2 should become priority 1 + fixture_recruitment_application.withdrawn = True + fixture_recruitment_application.save() + + fixture_recruitment_application.refresh_from_db() + fixture_recruitment_application2.refresh_from_db() + + assert fixture_recruitment_application.applicant_priority is None + assert fixture_recruitment_application2.applicant_priority == 1 + + # New application should get priority 2 + new_application = RecruitmentApplication.objects.create( + application_text='Test application text 3', + recruitment_position=fixture_recruitment_position2, + recruitment=fixture_recruitment_position2.recruitment, + user=fixture_recruitment_application.user, + ) + + assert new_application.applicant_priority == 2 + + +@pytest.mark.django_db +def test_reapplying_after_withdrawal_priority( + fixture_recruitment_application: RecruitmentApplication, fixture_recruitment_application2: RecruitmentApplication +): + """Test that reapplying after withdrawal gets correct priority""" + # Initial state + assert fixture_recruitment_application.applicant_priority == 1 + assert fixture_recruitment_application2.applicant_priority == 2 + + # Withdraw first application + fixture_recruitment_application.withdrawn = True + fixture_recruitment_application.save() + + fixture_recruitment_application2.refresh_from_db() + assert fixture_recruitment_application2.applicant_priority == 1 + + # Reapply - should get priority 2 + fixture_recruitment_application.withdrawn = False + fixture_recruitment_application.save() + + fixture_recruitment_application.refresh_from_db() + fixture_recruitment_application2.refresh_from_db() + + assert fixture_recruitment_application2.applicant_priority == 1 + assert fixture_recruitment_application.applicant_priority == 2 + + +@pytest.mark.django_db +def test_priority_constraints_with_withdrawn_applications( + fixture_recruitment_application: RecruitmentApplication, + fixture_recruitment_application2: RecruitmentApplication, + fixture_recruitment_position2: RecruitmentPosition, +): + """Test that priorities stay within bounds of active applications only""" + # Initial state + assert fixture_recruitment_application.applicant_priority == 1 + assert fixture_recruitment_application2.applicant_priority == 2 + + # Withdraw application 2 + fixture_recruitment_application2.withdrawn = True + fixture_recruitment_application2.save() + + fixture_recruitment_application.refresh_from_db() + assert fixture_recruitment_application.applicant_priority == 1 + + # Try to set priority higher than number of active applications + with pytest.raises(ValidationError): + fixture_recruitment_application.applicant_priority = 2 + fixture_recruitment_application.save() + + +@pytest.mark.django_db +def test_multiple_withdrawals_and_priorities( + fixture_recruitment: Recruitment, fixture_recruitment_position: RecruitmentPosition, fixture_recruitment_position2: RecruitmentPosition, fixture_user: User +): + """Test complex scenario with multiple withdrawals and reapplications""" + # Create three applications + apps = [] + for i in range(3): + app = RecruitmentApplication.objects.create( + application_text=f'Test application {i}', + recruitment_position=fixture_recruitment_position if i < 2 else fixture_recruitment_position2, + recruitment=fixture_recruitment, + user=fixture_user, + ) + apps.append(app) + + # Verify initial priorities + for i, app in enumerate(apps, 1): + assert app.applicant_priority == i + + # Withdraw middle application + apps[1].withdrawn = True + apps[1].save() + + # Refresh and verify priorities adjusted + for app in apps: + app.refresh_from_db() + + assert apps[0].applicant_priority == 1 + assert apps[1].applicant_priority is None + assert apps[2].applicant_priority == 2 + + # Withdraw first application + apps[0].withdrawn = True + apps[0].save() + + # Refresh and verify + for app in apps: + app.refresh_from_db() + + assert apps[0].applicant_priority is None + assert apps[1].applicant_priority is None + assert apps[2].applicant_priority == 1 + + def test_position_must_have_single_owner(fixture_recruitment_position: RecruitmentPosition, fixture_gang: Gang, fixture_gang_section: GangSection): fixture_recruitment_position.gang = fixture_gang fixture_recruitment_position.section = fixture_gang_section From 763fbe53a58e481cf901ff023eaf0d10200a1417 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 03:23:43 +0100 Subject: [PATCH 03/15] adds refactor of RecruitmentApplicationForApplicantView --- backend/samfundet/views.py | 49 ++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 875f80229..06c67efd2 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -841,35 +841,35 @@ class RecruitmentApplicationForApplicantView(ModelViewSet): queryset = RecruitmentApplication.objects.all() def update(self, request: Request, pk: int) -> Response: + """Handle application creation and updates""" data = request.data.dict() if isinstance(request.data, QueryDict) else request.data recruitment_position = get_object_or_404(RecruitmentPosition, pk=pk) data['recruitment_position'] = recruitment_position.pk data['recruitment'] = recruitment_position.recruitment.pk data['user'] = request.user.pk + serializer = self.get_serializer(data=data) if serializer.is_valid(): existing_application = RecruitmentApplication.objects.filter(user=request.user, recruitment_position=pk).first() + if existing_application: + # Update existing application existing_application.application_text = serializer.validated_data['application_text'] + if existing_application.withdrawn: + existing_application.withdrawn = False existing_application.save() - serializer = self.get_serializer(existing_application) - return Response(serializer.data, status=status.HTTP_200_OK) - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def retrieve(self, request: Request, pk: int) -> Response: - application = get_object_or_404(RecruitmentApplication, user=request.user, recruitment_position=pk) + # Get updated application for response + updated_application = RecruitmentApplication.objects.get(pk=existing_application.pk) + return Response(self.get_serializer(updated_application).data, status=status.HTTP_200_OK) + # Create new application + application = serializer.save() + return Response(self.get_serializer(application).data, status=status.HTTP_201_CREATED) - user_id = request.query_params.get('user_id') - if user_id: - # TODO: Add permissions - application = RecruitmentApplication.objects.filter(recruitment_position=pk, user_id=user_id).first() - serializer = self.get_serializer(application) - return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def list(self, request: Request) -> Response: - """Returns a list of all the applications for a user for a specified recruitment""" + """List all applications for a user in a recruitment""" recruitment_id = request.query_params.get('recruitment') user_id = request.query_params.get('user_id') @@ -878,20 +878,23 @@ def list(self, request: Request) -> Response: recruitment = get_object_or_404(Recruitment, id=recruitment_id) + # Filter active applications applications = RecruitmentApplication.objects.filter( - recruitment=recruitment, - user=request.user, - ) - - if user_id: - # TODO: Add permissions - applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user_id=user_id) - else: - applications = RecruitmentApplication.objects.filter(recruitment=recruitment, user=request.user) + recruitment=recruitment, user_id=user_id if user_id else request.user.id, withdrawn=False + ).order_by('applicant_priority') serializer = self.get_serializer(applications, many=True) return Response(serializer.data) + def retrieve(self, request: Request, pk: int) -> Response: + """Get a specific application""" + user_id = request.query_params.get('user_id') + + application = get_object_or_404(RecruitmentApplication, recruitment_position=pk, user_id=user_id if user_id else request.user.id, withdrawn=False) + + serializer = self.get_serializer(application) + return Response(serializer.data) + class RecruitmentApplicationWithdrawApplicantView(APIView): permission_classes = [IsAuthenticated] From d03cfa5e81e750aa614a371cbe1c26aa894e0c76 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 03:28:51 +0100 Subject: [PATCH 04/15] refactor of RecruitmentApplicationApplicantPriorityView --- backend/samfundet/views.py | 44 ++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 06c67efd2..bad09091b 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -899,13 +899,24 @@ def retrieve(self, request: Request, pk: int) -> Response: class RecruitmentApplicationWithdrawApplicantView(APIView): permission_classes = [IsAuthenticated] + def get(self, request: Request, pk: int) -> Response: + # Get applications for specific recruitment process + applications = RecruitmentApplication.objects.filter( + recruitment_position__recruitment_id=pk, + user=request.user, + withdrawn=True, # Only get non-withdrawn applications + ) + serializer = RecruitmentApplicationForApplicantSerializer(applications, many=True) + return Response(serializer.data) + def put(self, request: Request, pk: int) -> Response: # Checks if user has applied for position application = get_object_or_404(RecruitmentApplication, recruitment_position=pk, user=request.user) - # Withdraw if applied + # Application confirmed by get_object_or_404, contiues with withdrawing application application.withdrawn = True application.save() serializer = RecruitmentApplicationForApplicantSerializer(application) + return Response(serializer.data, status=status.HTTP_200_OK) @@ -921,33 +932,44 @@ def put(self, request: Request, pk: str) -> Response: return Response(serializer.data, status=status.HTTP_200_OK) +# TODO SIMPLIFY THIS class RecruitmentApplicationApplicantPriorityView(APIView): permission_classes = [IsAuthenticated] serializer_class = RecruitmentUpdateUserPrioritySerializer - def put( - self, - request: Request, - pk: int, - ) -> Response: + def put(self, request: Request, pk: int) -> Response: direction = RecruitmentUpdateUserPrioritySerializer(data=request.data) if direction.is_valid(): direction = direction.validated_data['direction'] else: return Response(direction.errors, status=status.HTTP_400_BAD_REQUEST) - # Dont think we need any extra perms in this view, admin should not be able to change priority + # Get the current application and verify it exists and isn't withdrawn application = get_object_or_404( RecruitmentApplication, id=pk, user=request.user, + withdrawn=False, ) + + # Update the priority application.update_priority(direction) + + # Get all non-withdrawn applications for this recruitment and user + active_applications = RecruitmentApplication.objects.filter( + recruitment=application.recruitment, + user=request.user, + withdrawn=False, # Explicitly exclude withdrawn applications + ).order_by('applicant_priority') + + # Rebase priorities to ensure they're sequential starting from 1 + for index, application in enumerate(active_applications, start=1): + if application.applicant_priority != index: + application.applicant_priority = index + application.save() + serializer = RecruitmentApplicationForApplicantSerializer( - RecruitmentApplication.objects.filter( - recruitment=application.recruitment, - user=request.user, - ).order_by('applicant_priority'), + active_applications, many=True, ) return Response(serializer.data) From 003ed8a8f960c843cbab1dcf56b40c74175f18ec Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 03:31:01 +0100 Subject: [PATCH 05/15] refactor of RecruitmentApplicationForGangUpdateStateView --- backend/samfundet/views.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index bad09091b..6f0f5b6ea 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -5,6 +5,7 @@ import hmac import hashlib from typing import Any +from xml.dom import ValidationErr from guardian.shortcuts import get_objects_for_user @@ -1048,23 +1049,26 @@ class RecruitmentApplicationForGangUpdateStateView(APIView): def put(self, request: Request, pk: int) -> Response: application = get_object_or_404(RecruitmentApplication, pk=pk) - # TODO add check if user has permission to update for GANG update_serializer = self.serializer_class(data=request.data) if update_serializer.is_valid(): - # Should return update list of applications on correct - if 'recruiter_priority' in update_serializer.data: - application.recruiter_priority = update_serializer.data['recruiter_priority'] - if 'recruiter_status' in update_serializer.data: - application.recruiter_status = update_serializer.data['recruiter_status'] - application.save() - applications = RecruitmentApplication.objects.filter( - recruitment_position__gang=application.recruitment_position.gang, - recruitment=application.recruitment, - ) - application.update_applicant_state() - serializer = RecruitmentApplicationForGangSerializer(applications, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + try: + # Should return update list of applications on correct + if 'recruiter_priority' in update_serializer.data: + application.update_recruiter_priority(update_serializer.data['recruiter_priority']) + if 'recruiter_status' in update_serializer.data: + application.recruiter_status = update_serializer.data['recruiter_status'] + application.save() + + applications = RecruitmentApplication.objects.filter( + recruitment_position__gang=application.recruitment_position.gang, + recruitment=application.recruitment, + ) + serializer = RecruitmentApplicationForGangSerializer(applications, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except ValidationErr as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(update_serializer.errors, status=status.HTTP_400_BAD_REQUEST) From 5f2b9e06ae46d9a8d6845c219e581eee941d0da4 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 03:32:01 +0100 Subject: [PATCH 06/15] migration file --- ...application_applicant_priority_and_more.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 backend/samfundet/migrations/0011_alter_recruitmentapplication_applicant_priority_and_more.py diff --git a/backend/samfundet/migrations/0011_alter_recruitmentapplication_applicant_priority_and_more.py b/backend/samfundet/migrations/0011_alter_recruitmentapplication_applicant_priority_and_more.py new file mode 100644 index 000000000..0a9111f40 --- /dev/null +++ b/backend/samfundet/migrations/0011_alter_recruitmentapplication_applicant_priority_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.1 on 2024-11-03 02:31 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0010_recruitment_promo_media'), + ] + + operations = [ + migrations.AlterField( + model_name='recruitmentapplication', + name='applicant_priority', + field=models.PositiveIntegerField(blank=True, help_text='Applicant priority of the position(recruitment_position) to which this application related.', null=True), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='applicant_state', + field=models.IntegerField(choices=[(0, 'Unprocessed by all above on priority'), (1, 'Highest priority, and reserve'), (2, 'Highest priority, and wanted'), (3, 'Another position has this on reserve, with higher priority'), (4, 'Another position has this on reserve, with higher priority, but you have reserved'), (5, 'Another position has this on reserve, with higher priority, but you have them as wanted'), (6, 'Another position has this on reserve, with higher priority'), (7, 'Another position has this on wanted, with higher priority, but you have reserved'), (8, 'Another position has this on wanted, with higher priority, but you have them as wanted'), (10, 'Other position has priority')], default=0, help_text="Recruiter's view of the applicant's status."), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='application_text', + field=models.TextField(help_text='Motivation text submitted by the applicant'), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='interview', + field=models.ForeignKey(blank=True, help_text='Interview associated with this application.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to='samfundet.interview'), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='recruiter_priority', + field=models.IntegerField(choices=[(0, 'Not Set'), (1, 'Reserve'), (2, 'Wanted'), (3, 'Not Wanted')], default=0, help_text="Recruiter's priority for this application - should not be visible to applicant"), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='recruiter_status', + field=models.IntegerField(choices=[(0, 'Not Set'), (1, 'Called and Accepted'), (2, 'Called and Rejected'), (3, 'Rejection'), (4, 'Automatic Rejection')], default=0, help_text='Current status of this application.'), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='recruitment', + field=models.ForeignKey(help_text='Recruitment to which this application is related.', on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='samfundet.recruitment'), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='recruitment_position', + field=models.ForeignKey(help_text='Position to which this application is related.', on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='samfundet.recruitmentposition'), + ), + migrations.AlterField( + model_name='recruitmentapplication', + name='user', + field=models.ForeignKey(help_text='User who submitted this application.', on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.AUTH_USER_MODEL), + ), + ] From 1f4870ec6e0004a4b3ee4a3d7400b313c4f78f65 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 05:21:35 +0100 Subject: [PATCH 07/15] recruiter admin application priority tests --- .../models/tests/test_recruitment.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/backend/samfundet/models/tests/test_recruitment.py b/backend/samfundet/models/tests/test_recruitment.py index 4161a4142..44aa8111d 100644 --- a/backend/samfundet/models/tests/test_recruitment.py +++ b/backend/samfundet/models/tests/test_recruitment.py @@ -1,5 +1,7 @@ from __future__ import annotations +from unittest.mock import patch + import pytest from django.utils import timezone @@ -981,3 +983,96 @@ def test_position_must_have_single_owner(fixture_recruitment_position: Recruitme fixture_recruitment_position.gang = None fixture_recruitment_position.section = fixture_gang_section fixture_recruitment_position.save() + + +@pytest.mark.django_db +class TestRecruitmentApplicationDeadlines: + def test_recruiter_priority_update_before_deadline(self, fixture_recruitment_application: RecruitmentApplication): + """Test that recruiter can update priority before the deadline""" + now = timezone.now() + fixture_recruitment_application.recruitment.visible_from = now + fixture_recruitment_application.recruitment.shown_application_deadline = now + timezone.timedelta(hours=12) + fixture_recruitment_application.recruitment.actual_application_deadline = now + timezone.timedelta(days=1) + fixture_recruitment_application.recruitment.reprioritization_deadline_for_applicant = now + timezone.timedelta(days=2) + fixture_recruitment_application.recruitment.reprioritization_deadline_for_groups = now + timezone.timedelta(days=3) + fixture_recruitment_application.recruitment.save() + + # Use the integer value instead of the choice class + fixture_recruitment_application.update_recruiter_priority(2) # 2 is WANTED + + fixture_recruitment_application.refresh_from_db() + assert fixture_recruitment_application.recruiter_priority == RecruitmentPriorityChoices.WANTED + + def test_recruiter_priority_update_after_deadline(self, fixture_recruitment_application: RecruitmentApplication): + """Test that recruiter cannot update priority after the deadline""" + now = timezone.now() + fixture_recruitment_application.recruitment.visible_from = now + fixture_recruitment_application.recruitment.shown_application_deadline = now + timezone.timedelta(hours=12) + fixture_recruitment_application.recruitment.actual_application_deadline = now + timezone.timedelta(days=1) + fixture_recruitment_application.recruitment.reprioritization_deadline_for_applicant = now + timezone.timedelta(days=2) + fixture_recruitment_application.recruitment.reprioritization_deadline_for_groups = now + timezone.timedelta(days=3) + fixture_recruitment_application.recruitment.save() + + original_priority = fixture_recruitment_application.recruiter_priority + + with patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = now + timezone.timedelta(days=3, hours=1) # Past group deadline + + # Use integer value here too + with pytest.raises(ValidationError) as exc: + fixture_recruitment_application.update_recruiter_priority(2) # 2 is WANTED + + assert 'Cannot change recruiter priority after the group reprioritization deadline' in str(exc.value) + + fixture_recruitment_application.refresh_from_db() + assert fixture_recruitment_application.recruiter_priority == original_priority + + def test_deadline_validation_with_multiple_applications( + self, fixture_recruitment_application: RecruitmentApplication, fixture_recruitment_application2: RecruitmentApplication + ): + """Test deadline validation with multiple applications""" + now = timezone.now() + recruitment = fixture_recruitment_application.recruitment + recruitment.visible_from = now + recruitment.shown_application_deadline = now + timezone.timedelta(hours=12) + recruitment.actual_application_deadline = now + timezone.timedelta(days=1) + recruitment.reprioritization_deadline_for_applicant = now + timezone.timedelta(days=2) + recruitment.reprioritization_deadline_for_groups = now + timezone.timedelta(days=3) + recruitment.save() + + with patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = now + timezone.timedelta(days=3, hours=1) + + with pytest.raises(ValidationError): + fixture_recruitment_application.update_priority(1) # RESERVE + + with pytest.raises(ValidationError): + fixture_recruitment_application2.update_priority(1) # RESERVE + + with pytest.raises(ValidationError): + # Use integer value here too + fixture_recruitment_application.update_recruiter_priority(2) # 2 is WANTED + + with pytest.raises(ValidationError): + fixture_recruitment_application2.update_recruiter_priority(2) # 2 is WANTED + + def test_deadline_ordering_validation(self, fixture_recruitment: Recruitment): + """Test that deadlines must be in correct chronological order""" + now = timezone.now() + fixture_recruitment.visible_from = now + fixture_recruitment.shown_application_deadline = now + timezone.timedelta(hours=12) + fixture_recruitment.actual_application_deadline = now + timezone.timedelta(days=1) + fixture_recruitment.reprioritization_deadline_for_applicant = now + timezone.timedelta(days=2) + fixture_recruitment.reprioritization_deadline_for_groups = now + timezone.timedelta(days=3) + fixture_recruitment.save() + + # Try invalid order - applicant deadline after groups deadline + fixture_recruitment.reprioritization_deadline_for_applicant = now + timezone.timedelta(days=4) + fixture_recruitment.reprioritization_deadline_for_groups = now + timezone.timedelta(days=3) + + with pytest.raises(ValidationError) as exc: + fixture_recruitment.clean() + + error_dict = dict(exc.value) + assert fixture_recruitment.REPRIORITIZATION_GROUP_BEFORE_APPLICANT in error_dict['reprioritization_deadline_for_groups'] + assert fixture_recruitment.REPRIORITIZATION_APPLICANT_AFTER_GROUP in error_dict['reprioritization_deadline_for_applicant'] From 09ee5c5a8950bedf7bd4aa4afde55d8ab7f2bb2a Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 05:30:46 +0100 Subject: [PATCH 08/15] applicant reprioritization deadline tests --- .../models/tests/test_recruitment.py | 105 ++++++++++++++---- 1 file changed, 86 insertions(+), 19 deletions(-) diff --git a/backend/samfundet/models/tests/test_recruitment.py b/backend/samfundet/models/tests/test_recruitment.py index 44aa8111d..9a8c21ed5 100644 --- a/backend/samfundet/models/tests/test_recruitment.py +++ b/backend/samfundet/models/tests/test_recruitment.py @@ -1056,23 +1056,90 @@ def test_deadline_validation_with_multiple_applications( with pytest.raises(ValidationError): fixture_recruitment_application2.update_recruiter_priority(2) # 2 is WANTED - def test_deadline_ordering_validation(self, fixture_recruitment: Recruitment): - """Test that deadlines must be in correct chronological order""" + def test_applicant_update_application_priority_before_deadline( + self, fixture_recruitment_application: RecruitmentApplication, fixture_recruitment_application2: RecruitmentApplication + ): + """Test that applicant can update their application priorities before the deadline""" + # Set up valid timeline + now = timezone.now() + recruitment = fixture_recruitment_application.recruitment + recruitment.visible_from = now + recruitment.shown_application_deadline = now + timezone.timedelta(hours=12) + recruitment.actual_application_deadline = now + timezone.timedelta(days=1) + recruitment.reprioritization_deadline_for_applicant = now + timezone.timedelta(days=2) + recruitment.reprioritization_deadline_for_groups = now + timezone.timedelta(days=3) + recruitment.save() + + # Initially app1 should be priority 1, app2 priority 2 + assert fixture_recruitment_application.applicant_priority == 1 + assert fixture_recruitment_application2.applicant_priority == 2 + + # Should be able to swap priorities before deadline + fixture_recruitment_application.update_priority(1) # Move app1 up + + fixture_recruitment_application.refresh_from_db() + fixture_recruitment_application2.refresh_from_db() + + assert fixture_recruitment_application.applicant_priority == 1 + assert fixture_recruitment_application2.applicant_priority == 2 + + def test_applicant_update_application_priority_after_deadline( + self, fixture_recruitment_application: RecruitmentApplication, fixture_recruitment_application2: RecruitmentApplication + ): + """Test that applicant cannot update their application priorities after the applicant reprioritization deadline""" + # Set up valid timeline now = timezone.now() - fixture_recruitment.visible_from = now - fixture_recruitment.shown_application_deadline = now + timezone.timedelta(hours=12) - fixture_recruitment.actual_application_deadline = now + timezone.timedelta(days=1) - fixture_recruitment.reprioritization_deadline_for_applicant = now + timezone.timedelta(days=2) - fixture_recruitment.reprioritization_deadline_for_groups = now + timezone.timedelta(days=3) - fixture_recruitment.save() - - # Try invalid order - applicant deadline after groups deadline - fixture_recruitment.reprioritization_deadline_for_applicant = now + timezone.timedelta(days=4) - fixture_recruitment.reprioritization_deadline_for_groups = now + timezone.timedelta(days=3) - - with pytest.raises(ValidationError) as exc: - fixture_recruitment.clean() - - error_dict = dict(exc.value) - assert fixture_recruitment.REPRIORITIZATION_GROUP_BEFORE_APPLICANT in error_dict['reprioritization_deadline_for_groups'] - assert fixture_recruitment.REPRIORITIZATION_APPLICANT_AFTER_GROUP in error_dict['reprioritization_deadline_for_applicant'] + recruitment = fixture_recruitment_application.recruitment + recruitment.visible_from = now + recruitment.shown_application_deadline = now + timezone.timedelta(hours=12) + recruitment.actual_application_deadline = now + timezone.timedelta(days=1) + recruitment.reprioritization_deadline_for_applicant = now + timezone.timedelta(days=2) + recruitment.reprioritization_deadline_for_groups = now + timezone.timedelta(days=3) + recruitment.save() + + initial_priority_1 = fixture_recruitment_application.applicant_priority + initial_priority_2 = fixture_recruitment_application2.applicant_priority + + # Mock being past applicant reprioritization deadline but before group deadline + with patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = now + timezone.timedelta(days=2, hours=1) + + # Should fail to update priorities + with pytest.raises(ValidationError) as exc: + fixture_recruitment_application.update_priority(1) + + assert 'Cannot reprioritize applications after the reprioritization deadline' in str(exc.value) + + # Verify priorities unchanged + fixture_recruitment_application.refresh_from_db() + fixture_recruitment_application2.refresh_from_db() + assert fixture_recruitment_application.applicant_priority == initial_priority_1 + assert fixture_recruitment_application2.applicant_priority == initial_priority_2 + + def test_applicant_priority_remains_sequential( + self, fixture_recruitment_application: RecruitmentApplication, fixture_recruitment_application2: RecruitmentApplication + ): + """Test that application priorities remain sequential (1,2,3...) when updated""" + # Set up valid timeline + now = timezone.now() + recruitment = fixture_recruitment_application.recruitment + recruitment.visible_from = now + recruitment.shown_application_deadline = now + timezone.timedelta(hours=12) + recruitment.actual_application_deadline = now + timezone.timedelta(days=1) + recruitment.reprioritization_deadline_for_applicant = now + timezone.timedelta(days=2) + recruitment.reprioritization_deadline_for_groups = now + timezone.timedelta(days=3) + recruitment.save() + + # Initial state should be sequential + assert fixture_recruitment_application.applicant_priority == 1 + assert fixture_recruitment_application2.applicant_priority == 2 + + # Update priority and verify it maintains sequence + fixture_recruitment_application2.update_priority(1) # Move app2 up (positive means higher priority) + + fixture_recruitment_application.refresh_from_db() + fixture_recruitment_application2.refresh_from_db() + + # Should have swapped but maintained sequence + assert fixture_recruitment_application2.applicant_priority == 1 + assert fixture_recruitment_application.applicant_priority == 2 From 7417f0d94771e01898ce5b4fb3d2a8a231bf9e90 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 06:02:49 +0100 Subject: [PATCH 09/15] adds more tests for validating application submit deadline, recruiter admin priority logic --- .../models/tests/test_recruitment.py | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/backend/samfundet/models/tests/test_recruitment.py b/backend/samfundet/models/tests/test_recruitment.py index 9a8c21ed5..d7b4da976 100644 --- a/backend/samfundet/models/tests/test_recruitment.py +++ b/backend/samfundet/models/tests/test_recruitment.py @@ -1143,3 +1143,191 @@ def test_applicant_priority_remains_sequential( # Should have swapped but maintained sequence assert fixture_recruitment_application2.applicant_priority == 1 assert fixture_recruitment_application.applicant_priority == 2 + + +@pytest.fixture +def test_user(): + """Fixture for creating test user with unique email""" + return User.objects.create( + username='testuser', + email='testuser@test.com', # Add required email + ) + + +@pytest.fixture +def test_recruitment_position(recruitment): + """Fixture for creating test position with all required fields""" + gang = Gang.objects.create(name_nb='TestGang', organization=recruitment.organization) + return RecruitmentPosition.objects.create( + name_nb='Test Position', + name_en='Test Position', + recruitment=recruitment, + gang=gang, + short_description_nb='Short description', + long_description_nb='Long description', + is_funksjonaer_position=False, + default_application_letter_nb='Default application letter', + tags='test', + ) + + +@pytest.mark.django_db +def test_deadline_validation_constraints(test_user): + """Test application validation against recruitment timeline deadlines""" + now = timezone.now() + org = Organization.objects.create(name='TestOrg') + + recruitment = Recruitment.objects.create( + name_nb='Test', + name_en='Test', + organization=org, + visible_from=now + timezone.timedelta(days=1), + shown_application_deadline=now + timezone.timedelta(days=2), + actual_application_deadline=now + timezone.timedelta(days=3), + reprioritization_deadline_for_applicant=now + timezone.timedelta(days=4), + reprioritization_deadline_for_groups=now + timezone.timedelta(days=5), + ) + + position = RecruitmentPosition.objects.create( + name_nb='Test Position', + name_en='Test Position', + recruitment=recruitment, + gang=Gang.objects.create(name_nb='TestGang', organization=org), + short_description_nb='Short description', + long_description_nb='Long description', + is_funksjonaer_position=False, + default_application_letter_nb='Default application letter', + tags='test', + ) + + # Test submitting before visible_from + with pytest.raises(ValidationError) as exc: + RecruitmentApplication.objects.create(application_text='Test', recruitment_position=position, recruitment=recruitment, user=test_user) + assert 'Recruitment period has not started yet' in str(exc.value) + + # Test submitting after deadline + with patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = now + timezone.timedelta(days=4) + with pytest.raises(ValidationError) as exc: + RecruitmentApplication.objects.create(application_text='Test', recruitment_position=position, recruitment=recruitment, user=test_user) + assert 'Application deadline has passed' in str(exc.value) + + +@pytest.mark.django_db +def test_priority_validation(): + """Test validation of application priority assignments""" + # Setup basic test data + org = Organization.objects.create(name='TestOrg') + recruitment = Recruitment.objects.create( + name_nb='Test', + name_en='Test', + organization=org, + visible_from=timezone.now(), + shown_application_deadline=timezone.now() + timezone.timedelta(days=2), + actual_application_deadline=timezone.now() + timezone.timedelta(days=3), + reprioritization_deadline_for_applicant=timezone.now() + timezone.timedelta(days=4), + reprioritization_deadline_for_groups=timezone.now() + timezone.timedelta(days=5), + ) + + position = RecruitmentPosition.objects.create( + name_nb='Test Position', + name_en='Test Position', + recruitment=recruitment, + gang=Gang.objects.create(name_nb='TestGang', organization=org), + short_description_nb='Short description', + long_description_nb='Long description', + is_funksjonaer_position=False, + default_application_letter_nb='Default application letter', + tags='test', + ) + + user = User.objects.create( + username='testuser2', + email='testuser2@test.com', + ) + + # Test setting priority higher than allowed + application = RecruitmentApplication.objects.create(application_text='Test', recruitment_position=position, recruitment=recruitment, user=user) + + with pytest.raises(ValidationError) as exc: + application.applicant_priority = 5 + application.save() + assert 'Priority cannot exceed active applications' in str(exc.value) + + +@pytest.mark.django_db +def test_reorder_priorities_edge_cases(): + """Test edge cases in priority (recruiter admin priority of application) reordering logic""" + now = timezone.now() + org = Organization.objects.create(name='TestOrg') + recruitment = Recruitment.objects.create( + name_nb='Test', + name_en='Test', + organization=org, + visible_from=now, + shown_application_deadline=now + timezone.timedelta(days=2), + actual_application_deadline=now + timezone.timedelta(days=3), + reprioritization_deadline_for_applicant=now + timezone.timedelta(days=4), + reprioritization_deadline_for_groups=now + timezone.timedelta(days=5), + ) + gang = Gang.objects.create(name_nb='TestGang', organization=org) + user = User.objects.create(username='testuser3', email='testuser3@test.com') + + # Create 3 positions + positions = [ + RecruitmentPosition.objects.create( + name_nb=f'Position {i}', + name_en=f'Position {i}', + recruitment=recruitment, + gang=gang, + short_description_nb=f'Short description {i}', + long_description_nb=f'Long description {i}', + is_funksjonaer_position=False, + default_application_letter_nb=f'Default letter {i}', + tags=f'test{i}', + ) + for i in range(3) + ] + + # Create 3 applications - they will be assigned priorities 1,2,3 automatically + applications = [ + RecruitmentApplication.objects.create(application_text=f'Test {i}', recruitment_position=positions[i], recruitment=recruitment, user=user) + for i in range(3) + ] + + # Verify initial priorities are 1,2,3 + applications = list(RecruitmentApplication.objects.filter(recruitment=recruitment, user=user).order_by('applicant_priority')) + + for i, app in enumerate(applications, start=1): + assert app.applicant_priority == i + + # Test trying to move priority 3 to priority 1 (up by 2) + applications[2].update_priority(2) # Positive means move up in priority (lower number) + + # Refresh all applications from DB + applications = list(RecruitmentApplication.objects.filter(recruitment=recruitment, user=user).order_by('applicant_priority')) + + # After moving priority 3 up by 2, it should be priority 1, others should shift down + assert applications[0].applicant_priority == 1 # The one we moved up + assert applications[1].applicant_priority == 2 # Shifted down + assert applications[2].applicant_priority == 3 # Shifted down + + # Test moving priority 1 up when already at top + applications[0].update_priority(1) + + applications = list(RecruitmentApplication.objects.filter(recruitment=recruitment, user=user).order_by('applicant_priority')) + + # Should maintain same order since already at top + assert applications[0].applicant_priority == 1 + assert applications[1].applicant_priority == 2 + assert applications[2].applicant_priority == 3 + + # Test moving priority 3 to priority 1 by large number (should cap at priority 1) + applications[2].update_priority(100) + + applications = list(RecruitmentApplication.objects.filter(recruitment=recruitment, user=user).order_by('applicant_priority')) + + # Should result in same order as moving up by 2 + assert applications[0].applicant_priority == 1 + assert applications[1].applicant_priority == 2 + assert applications[2].applicant_priority == 3 From be3ed84b6dc17f58e868c6f5e4f95d68fec9c8ae Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 06:12:52 +0100 Subject: [PATCH 10/15] small refactor to views and fixed erronius comment --- .../models/tests/test_recruitment.py | 2 +- backend/samfundet/views.py | 48 +++++++++---------- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/backend/samfundet/models/tests/test_recruitment.py b/backend/samfundet/models/tests/test_recruitment.py index d7b4da976..e20292bcb 100644 --- a/backend/samfundet/models/tests/test_recruitment.py +++ b/backend/samfundet/models/tests/test_recruitment.py @@ -1257,7 +1257,7 @@ def test_priority_validation(): @pytest.mark.django_db def test_reorder_priorities_edge_cases(): - """Test edge cases in priority (recruiter admin priority of application) reordering logic""" + """Test edge cases in priority reordering logic""" now = timezone.now() org = Organization.objects.create(name='TestOrg') recruitment = Recruitment.objects.create( diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 6f0f5b6ea..c8cde80d7 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -905,7 +905,7 @@ def get(self, request: Request, pk: int) -> Response: applications = RecruitmentApplication.objects.filter( recruitment_position__recruitment_id=pk, user=request.user, - withdrawn=True, # Only get non-withdrawn applications + withdrawn=True, ) serializer = RecruitmentApplicationForApplicantSerializer(applications, many=True) return Response(serializer.data) @@ -933,19 +933,22 @@ def put(self, request: Request, pk: str) -> Response: return Response(serializer.data, status=status.HTTP_200_OK) -# TODO SIMPLIFY THIS class RecruitmentApplicationApplicantPriorityView(APIView): + """ + View for handling applicant priority updates for recruitment applications. + Leverages the RecruitmentApplication model's built-in priority management. + """ + permission_classes = [IsAuthenticated] serializer_class = RecruitmentUpdateUserPrioritySerializer def put(self, request: Request, pk: int) -> Response: - direction = RecruitmentUpdateUserPrioritySerializer(data=request.data) - if direction.is_valid(): - direction = direction.validated_data['direction'] - else: - return Response(direction.errors, status=status.HTTP_400_BAD_REQUEST) + # Validate the direction from the request + direction_serializer = self.serializer_class(data=request.data) + if not direction_serializer.is_valid(): + return Response(direction_serializer.errors, status=status.HTTP_400_BAD_REQUEST) - # Get the current application and verify it exists and isn't withdrawn + # Get and verify the application exists and belongs to the user application = get_object_or_404( RecruitmentApplication, id=pk, @@ -953,27 +956,20 @@ def put(self, request: Request, pk: int) -> Response: withdrawn=False, ) - # Update the priority - application.update_priority(direction) + try: + # Update priority using the model's method which handles all reordering + application.update_priority(direction_serializer.validated_data['direction']) - # Get all non-withdrawn applications for this recruitment and user - active_applications = RecruitmentApplication.objects.filter( - recruitment=application.recruitment, - user=request.user, - withdrawn=False, # Explicitly exclude withdrawn applications - ).order_by('applicant_priority') + # Fetch the updated list of active applications + active_applications = RecruitmentApplication.objects.filter(recruitment=application.recruitment, user=request.user, withdrawn=False).order_by( + 'applicant_priority' + ) - # Rebase priorities to ensure they're sequential starting from 1 - for index, application in enumerate(active_applications, start=1): - if application.applicant_priority != index: - application.applicant_priority = index - application.save() + # Serialize and return the updated applications + return Response(RecruitmentApplicationForApplicantSerializer(active_applications, many=True).data) - serializer = RecruitmentApplicationForApplicantSerializer( - active_applications, - many=True, - ) - return Response(serializer.data) + except ValidationErr as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) class RecruitmentApplicationSetInterviewView(APIView): From dd3c7285f884a79a9da227638dd7c0e00696cc42 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 06:23:45 +0100 Subject: [PATCH 11/15] update seed script to respect recruitment position restrictions --- .../seed_scripts/recruitment_applications.py | 116 ++++++++++++++---- 1 file changed, 90 insertions(+), 26 deletions(-) diff --git a/backend/root/management/commands/seed_scripts/recruitment_applications.py b/backend/root/management/commands/seed_scripts/recruitment_applications.py index 6f5be2a63..1f2f51b08 100644 --- a/backend/root/management/commands/seed_scripts/recruitment_applications.py +++ b/backend/root/management/commands/seed_scripts/recruitment_applications.py @@ -1,42 +1,106 @@ from __future__ import annotations -from random import randint +from random import choice, randint +from datetime import timedelta + +from django.utils import timezone from samfundet.models.general import User -from samfundet.models.recruitment import RecruitmentPosition, RecruitmentApplication +from samfundet.models.recruitment import RecruitmentPosition, RecruitmentApplication, RecruitmentStatusChoices, RecruitmentPriorityChoices + +# Sample application texts for more realistic data +APPLICATION_TEXTS = [ + 'I am very interested in this position and would love to contribute to Samfundet.', + 'I have relevant experience and am excited about this opportunity.', + 'This position aligns perfectly with my interests and skills.', + 'I would be honored to join the team and contribute my expertise.', + 'I am passionate about this role and eager to learn and grow.', +] + + +def ensure_valid_recruitment_dates(position): + """Updates recruitment dates to ensure applications can be created.""" + recruitment = position.recruitment + now = timezone.now() + + # Only update if the deadline has passed + if recruitment.actual_application_deadline <= now: + recruitment.visible_from = now - timedelta(days=1) + recruitment.shown_application_deadline = now + timedelta(days=29) + recruitment.actual_application_deadline = now + timedelta(days=30) + recruitment.reprioritization_deadline_for_applicant = now + timedelta(days=35) + recruitment.reprioritization_deadline_for_groups = now + timedelta(days=40) + recruitment.save() -# Some example data to use for the new RecruitmentApplication instances -APPLICATION_DATA = { - 'application_text': 'This is the application text', - 'applicant_priority': 0, - 'recruiter_priority': 0, - 'recruiter_status': 0, -} + return recruitment -def seed(): +def get_priority_for_user(user, recruitment): + """Determines the next available priority for a user's applications.""" + existing_count = RecruitmentApplication.objects.filter(user=user, recruitment=recruitment, withdrawn=False).count() + return existing_count + 1 + + +def seed(): # noqa: C901 + """ + Seeds recruitment applications with realistic data while respecting application + constraints and deadlines. + """ yield 0, 'recruitment_applications' + + # Clear existing applications RecruitmentApplication.objects.all().delete() - yield 0, 'Deleted old applications' + yield 10, 'Deleted old applications' positions = RecruitmentPosition.objects.all() - users = User.objects.all() + users = list(User.objects.all()) # Convert to list to avoid multiple DB hits created_count = 0 for position_index, position in enumerate(positions): - for _ in range(randint(0, 5)): # Create between 0 and 5 instances for each position - application_data = APPLICATION_DATA.copy() - application_data.update( - { - 'recruitment_position': position, - 'recruitment': position.recruitment, - 'user': users[randint(0, len(users) - 1)], # random user from all users - } - ) - _application, created = RecruitmentApplication.objects.get_or_create(**application_data) - - if created: + # Ensure recruitment dates are valid + recruitment = ensure_valid_recruitment_dates(position) + + # Create between 0 and 5 applications for each position + num_applications = randint(0, 5) + + for _ in range(num_applications): + # Select a random user who hasn't exceeded application limit + user = choice(users) + + # Check if user has reached max applications (if limit exists) + if recruitment.max_applications: + existing_apps = RecruitmentApplication.objects.filter(user=user, recruitment=recruitment, withdrawn=False).count() + if existing_apps >= recruitment.max_applications: + continue + + # Check if user already applied for this position + if RecruitmentApplication.objects.filter(user=user, recruitment_position=position, withdrawn=False).exists(): + continue + + # Create application data + application_data = { + 'recruitment_position': position, + 'recruitment': recruitment, + 'user': user, + 'application_text': choice(APPLICATION_TEXTS), + 'applicant_priority': get_priority_for_user(user, recruitment), + 'recruiter_priority': choice(list(RecruitmentPriorityChoices)).value, + 'recruiter_status': choice(list(RecruitmentStatusChoices)).value, + 'withdrawn': False, + } + + try: + application = RecruitmentApplication.objects.create(**application_data) created_count += 1 - yield (position_index + 1) / len(positions), 'recruitment_applications' - yield 100, f'Created {created_count} recruitment_applications' + # Update applicant state after creation + application.update_applicant_state() + + except Exception as e: + yield (position_index + 1) / len(positions), f'Error creating application: {str(e)}' + continue + + progress = ((position_index + 1) / len(positions)) * 100 + yield progress, f'Processing position {position_index + 1} of {len(positions)}' + + yield 100, f'Successfully created {created_count} recruitment applications' From 6f8c464825065ae7e43d2887c432de0a001920cf Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 06:26:59 +0100 Subject: [PATCH 12/15] update seed script --- .../seed_scripts/recruitment_applications.py | 130 ++++++++++-------- 1 file changed, 75 insertions(+), 55 deletions(-) diff --git a/backend/root/management/commands/seed_scripts/recruitment_applications.py b/backend/root/management/commands/seed_scripts/recruitment_applications.py index 1f2f51b08..16357b7f2 100644 --- a/backend/root/management/commands/seed_scripts/recruitment_applications.py +++ b/backend/root/management/commands/seed_scripts/recruitment_applications.py @@ -3,12 +3,18 @@ from random import choice, randint from datetime import timedelta +from django.db import transaction from django.utils import timezone from samfundet.models.general import User -from samfundet.models.recruitment import RecruitmentPosition, RecruitmentApplication, RecruitmentStatusChoices, RecruitmentPriorityChoices +from samfundet.models.recruitment import ( + RecruitmentPosition, + RecruitmentStatistics, + RecruitmentApplication, + RecruitmentStatusChoices, + RecruitmentPriorityChoices, +) -# Sample application texts for more realistic data APPLICATION_TEXTS = [ 'I am very interested in this position and would love to contribute to Samfundet.', 'I have relevant experience and am excited about this opportunity.', @@ -19,20 +25,33 @@ def ensure_valid_recruitment_dates(position): - """Updates recruitment dates to ensure applications can be created.""" - recruitment = position.recruitment - now = timezone.now() + """ + Updates recruitment dates to ensure applications can be created. + Also ensures the recruitment has associated statistics. + """ + with transaction.atomic(): + recruitment = position.recruitment + now = timezone.now() + + # Only update if the deadline has passed + if recruitment.actual_application_deadline <= now: + recruitment.visible_from = now - timedelta(days=1) + recruitment.shown_application_deadline = now + timedelta(days=29) + recruitment.actual_application_deadline = now + timedelta(days=30) + recruitment.reprioritization_deadline_for_applicant = now + timedelta(days=35) + recruitment.reprioritization_deadline_for_groups = now + timedelta(days=40) - # Only update if the deadline has passed - if recruitment.actual_application_deadline <= now: - recruitment.visible_from = now - timedelta(days=1) - recruitment.shown_application_deadline = now + timedelta(days=29) - recruitment.actual_application_deadline = now + timedelta(days=30) - recruitment.reprioritization_deadline_for_applicant = now + timedelta(days=35) - recruitment.reprioritization_deadline_for_groups = now + timedelta(days=40) - recruitment.save() + # Ensure statistics exist before saving recruitment + if not hasattr(recruitment, 'statistics'): + RecruitmentStatistics.objects.create(recruitment=recruitment) - return recruitment + recruitment.save() + + # Double-check statistics exist even if dates weren't updated + if not hasattr(recruitment, 'statistics'): + RecruitmentStatistics.objects.create(recruitment=recruitment) + + return recruitment def get_priority_for_user(user, recruitment): @@ -41,7 +60,7 @@ def get_priority_for_user(user, recruitment): return existing_count + 1 -def seed(): # noqa: C901 +def seed(): """ Seeds recruitment applications with realistic data while respecting application constraints and deadlines. @@ -57,50 +76,51 @@ def seed(): # noqa: C901 created_count = 0 for position_index, position in enumerate(positions): - # Ensure recruitment dates are valid - recruitment = ensure_valid_recruitment_dates(position) + try: + # Ensure recruitment dates and statistics are valid + recruitment = ensure_valid_recruitment_dates(position) + + # Create between 0 and 5 applications for each position + num_applications = randint(0, 5) - # Create between 0 and 5 applications for each position - num_applications = randint(0, 5) + for _ in range(num_applications): + # Select a random user who hasn't exceeded application limit + user = choice(users) - for _ in range(num_applications): - # Select a random user who hasn't exceeded application limit - user = choice(users) + # Check if user has reached max applications (if limit exists) + if recruitment.max_applications: + existing_apps = RecruitmentApplication.objects.filter(user=user, recruitment=recruitment, withdrawn=False).count() + if existing_apps >= recruitment.max_applications: + continue - # Check if user has reached max applications (if limit exists) - if recruitment.max_applications: - existing_apps = RecruitmentApplication.objects.filter(user=user, recruitment=recruitment, withdrawn=False).count() - if existing_apps >= recruitment.max_applications: + # Check if user already applied for this position + if RecruitmentApplication.objects.filter(user=user, recruitment_position=position, withdrawn=False).exists(): continue - # Check if user already applied for this position - if RecruitmentApplication.objects.filter(user=user, recruitment_position=position, withdrawn=False).exists(): - continue - - # Create application data - application_data = { - 'recruitment_position': position, - 'recruitment': recruitment, - 'user': user, - 'application_text': choice(APPLICATION_TEXTS), - 'applicant_priority': get_priority_for_user(user, recruitment), - 'recruiter_priority': choice(list(RecruitmentPriorityChoices)).value, - 'recruiter_status': choice(list(RecruitmentStatusChoices)).value, - 'withdrawn': False, - } - - try: - application = RecruitmentApplication.objects.create(**application_data) - created_count += 1 - - # Update applicant state after creation - application.update_applicant_state() - - except Exception as e: - yield (position_index + 1) / len(positions), f'Error creating application: {str(e)}' - continue - - progress = ((position_index + 1) / len(positions)) * 100 - yield progress, f'Processing position {position_index + 1} of {len(positions)}' + # Create application with transaction to ensure consistency + with transaction.atomic(): + application_data = { + 'recruitment_position': position, + 'recruitment': recruitment, + 'user': user, + 'application_text': choice(APPLICATION_TEXTS), + 'applicant_priority': get_priority_for_user(user, recruitment), + 'recruiter_priority': choice(list(RecruitmentPriorityChoices)).value, + 'recruiter_status': choice(list(RecruitmentStatusChoices)).value, + 'withdrawn': False, + } + + application = RecruitmentApplication.objects.create(**application_data) + created_count += 1 + + # Update applicant state after creation + application.update_applicant_state() + + progress = ((position_index + 1) / len(positions)) * 100 + yield progress, f'Processing position {position_index + 1} of {len(positions)}' + + except Exception as e: + yield (position_index + 1) / len(positions), f'Error processing position {position_index + 1}: {str(e)}' + continue yield 100, f'Successfully created {created_count} recruitment applications' From b1baff291a9894f36b536f89c1d673d4610469d3 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 3 Nov 2024 06:28:22 +0100 Subject: [PATCH 13/15] rff --- .../commands/seed_scripts/recruitment_applications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/root/management/commands/seed_scripts/recruitment_applications.py b/backend/root/management/commands/seed_scripts/recruitment_applications.py index 16357b7f2..8a9d6d959 100644 --- a/backend/root/management/commands/seed_scripts/recruitment_applications.py +++ b/backend/root/management/commands/seed_scripts/recruitment_applications.py @@ -60,7 +60,7 @@ def get_priority_for_user(user, recruitment): return existing_count + 1 -def seed(): +def seed(): # noqa: C901 """ Seeds recruitment applications with realistic data while respecting application constraints and deadlines. From a7a17f4374a461291d0efbccb82f3190b228d724 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Mon, 4 Nov 2024 00:42:38 +0100 Subject: [PATCH 14/15] adds validation to constrain the ability to apply for the same position twice --- backend/samfundet/models/recruitment.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 065ae8de6..1a3cc15f2 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -578,6 +578,7 @@ def clean(self, *args: tuple, **kwargs: dict) -> None: # Don't validate withdrawn applications except for deadline if not self.withdrawn and self.applicant_priority: self._validate_priority(errors) + self._validate_no_duplicate_position(errors) if self.recruitment.max_applications: self._validate_application_limits(errors) @@ -587,6 +588,18 @@ def clean(self, *args: tuple, **kwargs: dict) -> None: if errors: raise ValidationError(errors) + def _validate_no_duplicate_position(self, errors: dict[str, list[str]]) -> None: + """ + Validates that the user hasn't already applied to this position, + while allowing updates to existing applications + """ + existing = ( + RecruitmentApplication.objects.filter(user=self.user, recruitment_position=self.recruitment_position, withdrawn=False).exclude(pk=self.pk).exists() + ) + + if existing: + errors['recruitment_position'].append('You have already applied for this position') + def _validate_priority(self, errors: dict[str, list[str]]) -> None: """ Validates that the application's priority is within allowed bounds. From 9ff4a4c87d24ed6183e018c081f222441d0228eb Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Mon, 4 Nov 2024 02:01:41 +0100 Subject: [PATCH 15/15] reverted newest change, andded them to another branch --- backend/samfundet/models/recruitment.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 1a3cc15f2..065ae8de6 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -578,7 +578,6 @@ def clean(self, *args: tuple, **kwargs: dict) -> None: # Don't validate withdrawn applications except for deadline if not self.withdrawn and self.applicant_priority: self._validate_priority(errors) - self._validate_no_duplicate_position(errors) if self.recruitment.max_applications: self._validate_application_limits(errors) @@ -588,18 +587,6 @@ def clean(self, *args: tuple, **kwargs: dict) -> None: if errors: raise ValidationError(errors) - def _validate_no_duplicate_position(self, errors: dict[str, list[str]]) -> None: - """ - Validates that the user hasn't already applied to this position, - while allowing updates to existing applications - """ - existing = ( - RecruitmentApplication.objects.filter(user=self.user, recruitment_position=self.recruitment_position, withdrawn=False).exclude(pk=self.pk).exists() - ) - - if existing: - errors['recruitment_position'].append('You have already applied for this position') - def _validate_priority(self, errors: dict[str, list[str]]) -> None: """ Validates that the application's priority is within allowed bounds.