Skip to content

Commit

Permalink
Merge branch 'master' into feature/addinterviewgroup
Browse files Browse the repository at this point in the history
  • Loading branch information
magsyg authored Nov 5, 2024
2 parents eb2c2a0 + 475739b commit 3bb8bba
Show file tree
Hide file tree
Showing 109 changed files with 1,892 additions and 795 deletions.
18 changes: 18 additions & 0 deletions backend/samfundet/migrations/0010_recruitment_promo_media.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-10-31 19:56

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('samfundet', '0009_recruitmentpositionsharedinterviewgroup_name_en_and_more'),
]

operations = [
migrations.AddField(
model_name='recruitment',
name='promo_media',
field=models.CharField(blank=True, default=None, help_text='Youtube video id', max_length=11, null=True),
),
]
1 change: 1 addition & 0 deletions backend/samfundet/models/recruitment.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class Recruitment(CustomBaseModel):
organization = models.ForeignKey(null=False, blank=False, to=Organization, on_delete=models.CASCADE, help_text='The organization that is recruiting')

max_applications = models.PositiveIntegerField(null=True, blank=True, verbose_name='Max applications per applicant')
promo_media = models.CharField(max_length=11, help_text='Youtube video id', null=True, default=None, blank=True)

def resolve_org(self, *, return_id: bool = False) -> Organization | int:
if return_id:
Expand Down
49 changes: 34 additions & 15 deletions backend/samfundet/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import re
import itertools
from typing import TYPE_CHECKING
from collections import defaultdict
Expand Down Expand Up @@ -739,11 +740,22 @@ class Meta:

class RecruitmentSerializer(CustomBaseSerializer):
separate_positions = RecruitmentSeparatePositionSerializer(many=True, read_only=True)
promo_media = serializers.CharField(max_length=100, allow_blank=True, allow_null=True)

class Meta:
model = Recruitment
fields = '__all__'

def validate_promo_media(self, value: str | None) -> str | None:
if value is None or value == '':
return None
match = re.search(r'(youtu.*be.*)\/(watch\?v=|embed\/|v|shorts|)(.*?((?=[&#?])|$))', value)
if match:
return match.group(3)
if len(value) == 11:
return value
raise ValidationError('Invalid youtube url')

def to_representation(self, instance: Recruitment) -> dict:
data = super().to_representation(instance)
data['organization'] = OrganizationSerializer(instance.organization).data
Expand All @@ -770,6 +782,7 @@ class RecruitmentPositionSerializer(CustomBaseSerializer):

gang = GangSerializer(read_only=True)
interviewers = InterviewerSerializer(many=True, read_only=True)
interviewer_ids = serializers.ListField(child=serializers.IntegerField(), write_only=True, required=False)

class Meta:
model = RecruitmentPosition
Expand All @@ -779,37 +792,43 @@ def _update_interviewers(
self,
*,
recruitment_position: RecruitmentPosition,
interviewer_objects: list[dict],
interviewer_ids: list[int],
) -> None:
try:
interviewers = []
if interviewer_objects:
interviewer_ids = [interviewer.get('id') for interviewer in interviewer_objects]
if interviewer_ids:
interviewers = User.objects.filter(id__in=interviewer_ids)
recruitment_position.interviewers.set(interviewers)
except (TypeError, KeyError):
raise ValidationError('Invalid data for interviewers.') from None
if interviewer_ids:
try:
interviewers = User.objects.filter(id__in=interviewer_ids)
found_ids = set(interviewers.values_list('id', flat=True))
invalid_ids = set(interviewer_ids) - found_ids

if invalid_ids:
raise ValidationError(f'Invalid interviewer IDs: {invalid_ids}')

recruitment_position.interviewers.set(interviewers)
except (TypeError, ValueError):
raise ValidationError('Invalid interviewer IDs format.') from None
else:
recruitment_position.interviewers.clear()

def validate(self, data: dict) -> dict:
gang_id = self.initial_data.get('gang').get('id')
gang_id = self.initial_data.get('gang', {}).get('id')
if gang_id:
try:
data['gang'] = Gang.objects.get(id=gang_id)
except Gang.DoesNotExist:
raise serializers.ValidationError('Invalid gang id') from None

self.interviewer_ids = data.pop('interviewer_ids', [])

return super().validate(data)

def create(self, validated_data: dict) -> RecruitmentPosition:
recruitment_position = super().create(validated_data)
interviewer_objects = self.initial_data.get('interviewers', [])
self._update_interviewers(recruitment_position=recruitment_position, interviewer_objects=interviewer_objects)
self._update_interviewers(recruitment_position=recruitment_position, interviewer_ids=self.interviewer_ids)
return recruitment_position

def update(self, instance: RecruitmentPosition, validated_data: dict) -> RecruitmentPosition:
updated_instance = super().update(instance, validated_data)
interviewer_objects = self.initial_data.get('interviewers', [])
self._update_interviewers(recruitment_position=updated_instance, interviewer_objects=interviewer_objects)
self._update_interviewers(recruitment_position=updated_instance, interviewer_ids=self.interviewer_ids)
return updated_instance

