Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1481 implement similar positions on recruitmentapplicationformpage #1596

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Copy link
Contributor

Choose a reason for hiding this comment

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

Kan kanskje være litt forvirrende om navn og tags ikke stemmer? Ikke så farlig for meg assa

Original file line number Diff line number Diff line change
@@ -1,21 +1,70 @@
from __future__ import annotations

from random import sample
from random import sample, randint

from root.utils.samfundet_random import words

from samfundet.models.general import Gang
from samfundet.models.recruitment import Recruitment, RecruitmentPosition

# Some example data to use for the new RecruitmentPosition instances
POSITION_DATA = {
'is_funksjonaer_position': False,
'default_application_letter_nb': 'Default Application Letter NB',
'default_application_letter_en': 'Default Application Letter EN',
'tags': 'tag1,tag2',
# Define position types with their associated tags
POSITION_TYPES = {
'technical': {
'tags': ['webdev', 'react', 'python', 'backend', 'frontend', 'database', 'devops'],
'name_prefix': 'Tech',
},
'media': {
'tags': ['photo', 'video', 'editing', 'photoshop', 'design', 'social-media'],
'name_prefix': 'Media',
},
'event': {
'tags': ['rigge', 'lys', 'lyd', 'scene', 'event-planning', 'booking'],
'name_prefix': 'Event',
},
'bar': {
'tags': ['bartender', 'kaffe', 'service', 'customer-service', 'cash-register'],
'name_prefix': 'Bar',
},
'culture': {
'tags': ['kultur', 'musikk', 'kunst', 'teater', 'festival', 'arrangement'],
'name_prefix': 'Culture',
},
'pr': {
'tags': ['marketing', 'social-media', 'writing', 'communication', 'pr', 'design'],
'name_prefix': 'PR',
},
}


def generate_tags(position_type: str, num_tags: int = 3) -> str:
"""Generate a comma-separated string of tags for a position"""
# Get base tags for the position type
base_tags = POSITION_TYPES[position_type]['tags']
# Select random number of tags (2-4)
selected_tags = sample(base_tags, min(num_tags, len(base_tags)))
return ','.join(selected_tags)


def generate_position_data(gang: Gang, recruitment: Recruitment, position_index: int, position_type: str) -> dict:
"""Generate data for a single position"""
name_prefix = POSITION_TYPES[position_type]['name_prefix']

return {
'name_nb': f'{gang.abbreviation} {name_prefix} {position_index}',
'name_en': f'{gang.abbreviation} {name_prefix} {position_index}',
'short_description_nb': words(3),
'short_description_en': words(3),
'long_description_nb': words(20),
'long_description_en': words(20),
'is_funksjonaer_position': bool(randint(0, 1)),
'default_application_letter_nb': 'Default Application Letter NB',
'default_application_letter_en': 'Default Application Letter EN',
'gang': gang,
'recruitment': recruitment,
'tags': generate_tags(position_type),
}


def seed():
yield 0, 'recruitment_positions'
RecruitmentPosition.objects.all().delete()
Expand All @@ -24,26 +73,24 @@ def seed():
gangs = Gang.objects.all()
recruitments = Recruitment.objects.all()
created_count = 0

for recruitment_index, recruitment in enumerate(recruitments):
for gang_index, gang in enumerate(sample(list(gangs), 6)):
for i in range(2): # Create 2 instances for each gang and recruitment
position_data = POSITION_DATA.copy()
position_data.update(
{
'name_nb': f'{gang.abbreviation} stilling {i}',
'name_en': f'{gang.abbreviation} position {i}',
'short_description_nb': words(3),
'short_description_en': words(3),
'long_description_nb': words(20),
'long_description_en': words(20),
'gang': gang,
'recruitment': recruitment,
}
)
_position, created = RecruitmentPosition.objects.get_or_create(**position_data)
# For each recruitment, select random gangs
selected_gangs = sample(list(gangs), 6)

for gang_index, gang in enumerate(selected_gangs):
# For each gang, create 2-4 positions with different types
num_positions = randint(2, 4)
position_types = sample(list(POSITION_TYPES.keys()), num_positions)

for i, position_type in enumerate(position_types):
position_data = generate_position_data(gang=gang, recruitment=recruitment, position_index=i + 1, position_type=position_type)

_, created = RecruitmentPosition.objects.get_or_create(**position_data)
if created:
created_count += 1
yield (gang_index + recruitment_index / len(recruitments)) / len(gangs), 'recruitment_positions'

progress = (gang_index + recruitment_index / len(recruitments)) / len(gangs)
yield progress, 'recruitment_positions'

yield 100, f'Created {created_count} recruitment_positions'
1 change: 1 addition & 0 deletions backend/root/utils/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,5 +607,6 @@
samfundet__recruitment_availability = 'samfundet:recruitment_availability'
samfundet__feedback = 'samfundet:feedback'
samfundet__purchase_feedback = 'samfundet:purchase_feedback'
samfundet__recruitment_positions_by_tags = 'samfundet:recruitment_positions_by_tags'
static__path = ''
media__path = ''
1 change: 0 additions & 1 deletion backend/samfundet/models/recruitment.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,6 @@ class RecruitmentPosition(CustomBaseModel):
help_text='Shared interviewgroup for position',
)

# TODO: Implement tag functionality
tags = models.CharField(max_length=100, help_text='Tags for the position')

# TODO: Implement interviewer functionality
Expand Down
2 changes: 2 additions & 0 deletions backend/samfundet/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -870,7 +870,9 @@ class Meta:
'short_description_en',
'long_description_nb',
'long_description_en',
'tags',
'is_funksjonaer_position',
'norwegian_applicants_only',
'default_application_letter_nb',
'default_application_letter_en',
'gang',
Expand Down
147 changes: 147 additions & 0 deletions backend/samfundet/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import time
from typing import TYPE_CHECKING

import pytest
from guardian.shortcuts import assign_perm

from rest_framework import status
Expand All @@ -15,12 +16,14 @@

from samfundet.serializers import UserSerializer, RegisterSerializer
from samfundet.models.general import (
Gang,
User,
Image,
Merch,
BlogPost,
KeyValue,
TextItem,
Organization,
InformationPage,
)
from samfundet.models.recruitment import (
Expand Down Expand Up @@ -1277,3 +1280,147 @@ def test_recruitment_application_update_pri_down(
assert response.data[0]['applicant_priority'] == 1
assert response.data[1]['id'] == str(fixture_recruitment_application.pk)
assert response.data[1]['applicant_priority'] == 2


@pytest.mark.django_db
class TestPositionByTagsView:
@pytest.fixture
def base_url(self, fixture_recruitment: Recruitment) -> str:
"""Get base URL for the tags endpoint"""
return reverse('samfundet:recruitment_positions_by_tags', kwargs={'id': fixture_recruitment.id})

@pytest.fixture
def authenticated_client(self, fixture_rest_client: APIClient, fixture_user: User) -> APIClient:
"""Get an authenticated client"""
fixture_rest_client.force_authenticate(user=fixture_user)
return fixture_rest_client

def create_position(
self,
name: str,
tags: str,
recruitment: Recruitment,
gang: Gang,
) -> RecruitmentPosition:
"""Helper to create a recruitment position with default values"""
return RecruitmentPosition.objects.create(
name_nb=f'Position {name}',
name_en=f'Position {name}',
short_description_nb=f'Short desc {name}',
short_description_en=f'Short desc {name}',
long_description_nb=f'Long desc {name}',
long_description_en=f'Long desc {name}',
is_funksjonaer_position=False,
default_application_letter_nb=f'Default letter {name}',
default_application_letter_en=f'Default letter {name}',
tags=tags,
recruitment=recruitment,
gang=gang,
)

def test_no_tags_parameter(self, authenticated_client: APIClient, base_url: str):
"""Test that requests without tags parameter return 400"""
response = authenticated_client.get(base_url)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data['message'] == 'No tags provided in query parameters'

def test_empty_tags_string(self, authenticated_client: APIClient, base_url: str):
"""Test that empty tags string returns 400"""
response = authenticated_client.get(f'{base_url}?tags=')
assert response.status_code == status.HTTP_400_BAD_REQUEST

def test_matching_single_tag(
self,
authenticated_client: APIClient,
base_url: str,
fixture_recruitment: Recruitment,
fixture_gang: Gang,
):
"""Test finding positions with a single matching tag"""
pos1 = self.create_position('1', 'developer,python', fixture_recruitment, fixture_gang)
self.create_position('2', 'designer,ui', fixture_recruitment, fixture_gang)

response = authenticated_client.get(f'{base_url}?tags=python')

assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 1
assert response.data['positions'][0]['id'] == pos1.id

def test_matching_multiple_tags(
self,
authenticated_client: APIClient,
base_url: str,
fixture_recruitment: Recruitment,
fixture_gang: Gang,
):
"""Test finding positions with multiple matching tags (OR operation)"""
pos1 = self.create_position('1', 'developer,python', fixture_recruitment, fixture_gang)
pos2 = self.create_position('2', 'designer,python', fixture_recruitment, fixture_gang)

response = authenticated_client.get(f'{base_url}?tags=python,designer')

assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 2
assert {pos['id'] for pos in response.data['positions']} == {pos1.id, pos2.id}

def test_case_insensitive_tag_matching(
self,
authenticated_client: APIClient,
base_url: str,
fixture_recruitment: Recruitment,
fixture_gang: Gang,
):
"""Test that tag matching is case insensitive"""
pos = self.create_position('1', 'Developer,PYTHON', fixture_recruitment, fixture_gang)

response = authenticated_client.get(f'{base_url}?tags=developer,python')

assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 1
assert response.data['positions'][0]['id'] == pos.id

def test_recruitment_filtering(
self,
authenticated_client: APIClient,
base_url: str,
fixture_recruitment: Recruitment,
fixture_gang: Gang,
fixture_organization: Organization,
):
"""Test that positions are filtered by recruitment_id"""
pos1 = self.create_position('1', 'developer', fixture_recruitment, fixture_gang)

# Create another recruitment
other_recruitment = Recruitment.objects.create(
name_nb='Other Recruitment',
name_en='Other Recruitment',
visible_from=fixture_recruitment.visible_from,
actual_application_deadline=fixture_recruitment.actual_application_deadline,
shown_application_deadline=fixture_recruitment.shown_application_deadline,
reprioritization_deadline_for_applicant=fixture_recruitment.reprioritization_deadline_for_applicant,
reprioritization_deadline_for_groups=fixture_recruitment.reprioritization_deadline_for_groups,
organization=fixture_organization,
)
self.create_position('2', 'developer', other_recruitment, fixture_gang)

response = authenticated_client.get(f'{base_url}?tags=developer')

assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 1
assert response.data['positions'][0]['id'] == pos1.id

def test_whitespace_tag_handling(
self,
authenticated_client: APIClient,
base_url: str,
fixture_recruitment: Recruitment,
fixture_gang: Gang,
):
"""Test that whitespace in tags is handled correctly"""
pos = self.create_position('1', 'developer, python', fixture_recruitment, fixture_gang)

response = authenticated_client.get(f'{base_url}?tags= developer , python ')

assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 1
assert response.data['positions'][0]['id'] == pos.id
1 change: 1 addition & 0 deletions backend/samfundet/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,5 @@
path('recruitment/<int:id>/availability/', views.RecruitmentAvailabilityView.as_view(), name='recruitment_availability'),
path('feedback/', views.UserFeedbackView.as_view(), name='feedback'),
path('purchase-feedback/', views.PurchaseFeedbackView.as_view(), name='purchase_feedback'),
path('recruitment/<int:id>/positions-by-tags/', views.PositionByTagsView.as_view(), name='recruitment_positions_by_tags'),
]
51 changes: 51 additions & 0 deletions backend/samfundet/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import csv
import hmac
import hashlib
import operator
from typing import Any
from functools import reduce

from guardian.shortcuts import get_objects_for_user

Expand Down Expand Up @@ -1342,3 +1344,52 @@ def post(self, request: Request) -> Response:
form=purchase_model,
)
return Response(status=status.HTTP_201_CREATED, data={'message': 'Feedback submitted successfully!'})