def get_total_applicants(self, recruitment_position: RecruitmentPosition) -> int:
Expand Down
2 changes: 1 addition & 1 deletion frontend/.stylelintrc
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"declaration-block-single-line-max-declarations": null,
"declaration-property-max-values": null,
"max-nesting-depth": [
1,
2,
{
"ignoreAtRules": [""],
"ignore": ["pseudo-classes", "blockless-at-rules"]
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/Components/Carousel/Carousel.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@
transition: 0.2s;
cursor: pointer;

/* stylelint-disable-next-line selector-max-class, max-nesting-depth */
/* stylelint-disable-next-line selector-max-class */
&.left {
left: 1em;
}
/* stylelint-disable-next-line selector-max-class, max-nesting-depth */
/* stylelint-disable-next-line selector-max-class */
&.right {
right: 3em;
}
Expand Down
1 change: 0 additions & 1 deletion frontend/src/Components/CommandMenu/CommandMenu.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/* stylelint-disable selector-max-compound-selectors */
/* stylelint-disable selector-max-combinators */
/* stylelint-disable max-nesting-depth */
@import 'src/constants';

@import 'src/mixins';
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/Components/DatePicker/DatePicker.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
@use 'sass:color';

/* stylelint-disable-next-line no-invalid-position-at-import-rule */
@import 'src/constants';

/* stylelint-disable-next-line no-invalid-position-at-import-rule */
@import 'src/mixins';

.container {
position: relative;
width: 260px;
}

.button {
@include rounded-lighter;
display: flex;
gap: 0.25rem;
align-items: center;
width: 100%;
justify-content: flex-start;
font-size: 0.875rem;
padding: 0.75rem 2.5rem 0.75rem 1rem;
color: $black;
cursor: pointer;
margin-top: 0.5em; // Make sure this is the same for all inputs that should be used together
border: 1px solid $grey-35;
background-color: $white;
font-weight: initial;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);

&:hover {
background-color: $grey-4;
}

&:focus {
border-color: $grey-3;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
outline: 1px solid rgba(0, 0, 0, 0.1);
}

@include theme-dark {
background-color: $theme-dark-input-bg;
color: white;
border-color: $grey-0;
&:focus {
border-color: $grey-1;
outline: 1px solid rgba(255, 255, 255, 0.6);
}
&:hover {
background-color: color.scale($theme-dark-input-bg, $lightness: 8%);
}
}
}

.popover {
position: absolute;
left: 0;
top: 100%;
margin-top: 4px;
padding: 0.25rem;
background: $white;
border-radius: 0.5rem;
z-index: 100;
//box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.1);
//box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.1) 0px 2px 4px -2px;
border: 1px solid $grey-35;

@include theme-dark {
background: $black-1;
border-color: $grey-0;
}
}

.hidden {
display: none;
}
20 changes: 20 additions & 0 deletions frontend/src/Components/DatePicker/DatePicker.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/react';
import { DatePicker } from './DatePicker';

// Local component config.
const meta = {
title: 'Components/DatePicker',
component: DatePicker,
} satisfies Meta<typeof DatePicker>;

export default meta;

type Story = StoryObj<typeof meta>;

const onChange = (value: unknown) => console.log('Selected:', value);

export const Basic: Story = {
args: {
onChange,
},
};
76 changes: 76 additions & 0 deletions frontend/src/Components/DatePicker/DatePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Icon } from '@iconify/react';
import classNames from 'classnames';
import { format } from 'date-fns';
import React, { useMemo } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, MiniCalendar } from '~/Components';
import { useClickOutside } from '~/hooks';
import { KEY } from '~/i18n/constants';
import styles from './DatePicker.module.scss';

type DatePickerProps = {
label?: string;
disabled?: boolean;
value?: Date | null;
buttonClassName?: string;
onChange?: (date: Date | null) => void;
className?: string;

minDate?: Date;
maxDate?: Date;
};

export function DatePicker({
value: initialValue,
onChange,
disabled,
label,
buttonClassName,
minDate,
maxDate,
}: DatePickerProps) {
const isControlled = initialValue !== undefined;

const [date, setDate] = useState<Date | null>(null);
const [open, setOpen] = useState(false);

const { t } = useTranslation();

const clickOutsideRef = useClickOutside<HTMLDivElement>(() => setOpen(false));

const value = useMemo(() => {
if (isControlled) {
return initialValue;
}
return date;
}, [isControlled, initialValue, date]);

function handleChange(d: Date | null) {
setDate(d);
onChange?.(d);
}

return (
<div className={styles.container} ref={clickOutsideRef}>
<button
type="button"
className={classNames(styles.button, buttonClassName)}
onClick={() => setOpen((v) => !v)}
disabled={disabled}
>
<Icon icon="material-symbols:calendar-month-outline-rounded" />
{value ? format(value, 'PPP') : <span>{label ?? t(KEY.pick_a_date)}</span>}
</button>
<div className={classNames(styles.popover, !open && styles.hidden)}>
<MiniCalendar
baseDate={value || new Date()}
onChange={handleChange}
minDate={minDate}
maxDate={maxDate}
displayLabel
/>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions frontend/src/Components/DatePicker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DatePicker } from './DatePicker';
4 changes: 4 additions & 0 deletions frontend/src/Components/Dropdown/Dropdown.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

@import 'src/mixins';

.italic {
font-style: italic;
}

// label som wrapper
.select_wrapper {
display: flex;
Expand Down
Loading

0 comments on commit 3bb8bba

Please sign in to comment.