class PositionByTagsView(ListAPIView):
"""
Fetches recruitment positions by common tags for a specific recruitment.
Expects tags as query parameter in format: ?tags=tag1,tag2,tag3
Optionally accepts position_id parameter to exclude current position
"""

permission_classes = [AllowAny]
serializer_class = RecruitmentPositionForApplicantSerializer

def get_queryset(self) -> QuerySet:
recruitment_id = self.kwargs.get('id')
tags_param = self.request.query_params.get('tags')
Comment on lines +1360 to +1361
Copy link
Contributor

Choose a reason for hiding this comment

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

Funker det ikke å bruke getlist her?

current_position_id = self.request.query_params.get('position_id')

if not tags_param:
return RecruitmentPosition.objects.none()

# Split and clean the tags
tags = [tag.strip() for tag in tags_param.split(',') if tag.strip()]

if not tags:
return RecruitmentPosition.objects.none()

# Create Q objects for each tag to search in the tags field
tag_queries = [Q(tags__icontains=tag) for tag in tags]

# Combine queries with OR operator
combined_query = reduce(operator.or_, tag_queries)

# Base queryset with recruitment and tag filtering
queryset = RecruitmentPosition.objects.filter(combined_query, recruitment_id=recruitment_id).select_related('gang')

# Exclude current position if position_id is provided
if current_position_id:
queryset = queryset.exclude(id=current_position_id)

return queryset

def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
if not request.query_params.get('tags'):
return Response({'message': 'No tags provided in query parameters'}, status=status.HTTP_400_BAD_REQUEST)

queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)

return Response({'count': len(serializer.data), 'positions': serializer.data})
Loading