diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c33e1fe --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + + +[*] + +indent_style = space +indent_size = 4 +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a643a3d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,87 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Unit tests + +on: + workflow_dispatch: + push: + branches: + - 'develop' + - 'main' + - 'feature/**' + - 'bugfix/**' + - 'hotfix/**' + - 'release/**' + +jobs: + build: + + runs-on: ubuntu-20.04 + + services: + # Label used to access the service container + postgres: + # Docker Hub image + image: postgres + # Provide the password for postgres + env: + POSTGRES_DB: wisselwerking + POSTGRES_USER: wisselwerking_user + POSTGRES_PASSWORD: wisselwerking_pwd + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + # Maps tcp port 5432 on service container to the host + - 5432:5432 + + strategy: + matrix: + python-version: ['3.8'] + node-version: ['18.x', '20.x'] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - name: Install Python dependencies + run: | + cd backend + python -m pip install --upgrade pip + pip install virtualenv + pip install -r requirements.txt + - name: Lint with flake8 + run: | + pip install flake8 + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Run all tests + env: + # Provide PostgreSQL environment variables in order to default to TCP connection + PGDATABASE: wisselwerking + PGHOST: localhost + PGPORT: ${{ job.services.postgres.ports['5432'] }} + PGUSER: wisselwerking_user + PGPASSWORD: wisselwerking_pwd + run: | + cat bootstrap_ci.txt | python bootstrap.py + yarn + yarn django migrate + # start the backend first for the SSR + yarn start-back-p & + sleep 10 + yarn static-p + find static + chromedriver --version + yarn test diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..d78bf0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18.20.4 diff --git a/README.md b/README.md index 9bf75f7..2f9b9c0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ python magic.py aanmeldformulier-wisselwerking.csv "/run/user/1000/gvfs/dav:host (dat laatste is de locatie van de voorgaande toewijzingen op de O-schijf) - ## Statistieken Genereer csv-bestanden (geanonimiseerd!) met informatie over deelname in het verleden: diff --git a/backend/example/admin.py b/backend/example/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/backend/example/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/backend/example/apps.py b/backend/example/apps.py deleted file mode 100644 index dbb0083..0000000 --- a/backend/example/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class ExampleConfig(AppConfig): - name = 'example' diff --git a/backend/example/models.py b/backend/example/models.py deleted file mode 100644 index 71a8362..0000000 --- a/backend/example/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/backend/example/views.py b/backend/example/views.py deleted file mode 100644 index 7965457..0000000 --- a/backend/example/views.py +++ /dev/null @@ -1,11 +0,0 @@ -from rest_framework.decorators import api_view -from rest_framework.response import Response - -# This a very basic View that servers as an example. -# Note that, when utilizing models and a database, etc, you would not have -# views like this, but rather Viewsets. See the Django Rest Framework (DRF) documentation. - -@api_view() -def hooray(request): - response = [{ 'message': 'https://media.giphy.com/media/yoJC2GnSClbPOkV0eA/source.gif' }] - return Response(response) diff --git a/backend/example/__init__.py b/backend/registration/__init__.py similarity index 100% rename from backend/example/__init__.py rename to backend/registration/__init__.py diff --git a/backend/registration/admin.py b/backend/registration/admin.py new file mode 100644 index 0000000..e53ab13 --- /dev/null +++ b/backend/registration/admin.py @@ -0,0 +1,233 @@ +from typing import List, Set, cast +from django import forms +from django.contrib import admin, messages +from django.contrib.postgres.aggregates import StringAgg +from django.urls import path +from django.db.models.query import QuerySet + + +from registration.models import ( + Person, + Department, + DepartmentDescription, + Exchange, + ExchangeDescription, + ExchangeSession, + ExchangeSessionDescription, + Mail, + PersonMail, + Registration, +) + + +class ExchangeSessionInline(admin.TabularInline): + model = ExchangeSession + show_change_link = True + readonly_fields = ("exchange", "subtitles", "assigned_count") + fields = readonly_fields + extra = 0 + can_delete = False + max_num = 0 + ordering = ["exchange__begin"] + + +class DepartmentDescriptionInline(admin.TabularInline): + model = DepartmentDescription + max_num = 2 + + +class DepartmentAdmin(admin.ModelAdmin): + list_display = ["slug", "name"] + + inlines = [DepartmentDescriptionInline, ExchangeSessionInline] + filter_horizontal = ["contact_persons"] + + def get_queryset(self, request): + queryset = super().get_queryset(request) + queryset = queryset.annotate( + _name=StringAgg("description__name", " / "), + ).order_by("_name") + return queryset + + +class ExchangeDescriptionInline(admin.TabularInline): + model = ExchangeDescription + max_num = 2 + + +class ExchangeAdmin(admin.ModelAdmin): + inlines = [ExchangeDescriptionInline, ExchangeSessionInline] + ordering = ["begin"] + list_display = ["__str__", "active"] + + +class ExchangeSessionDescriptionInline(admin.StackedInline): + model = ExchangeSessionDescription + max_num = 2 + + +class ExchangeSessionAdmin(admin.ModelAdmin): + actions = ["copy_exchange"] + list_display = ["department", "titles", "subtitles", "exchange"] + list_filter = ["exchange", "department"] + inlines = [ExchangeSessionDescriptionInline] + filter_horizontal = ["assigned", "organizers"] + readonly_fields = ["assigned"] + + def get_queryset(self, request): + queryset = super().get_queryset(request) + queryset = queryset.annotate( + _name=StringAgg("department__description__name", " / "), + ).order_by("exchange", "_name") + return queryset + + @admin.action(description="Copy to active exchange") + def copy_exchange(self, request, queryset): + exchange = Exchange.objects.get(active=True) + for obj in queryset: + session = cast(ExchangeSession, obj) + copy = ExchangeSession() + copy.exchange = exchange + copy.department = session.department + copy.participants_min = session.participants_min + copy.participants_max = session.participants_max + copy.session_count = session.session_count + copy.save() + copy.organizers.set(session.organizers.all()) + + for description in session.description.all(): + description.pk = None + description.exchange = copy + description.save() + + messages.success(request, "Successfully copied to latest exchange!") + + +class MailAdmin(admin.ModelAdmin): + list_display = ["type", "language", "subject"] + ordering = ["language", "type"] + + +class PersonRegistrationsInline(admin.TabularInline): + model = Registration + readonly_fields = ("session", "priority", "date_time") + extra = 0 + can_delete = False + max_num = 0 + ordering = ["session__exchange__begin"] + + +class PersonMailInline(admin.TabularInline): + model = PersonMail + extra = 0 + + +class PersonForm(forms.ModelForm): + given_names = forms.CharField() + surnames = forms.CharField() + main_mail = forms.ChoiceField(choices=[], required=False) + sessions = forms.CharField(widget=forms.Textarea, disabled=True, required=False) + organizes = forms.CharField(widget=forms.Textarea, disabled=True, required=False) + + def save(self, commit=True): + main_mail = self.cleaned_data.get("main_mail", None) + given_names = self.cleaned_data.get("given_names", None) + surnames = self.cleaned_data.get("surnames", None) + + person: Person = self.instance + + if main_mail != person.user.email: + # swap PersonMail objects + pm = PersonMail.objects.get(person=person, address=main_mail) + pm.address = person.user.email + # record that the person's mail is different now + # so this address will be saved + person.user.email = main_mail + pm.person = person + pm.save() + + person.user.first_name = given_names + person.user.last_name = surnames + person.user.save() + + # ...do something with extra_field here... + return super().save(commit=commit) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + person: Person = self.instance + emails = set([person.email]) + for pm in PersonMail.objects.filter(person=person): + emails.add(pm.address) + self.fields["main_mail"].choices = set((m, m) for m in emails) + + self.fields["given_names"].initial = person.given_names + self.fields["surnames"].initial = person.surnames + self.fields["main_mail"].initial = person.email + + # get and display all the sessions + self.fields["organizes"].initial = self.list_sessions( + ExchangeSession.objects.filter(organizers=person) + ) + self.fields["sessions"].initial = self.list_sessions( + ExchangeSession.objects.filter(assigned=person) + ) + + def list_sessions(self, query_set: QuerySet[Person]) -> str: + return "\n".join(sorted(str(session) for session in query_set)) + + class Meta: + model = Person + fields = "__all__" + + +class PersonAdmin(admin.ModelAdmin): + form = PersonForm + actions = ["merge_persons"] + list_display = ["full_name", "get_affiliation"] + ordering = ["user__first_name", "user__last_name"] + filter_horizontal = ["departments"] + fields = ( + "user", + "given_names", + "prefix_surname", + "surnames", + "main_mail", + "url", + "language", + "departments", + "other_affiliation", + "organizes", + "sessions", + ) + inlines = [PersonRegistrationsInline, PersonMailInline] + + @admin.action(description="Merge person records") + def merge_persons(self, request, queryset: QuerySet): + persons: List[Person] = list(queryset.all()) + if len(persons) != 2: + messages.error(request, "Select two records!") + return + + persons[0].move_to(persons[1]) + + messages.success( + request, f"Successfully merged records for {persons[0].full_name}!" + ) + + def has_add_permission(self, request, obj=None): + return False + + +class RegistrationAdmin(admin.ModelAdmin): + list_display = ["requestor", "session", "priority", "date_time"] + ordering = ["date_time"] + list_filter = ["session__department", "session__exchange"] + + +admin.site.register(Person, PersonAdmin) +admin.site.register(Department, DepartmentAdmin) +admin.site.register(Exchange, ExchangeAdmin) +admin.site.register(ExchangeSession, ExchangeSessionAdmin) +admin.site.register(Mail, MailAdmin) +admin.site.register(Registration, RegistrationAdmin) diff --git a/backend/registration/apps.py b/backend/registration/apps.py new file mode 100644 index 0000000..f9d01d2 --- /dev/null +++ b/backend/registration/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RegistrationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'registration' diff --git a/backend/example/migrations/__init__.py b/backend/registration/management/__init__.py similarity index 100% rename from backend/example/migrations/__init__.py rename to backend/registration/management/__init__.py diff --git a/wisselwerking/__init__.py b/backend/registration/management/commands/__init__.py similarity index 100% rename from wisselwerking/__init__.py rename to backend/registration/management/commands/__init__.py diff --git a/backend/registration/management/commands/assign.py b/backend/registration/management/commands/assign.py new file mode 100644 index 0000000..ffd0aea --- /dev/null +++ b/backend/registration/management/commands/assign.py @@ -0,0 +1,126 @@ +from typing import Any, Dict, List, Set +from django.core.management.base import BaseCommand, CommandError + +from registration.models import Exchange, ExchangeSession, Person, Registration + + +class Command(BaseCommand): + help = "Assign the persons to the exchanges for the current session" + + exchange: Exchange + requestors: Set[Person] = set() + unassigned_requestors: Set[Person] = set() + assigned_random: Set[Person] = set() + # key is the pk + capacities: Dict[any, int] = {} + session_counts: Dict[any, int] = {} + + def handle(self, *args, **options): + self.exchange = Exchange.objects.get(active=True) + + # clear all current assignments + for session in ExchangeSession.objects.filter(exchange=self.exchange): + session.assigned.clear() + + # this assumes there is ONE registration moment per year + registrations = Registration.objects.filter( + date_time__year=self.exchange.begin + ).order_by("date_time") + + # mark everyone as unassigned + for registration in registrations: + self.requestors.add(registration.requestor) + self.unassigned_requestors.add(registration.requestor) + + # first try to give everyone their first pick + for priority in (1, 2, 3): + for registration in registrations: + if registration.priority == priority: + self.attempt_placement(registration) + + print(f"Unassigned requestors: {len(self.unassigned_requestors)}") + + empty_sessions: List[ExchangeSession] = [] + # we want more! + too_low_sessions: List[ExchangeSession] = [] + for session in ExchangeSession.objects.filter(exchange=self.exchange): + if session.assigned.count() == 0: + empty_sessions.append(session) + elif session.participants_min > self.session_counts[session.pk]: + too_low_sessions.append(session) + + print(f"Empty sessions: {len(empty_sessions)}") + for session in empty_sessions: + print( + f" - {session} (pk={session.pk}; max_participants={session.participants_max})" + ) + + if len(too_low_sessions) > 0: + print(f"Too few participants in sessions: {len(too_low_sessions)}") + for session in too_low_sessions: + print( + f" - {session} (pk={session.pk}; min_participants={session.participants_min}; actual={self.session_counts[session.pk]})" + ) + + print(f"Requestors: {len(self.requestors)}") + print(f"Random assignees left: {len(self.assigned_random)}") + + for person in self.assigned_random: + self.assign_random(person) + + def get_capacity(self, session_pk: Any) -> int: + try: + return self.capacities[session_pk] + except KeyError: + capacity = self.capacities[session_pk] = ExchangeSession.objects.get( + pk=session_pk + ).participants_max + self.session_counts[session_pk] = 0 + return capacity + + def assign_random(self, person: Person): + print(f"{person} already participated in: ") + for session in ExchangeSession.objects.filter(assigned=person): + print(f" - {session}") + + while True: + try: + session_pk = input("Assign to pk? ") + session = ExchangeSession.objects.get(pk=int(session_pk)) + if self.get_capacity(session.pk) <= 0: + raise Exception("Not enough capacity!") + elif session.exchange != self.exchange: + raise Exception("Invalid exchange") + else: + break + except Exception as error: + print(type(error)) + print(error) + + self.perform_placement(person, session, False) + + def attempt_placement(self, registration: Registration): + person = registration.requestor + if person not in self.unassigned_requestors: + # already assigned to something + return + + if registration.session is None: + # assign to something random (done at the end) + self.assigned_random.add(person) + self.unassigned_requestors.remove(person) + else: + # is there capacity left on this exchange? + capacity = self.get_capacity(registration.session.pk) + if capacity > 0: + self.perform_placement(registration.requestor, registration.session) + + def perform_placement(self, person: Person, session: ExchangeSession, remove=True): + # lower capacity by one + self.capacities[session.pk] -= 1 + self.session_counts[session.pk] += 1 + + if remove: + self.unassigned_requestors.remove(person) + + session.assigned.add(person) diff --git a/backend/registration/management/commands/enrich.py b/backend/registration/management/commands/enrich.py new file mode 100644 index 0000000..5b8c027 --- /dev/null +++ b/backend/registration/management/commands/enrich.py @@ -0,0 +1,153 @@ +import csv +import pathlib +from typing import Dict, List, Tuple +from django.contrib.auth.models import User, Group +from django.core.management.base import BaseCommand, CommandError +import os +import datetime + +from registration.models import ( + Exchange, + ExchangeSession, + ExchangeSessionDescription, + Mail, + Person, +) + +DEFAULT_LANGUAGE = "nl" + +ENROLLMENT_ADD = "_fd_Add" +ENROLLMENT_MAIL = "e_mailadres" +ENROLLMENT_ASSIGNED = "toegewezen" +ENROLLMENT_MAIL_SUBJECT = "onderwerp" +ENROLLMENT_MAIL_CONTENT = "bericht" + + +class Command(BaseCommand): + help = "Enriches data of an existing enrollments" + + def add_arguments(self, parser): + parser.add_argument("files", nargs="+", type=str) + + def handle(self, *args, **options): + project_root = pathlib.Path( + __file__ + ).parent.parent.parent.parent.parent.resolve() + + for filename in options["files"]: + filepath = os.path.join(project_root, filename) + if not os.path.isfile(filepath): + raise CommandError(f"File {filepath} does not exist") + + enriched, fieldnames = self.enrich_data(filepath) + self.write_data(filepath, fieldnames, enriched) + + def enrich_data(self, filepath: str) -> Tuple[List[List[str]], List[str]]: + output: List[Dict[str, str]] = [] + team = self.get_team_str() + with open(filepath, mode="r", encoding="utf-8-sig") as csv_file: + csv_reader = csv.DictReader(csv_file, delimiter=";") + + for row in csv_reader: + email = row[ENROLLMENT_MAIL].lower() + + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + raise CommandError( + f"User {email} does not exist. Import the file first." + ) + + try: + person = Person.objects.get(user=user) + except Person.DoesNotExist: + raise CommandError( + f"Person {email} does not exist. Import the file first." + ) + + add = None + for format in [ + "%d-%m-%Y %H:%M", + "%d-%m-%Y, %H:%M", + "%d-%m-%y %H:%M", + "%d-%m-%Y", + "%d-%m-%y", + ]: + try: + add = datetime.datetime.strptime( + row[ENROLLMENT_ADD], format + ).astimezone() + except ValueError: + continue + break + if add is None: + raise ValueError(f"Could not parse {row[ENROLLMENT_ADD]}") + + try: + exchange = Exchange.objects.get(begin=add.year) + except Exchange.DoesNotExist: + raise CommandError( + f"Exchange {add.year}-{add.year + 1} does not exist. Import the file first." + ) + + assigned = ExchangeSession.objects.get( + assigned=person, exchange=exchange + ) + try: + mail = Mail.objects.get( + type="assigned", language=person.language or DEFAULT_LANGUAGE + ) + except Mail.DoesNotExist: + mail = Mail.objects.get(type="assigned", language=DEFAULT_LANGUAGE) + try: + title = ExchangeSessionDescription.objects.get( + exchange=assigned, language=person.language or DEFAULT_LANGUAGE + ).title + except ExchangeSessionDescription.DoesNotExist: + title = ExchangeSessionDescription.objects.get( + exchange=assigned, language=DEFAULT_LANGUAGE + ).title + + row[ENROLLMENT_ASSIGNED] = title + row[ENROLLMENT_MAIL_SUBJECT], row[ENROLLMENT_MAIL_CONTENT] = ( + self.prepare_mail(person, mail, title, team) + ) + output.append(row) + + return output, list(csv_reader.fieldnames) + [ + ENROLLMENT_ASSIGNED, + ENROLLMENT_MAIL_SUBJECT, + ENROLLMENT_MAIL_CONTENT, + ] + + def write_data( + self, filepath: str, fieldnames: List[str], enriched: List[Dict[str, str]] + ) -> None: + target_filepath, extension = os.path.splitext(filepath) + target_filepath += "_out" + extension + + with open(target_filepath, mode="w", encoding="utf-8-sig") as csv_file: + csv_writer = csv.DictWriter(csv_file, delimiter=";", fieldnames=fieldnames) + csv_writer.writeheader() + csv_writer.writerows(enriched) + + def prepare_mail( + self, person: Person, mail: Mail, assigned: str, team: str + ) -> Tuple[str, str]: + data = { + "given_names": person.given_names.strip(), + "assigned": assigned, + "team": team, + } + return self.enrich_mail(mail.subject, data), self.enrich_mail(mail.text, data) + + def enrich_mail(self, text: str, data: Dict[str, str]) -> str: + for key, value in data.items(): + text = text.replace(f"{{{{{key}}}}}", value) + + return text + + def get_team_str(self) -> str: + team = Group.objects.get(name="Team") + persons = Person.objects.filter(user__groups=team).order_by("user__first_name") + return ", ".join(person.full_name for person in persons) diff --git a/backend/registration/management/commands/history.py b/backend/registration/management/commands/history.py new file mode 100644 index 0000000..9c0aa9e --- /dev/null +++ b/backend/registration/management/commands/history.py @@ -0,0 +1,187 @@ +from dataclasses import dataclass +import os +import csv +import pathlib + +from typing import Dict, List, Tuple, cast +from django.core.management.base import BaseCommand, CommandError +from registration.models import Exchange, ExchangeSession, Person + + +HISTORY_YEARS = "jaren" +HISTORY_HOW_MANY = "hoeveelste_keer" +ENROLLMENT_DEPT = "afdeling" +ASSIGNED_CHOICE = "toegewezen" + + +@dataclass +class Enrollment: + assigned_dept: str + from_dept: str + + +class Command(BaseCommand): + help = "Export statistics about the enrollments" + project_root: str + + def handle(self, *args, **options): + self.project_root = pathlib.Path( + __file__ + ).parent.parent.parent.parent.parent.resolve() + + fieldnames, rows = self.get_enrollments() + self.write_file("history.csv", fieldnames, rows) + + fieldnames, rows = self.new_participants_each_year() + self.write_file("history_new_participants.csv", fieldnames, rows) + + fieldnames, rows = self.histogram() + self.write_file("history_histogram.csv", fieldnames, rows) + + fieldnames, rows = self.depts_histogram() + self.write_file("history_depts_histogram.csv", fieldnames, rows) + + def write_file( + self, filename: str, keys: List[str], rows: List[Dict[str, str]] + ) -> None: + with open( + os.path.join(self.project_root, filename), "w", encoding="utf-8-sig" + ) as csv_file: + writer = csv.DictWriter(csv_file, fieldnames=keys, delimiter=";") + writer.writeheader() + writer.writerows(rows) + + def get_enrollments(self) -> Tuple[List[str], List[Dict[str, str]]]: + participant_count: Dict[any, int] = {} + keys = [ + "id", + "count", + HISTORY_HOW_MANY, + HISTORY_YEARS, + ENROLLMENT_DEPT, + ASSIGNED_CHOICE, + ] + rows: List[Dict[str, str]] = [] + + for session in ExchangeSession.objects.all(): + for enrollment in session.assigned.all(): + person: Person = enrollment + participant_id = person.pk + try: + how_many = participant_count[participant_id] + 1 + except KeyError: + participant_count[participant_id] = 0 + how_many = 1 + participant_count[participant_id] += 1 + rows.append( + { + "id": participant_id, + "count": 1, # makes pivot tables easier to create + HISTORY_HOW_MANY: how_many, + HISTORY_YEARS: f"{session.exchange.begin}-{session.exchange.end}", + ENROLLMENT_DEPT: person.get_affiliation(), + ASSIGNED_CHOICE: session.get_prefer_dutch_name(), + } + ) + + return keys, rows + + def per_year_enrollment(self): + per_year: Dict[str, List[any]] = {} + per_year_enrollment: Dict[str, List[Enrollment]] = {} + + for session in ExchangeSession.objects.all(): + for person in session.assigned.all(): + years = f"{session.exchange.begin}-{session.exchange.end}" + participant_id = person.id + enrollment = Enrollment( + session.get_prefer_dutch_name(), + cast(Person, person).get_affiliation(), + ) + try: + per_year[years].append(participant_id) + per_year_enrollment[years].append(enrollment) + except KeyError: + per_year[years] = [participant_id] + per_year_enrollment[years] = [enrollment] + + return per_year, per_year_enrollment + + def new_participants_each_year(self) -> Tuple[List[str], List[Dict[str, str]]]: + # new participants each year + per_year, _ = self.per_year_enrollment() + + all_previous_years = list(sorted(per_year.keys()))[:-1] + + fieldnames = [HISTORY_YEARS] + all_previous_years + ["completely_new"] + + previous_years: List[str] = [] + rows: List[Dict[str, str]] = [] + i = 0 + for years in sorted(per_year.keys()): + new_count = 0 + prev_years_counts = dict.fromkeys(all_previous_years, 0) + for p in per_year[years]: + for prev_years in all_previous_years[0:i]: + if prev_years != years and p in per_year[prev_years]: + prev_years_counts[prev_years] += 1 + break + else: + new_count += 1 + + rows.append( + {HISTORY_YEARS: years, **prev_years_counts, "completely_new": new_count} + ) + + previous_years.insert(0, years) + i += 1 + + return fieldnames, rows + + def histogram(self): + # how many times do people participate over the years? + histogram: Dict[int, int] = {} + + participant_count: Dict[any, int] = {} + + for session in ExchangeSession.objects.all(): + for enrollment in session.assigned.all(): + person: Person = enrollment + participant_id = person.pk + try: + participant_count[participant_id] + 1 + except KeyError: + participant_count[participant_id] = 0 + participant_count[participant_id] += 1 + + for participant_id, participant_count in participant_count.items(): + try: + histogram[participant_count] += 1 + except KeyError: + histogram[participant_count] = 1 + + rows = [{"times": times, "count": count} for times, count in sorted(histogram.items())] + return ["times", "count"], rows + + def depts_histogram(self): + # how many different departments participated? + _, per_year_enrollment = self.per_year_enrollment() + + rows: List[Dict[str, str]] = [] + + for years, enrollments in per_year_enrollment.items(): + assigned_depts = set() + from_depts = set() + for e in enrollments: + assigned_depts.add(e.assigned_dept) + from_depts.add(e.from_dept) + + rows.append( + { + HISTORY_YEARS: years, + "assigned_depts": len(assigned_depts), + "from_depts": len(from_depts), + } + ) + + return [HISTORY_YEARS, "assigned_depts", "from_depts"], rows diff --git a/backend/registration/management/commands/import.py b/backend/registration/management/commands/import.py new file mode 100644 index 0000000..1258959 --- /dev/null +++ b/backend/registration/management/commands/import.py @@ -0,0 +1,271 @@ +import csv +import pathlib +from typing import Dict, List, Tuple, Optional +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand, CommandError +import os +import datetime +import re + +from registration.models import ( + Exchange, + ExchangeSession, + Department, + Person, + PersonMail, + Registration, +) + +ENROLLMENT_ADD = "_fd_Add" +ENROLLMENT_FIRSTNAME = "voornaam" +ENROLLMENT_LASTNAME = "achternaam" +ENROLLMENT_MAIL = "e_mailadres" +ENROLLMENT_DEPT = "afdeling" +ENROLLMENT_ASSIGNED = "toegewezen" +ENROLLMENT_CHOICES = ["eerste_keuze", "tweede_keuze", "derde_keuze"] + +ignore_dept = ["HFS", "**GEEN**", "» Verras me", "Maak je keuze", "geen", "niet ingevuld", "n.v.t.", ""] +renames: Dict[str, str] = {} +dept_lookup: Dict[str, Department] = {} + + +class Command(BaseCommand): + help = "Import data from existing enrollments" + + def add_arguments(self, parser): + parser.add_argument("files", nargs="+", type=str) + + def handle(self, *args, **options): + project_root = pathlib.Path( + __file__ + ).parent.parent.parent.parent.parent.resolve() + with open( + os.path.join(project_root, "renames.csv"), mode="r", encoding="utf-8-sig" + ) as csv_file: + csv_reader = csv.DictReader(csv_file, delimiter=";") + + for row in csv_reader: + old = row["old"].lower().strip() + new = row["new"].strip() + renames[old] = new + renames[new.lower()] = new + + for department in Department.objects.all(): + names = department.name.split(" / ") + for name in names: + dept_lookup[name] = department + + for filename in options["files"]: + filepath = os.path.join(project_root, filename) + if not os.path.isfile(filepath): + raise CommandError(f"File {filepath} does not exist") + + read_history_year(filepath) + + +def unique_username(first_name: str, prefix: str, last_name: str) -> str: + full_name = " ".join([first_name.lower(), prefix.lower(), last_name.lower()]) + candidates: List[str] = [ + first_name.lower(), + full_name, + ] + duplicate = 2 + while True: + if candidates: + candidate = candidates.pop(0) + else: + candidate = f"{full_name}{duplicate}" + duplicate += 1 + candidate = re.sub(r"[\s\-_\.]+", "_", candidate).strip("_") + try: + User.objects.get(username=candidate) + except User.DoesNotExist: + return candidate + + +def read_history_year(filepath: str): + with open(filepath, mode="r", encoding="utf-8-sig") as csv_file: + csv_reader = csv.DictReader(csv_file, delimiter=";") + + for row in csv_reader: + add = None + for format in ["%d-%m-%Y %H:%M", "%d-%m-%Y, %H:%M", "%d-%m-%y %H:%M", "%d-%m-%Y", "%d-%m-%y"]: + try: + add = datetime.datetime.strptime( + row[ENROLLMENT_ADD], format + ).astimezone() + except ValueError: + continue + break + if add is None: + raise ValueError(f"Could not parse {row[ENROLLMENT_ADD]}") + email = row[ENROLLMENT_MAIL].lower() + # naam = row["naam"].split(' ', 1) + # first_name = capitalize(naam[0]) + # prefix, last_name = format_last_name("" if len(naam) == 1 else naam[1]) + first_name = capitalize(row["voornaam"]) + prefix, last_name = format_last_name(row["achternaam"]) + person_dept_name = rename_dept(row[ENROLLMENT_DEPT]) + dept = lookup_dept(person_dept_name) + assigned = lookup_dept(rename_dept(row[ENROLLMENT_ASSIGNED]), False) + + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + try: + pm = PersonMail.objects.get(address=email) + user = pm.person.user + except PersonMail.DoesNotExist: + user = User() + user.username = unique_username(first_name, prefix, last_name) + user.email = email + user.first_name = first_name + user.last_name = last_name + if user.date_joined > add: + user.date_joined = add + user.save() + + try: + person = Person.objects.get(user=user) + + for registration in Registration.objects.filter(requestor=person): + if registration.date_time.year == add.year: + # removing existing registrations + registration.delete() + + + except Person.DoesNotExist: + person = Person() + person.user = user + person.save() + person.prefix_surname = prefix + if dept: + person.departments.add(dept) + else: + person.other_affiliation = person_dept_name + person.save() + + try: + exchange = Exchange.objects.get(begin=add.year) + except Exchange.DoesNotExist: + exchange = Exchange() + exchange.active = False + exchange.begin = add.year + exchange.end = add.year + 1 + exchange.enrollment_deadline = datetime.date(exchange.begin, 1, 1) + exchange.save() + + if add.date() > exchange.enrollment_deadline: + exchange.enrollment_deadline = add.date() + exchange.save() + + if assigned: + session = dept_session(exchange, assigned) + session.assigned.add(person) + session.save() + + for priority, column in enumerate(ENROLLMENT_CHOICES, 1): + value = row[column] + if re.match(r"^\-+$", value): + # empty + continue + choice_dept = lookup_dept(rename_dept(value), True) + choice = ( + None if choice_dept == None else dept_session(exchange, choice_dept) + ) + + # choice is None if the registration is blank (e.g. assign me randomly) + try: + Registration.objects.get(requestor=person, session=choice) + except Registration.DoesNotExist: + registration = Registration() + registration.requestor = person + registration.session = choice + registration.priority = priority + registration.date_time = add + registration.save() + + return [] + + +def dept_session(exchange: Exchange, department: Department) -> ExchangeSession: + session = ExchangeSession.objects.filter( + department=department, exchange=exchange + ).first() + if not session: + session = ExchangeSession() + session.department = department + session.exchange = exchange + session.participants_min = 0 + session.participants_max = 999 + session.session_count = 999 + session.save() + + return session + + +def lookup_dept(name: str, fail_on_key_error=False) -> Optional[Department]: + if name in ignore_dept: + return None + + try: + return dept_lookup[name] + except KeyError: + message = f"Department {name} not found" + if fail_on_key_error: + raise CommandError(message) + print(message) + return None + + +def rename_dept(department: str) -> str: + department = department.replace("\u2013", "-") + department = re.sub(r"\s+", " ", department) + department = department.strip() + try: + return renames[department.lower()] + except KeyError: + return department + + +def capitalize(value: str) -> str: + output = "" + separators = [" ", "-", ".", "'"] + capitalize_next = True + prev = "" + + for char in value: + if char in separators: + capitalize_next = True + elif capitalize_next: + capitalize_next = False + char = char.upper() + else: + if prev == "I" and char.lower() == "j": + # capitalize IJ correctly in Dutch + char = char.upper() + else: + char = char.lower() + output += char + prev = char + + return output + + +def format_last_name(value: str) -> Tuple[str, str]: + prefix_parts = [] + surname_parts = [] + for part in value.strip().split(" "): + if not surname_parts and part.lower() in [ + "van", + "von", + "de", + "der", + "den", + "die", + ]: + prefix_parts.append(part.lower()) + else: + surname_parts.append(part) + + return (str.join(" ", prefix_parts), capitalize(str.join(" ", surname_parts))) diff --git a/backend/registration/management/commands/organizers_mail.py b/backend/registration/management/commands/organizers_mail.py new file mode 100644 index 0000000..1828942 --- /dev/null +++ b/backend/registration/management/commands/organizers_mail.py @@ -0,0 +1,154 @@ +import csv +import pathlib +from typing import Dict, List, Tuple +from django.contrib.auth.models import User, Group +from django.core.management.base import BaseCommand, CommandError +import os +import datetime + +from registration.models import ( + Exchange, + ExchangeSession, + ExchangeSessionDescription, + Mail, + Person, +) + +DEFAULT_LANGUAGE = "nl" + +RECEIVERS = "ontvangers" +MAIL_SUBJECT = "onderwerp" +MAIL_CONTENT = "bericht" + +MAIL_TYPE = "overview_assigned" +CONJUNCT = {"en": " and ", "nl": " en "} + + +class Command(BaseCommand): + help = "Creates a file with a mail for the organizers with the enrollments" + + def add_arguments(self, parser): + parser.add_argument("files", nargs=1, type=str) + + def handle(self, *args, **options): + project_root = pathlib.Path( + __file__ + ).parent.parent.parent.parent.parent.resolve() + + for filename in options["files"]: + filepath = os.path.join(project_root, filename) + if os.path.isfile(filepath): + raise CommandError(f"File {filepath} already exists!") + + output, fieldnames = self.mail_info() + self.write_data(filepath, fieldnames, output) + + def mail_info(self) -> Tuple[List[List[str]], List[str]]: + output: List[Dict[str, str]] = [] + team = self.get_team_str() + + exchange = Exchange.objects.get(active=True) + for session in ExchangeSession.objects.filter(exchange=exchange): + organizers: List[Person] = list(session.organizers.all().order_by("user__first_name")) + assigned: List[Person] = list(session.assigned.all().order_by("user__first_name")) + + mail = self.get_mail(organizers) + mail_subject, mail_content = self.prepare_mail( + mail, session, organizers, assigned, team + ) + output.append( + { + RECEIVERS: ",".join( + self.format_mail_person(organizer) for organizer in organizers + ) or session.department.email, + MAIL_SUBJECT: mail_subject, + MAIL_CONTENT: mail_content, + } + ) + + return output, [RECEIVERS, MAIL_SUBJECT, MAIL_CONTENT] + + def conjunct(self, language: str, items: List[str]) -> str: + if len(items) < 2: + return items[0] + + return ", ".join(items[0:-1]) + CONJUNCT[language] + items[-1] + + def format_assigned(self, assigned: List[Person]) -> str: + output: List[str] = [] + for person in assigned: + output.append(f" - {self.format_mail_person(person)}") + + return "\n".join(output) + + def format_mail_person(self, person: Person) -> str: + return f"{person.full_name} <{person.email}>" + + def get_mail(self, organizers: List[Person]) -> Mail: + language = DEFAULT_LANGUAGE + for organizer in organizers: + if organizer.language == "en": + language = "en" + break + + try: + return Mail.objects.get(type=MAIL_TYPE, language=language) + except Mail.DoesNotExist: + return Mail.objects.get(type=MAIL_TYPE, language=DEFAULT_LANGUAGE) + + def write_data( + self, filepath: str, fieldnames: List[str], enriched: List[Dict[str, str]] + ) -> None: + with open(filepath, mode="w", encoding="utf-8-sig") as csv_file: + csv_writer = csv.DictWriter(csv_file, delimiter=";", fieldnames=fieldnames) + csv_writer.writeheader() + csv_writer.writerows(enriched) + + def prepare_mail( + self, + mail: Mail, + session: ExchangeSession, + organizers: List[Person], + assigned: List[Person], + team: str, + ) -> Tuple[str, str]: + choice = self.get_session_title(session, mail.language) + if organizers: + organizers_names = self.conjunct( + mail.language, + list(organizer.given_names.strip() for organizer in organizers), + ) + else: + organizers_names = "organisator" # TODO: translate + + choice_assignments = self.format_assigned(assigned) + + data = { + "choice": choice, + "organizers": organizers_names, + "count": str(len(assigned)), + "choice_assignments": choice_assignments, + "team": team, + } + return self.enrich_mail(mail.subject, data), self.enrich_mail(mail.text, data) + + def enrich_mail(self, text: str, data: Dict[str, str]) -> str: + for key, value in data.items(): + text = text.replace(f"{{{{{key}}}}}", value) + + return text + + def get_team_str(self) -> str: + team = Group.objects.get(name="Team") + persons = Person.objects.filter(user__groups=team).order_by("user__first_name") + return ", ".join(person.full_name for person in persons) + + def get_session_title(self, session: ExchangeSession, language: str) -> str: + try: + return ExchangeSessionDescription.objects.get( + exchange=session, language=language + ).title + except ExchangeSessionDescription.DoesNotExist: + return ExchangeSessionDescription.objects.get( + exchange=session, language=DEFAULT_LANGUAGE + ).title diff --git a/backend/registration/migrations/0001_initial.py b/backend/registration/migrations/0001_initial.py new file mode 100644 index 0000000..a75e576 --- /dev/null +++ b/backend/registration/migrations/0001_initial.py @@ -0,0 +1,167 @@ +# Generated by Django 4.2.14 on 2024-11-13 16:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Exchange', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('enrollment_deadline', models.DateField()), + ('active', models.BooleanField()), + ('begin', models.IntegerField(unique=True)), + ('end', models.IntegerField(unique=True)), + ], + options={ + 'unique_together': {('begin', 'end')}, + }, + ), + migrations.CreateModel( + name='Person', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('prefix_surname', models.CharField(blank=True)), + ('url', models.URLField(blank=True)), + ('language', models.CharField(choices=[('en', 'English'), ('nl', 'Dutch')])), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ExchangeDescription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ('language', models.CharField(choices=[('en', 'English'), ('nl', 'Dutch')])), + ('exchange', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registration.exchange')), + ], + ), + migrations.CreateModel( + name='Department', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(blank=True, max_length=254)), + ('avatar', models.FileField(blank=True, upload_to='')), + ('contact_persons', models.ManyToManyField(to='registration.person')), + ], + ), + migrations.AddField( + model_name='person', + name='departments', + field=models.ManyToManyField(blank=True, to='registration.department'), + ), + migrations.AlterField( + model_name='department', + name='contact_persons', + field=models.ManyToManyField(blank=True, to='registration.person'), + ), + migrations.CreateModel( + name='ExchangeSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('participants_min', models.IntegerField()), + ('participants_max', models.IntegerField()), + ('session_count', models.IntegerField()), + ('assigned', models.ManyToManyField(blank=True, related_name='exchange_assignees', to='registration.person')), + ('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registration.department')), + ('exchange', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registration.exchange')), + ('organizers', models.ManyToManyField(blank=True, related_name='exchange_organizers', to='registration.person')), + ], + ), + migrations.CreateModel( + name='DepartmentDescription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150)), + ('description', models.TextField(blank=True)), + ('language', models.CharField(choices=[('en', 'English'), ('nl', 'Dutch')])), + ('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='description', to='registration.department')), + ], + ), + migrations.AlterField( + model_name='department', + name='email', + field=models.EmailField(blank=True, help_text='Email address of the department itself', max_length=254), + ), + migrations.AddField( + model_name='department', + name='slug', + field=models.CharField(unique=True), + ), + migrations.AddField( + model_name='person', + name='other_affiliation', + field=models.CharField(blank=True), + ), + migrations.CreateModel( + name='PersonMail', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address', models.EmailField(max_length=254, unique=True)), + ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registration.person')), + ], + ), + migrations.AlterModelOptions( + name='department', + options={'ordering': ['slug']}, + ), + migrations.AddField( + model_name='department', + name='url', + field=models.URLField(blank=True), + ), + migrations.AddField( + model_name='person', + name='external', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='department', + name='slug', + field=models.SlugField(unique=True), + ), + migrations.CreateModel( + name='Registration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('priority', models.IntegerField()), + ('date_time', models.DateTimeField()), + ('requestor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registration.person')), + ('session', models.ForeignKey(blank=True, help_text='Keep empty for assigning to a random session', null=True, on_delete=django.db.models.deletion.CASCADE, to='registration.exchangesession')), + ], + ), + migrations.CreateModel( + name='Mail', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('confirm_registration', 'Confirm Registration'), ('assigned', 'Assigned'), ('overview_assigned', 'Overview Assigned'), ('no_participants', 'No Participants')])), + ('language', models.CharField(choices=[('en', 'English'), ('nl', 'Dutch')])), + ('subject', models.CharField(max_length=150)), + ('text', models.TextField()), + ], + ), + migrations.CreateModel( + name='ExchangeSessionDescription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subtitle', models.CharField(blank=True)), + ('program', models.TextField()), + ('language', models.CharField(choices=[('en', 'English'), ('nl', 'Dutch')])), + ('date', models.CharField()), + ('location', models.CharField()), + ('exchange', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='description', to='registration.exchangesession')), + ('title', models.CharField(blank=True)), + ('intro', models.TextField()), + ], + ), + ] diff --git a/frontend/src/app/home/home.component.scss b/backend/registration/migrations/__init__.py similarity index 100% rename from frontend/src/app/home/home.component.scss rename to backend/registration/migrations/__init__.py diff --git a/backend/registration/models.py b/backend/registration/models.py new file mode 100644 index 0000000..b53b44d --- /dev/null +++ b/backend/registration/models.py @@ -0,0 +1,351 @@ +from typing import Set +from django.contrib import admin +from django.contrib.auth.models import User +from django.db import models, transaction +from django.dispatch import receiver +from django.db.models.signals import post_save +from django.db.models.signals import pre_save, post_save +import re + +LANGUAGES = [("en", "English"), ("nl", "Dutch")] + + +# APPLICATION: REGISTRATION + + +class Mail(models.Model): + MAIL_TYPES = [ + # sent when someone filled in the registration form + ("confirm_registration", "Confirm Registration"), + # sent to the participant when an exchange has been assigned + ("assigned", "Assigned"), + # overview sent to the organizer of an exchange with the participants + ("overview_assigned", "Overview Assigned"), + # mail the organizers that no participants are registered for this exchange + ("no_participants", "No Participants") + ] + type = models.CharField(choices=MAIL_TYPES) + language = models.CharField(choices=LANGUAGES) + subject = models.CharField(max_length=150) + text = models.TextField() + + +class Person(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + + @property + def email(self): + return self.user.email + + @property + def given_names(self): + return self.user.first_name + + @property + def surnames(self): + return self.user.last_name + + @property + def full_name(self): + name = " ".join( + filter( + lambda x: x and not x.isspace(), + [self.given_names, self.prefix_surname, self.surnames], + ) + ) + if not name or name.isspace(): + return self.user.username + return name + + prefix_surname = models.CharField(blank=True) + + url = models.URLField(blank=True) + language = models.CharField(choices=LANGUAGES) + external = models.BooleanField(default=False) + departments = models.ManyToManyField("Department", blank=True) + other_affiliation = models.CharField(blank=True) + + @admin.display( + description="Affiliation", + boolean=False, + ) + def get_affiliation(self): + departments = [str(p) for p in self.departments.all()] + if len(self.other_affiliation): + departments.append(self.other_affiliation) + return ", ".join(departments) + + @transaction.atomic + def move_to(self, target: "Person"): + """Moves this person record to another person record. Deletes + this objects afterwards + + Args: + target (Person): the target to move to + """ + if target.pk > self.pk: + # always move to the oldest record + return target.move_to(self) + + emails: Set[str] = set() + emails.add(target.email) + emails.add(self.email) + + for pm in PersonMail.objects.filter(person=target): + emails.add(pm.address) + + for pm in PersonMail.objects.filter(person=self): + if pm.address not in emails: + emails.add(pm.address) + pm.person = target + else: + pm.delete() + + # prefer @uu.nl + for email in emails: + if email.endswith("@uu.nl"): + target.user.email = email + + # prefer newest @uu.nl + if self.user.email.endswith("@uu.nl"): + target.user.email = self.user.email + + for email in emails: + if target.email != email: + try: + PersonMail.objects.get(person=target, address=email) + except PersonMail.DoesNotExist: + pm = PersonMail() + pm.person = target + pm.address = email + pm.save() + + # self is the newest information + target.user.first_name = self.user.first_name + target.user.last_name = self.user.last_name + target.prefix_surname = self.prefix_surname + + if ( + target.user.last_login != None + and self.user.last_login != None + and self.user.last_login > target.user.last_login + ): + target.user.last_login = self.user.last_login + + target.departments.aadd(self.departments.all()) + target.other_affiliation = " ".join( + set([target.other_affiliation, self.other_affiliation]) + ).strip() + + if self.user.date_joined < target.user.date_joined: + target.user.date_joined = self.user.date_joined + + Registration.objects.filter(requestor=self).update(requestor=target) + + for department in Department.objects.filter(contact_persons=self): + department.contact_persons.add(target.pk) + + for session in ExchangeSession.objects.filter(assigned=self): + session.assigned.add(target.pk) + + for session in ExchangeSession.objects.filter(organizers=self): + session.organizers.add(target.pk) + + self.user.delete() + self.delete() + target.user.save() + target.save() + + def __str__(self): + return self.full_name + + +class PersonMail(models.Model): + """Defines an alternative email address""" + + person = models.ForeignKey(Person, on_delete=models.CASCADE) + address = models.EmailField(unique=True) + + def save(self, *args, **kwargs): + if self.person.email == self.address: + # prevent saving when this is the same address as the person + # this can happen when updating the main address of a user + return + + super().save(*args, **kwargs) + + +class Department(models.Model): + slug = models.SlugField(blank=False, unique=True) + email = models.EmailField( + blank=True, help_text="Email address of the department itself" + ) + url = models.URLField(blank=True) + contact_persons = models.ManyToManyField(Person, blank=True) + + avatar = models.FileField(blank=True) + + @property + @admin.display( + ordering="_name", + description="Name of the department", + boolean=False, + ) + def name(self): + descriptions = set(d.name for d in self.description.all()) + if not descriptions: + return "UNKNOWN DEPARTMENT" + return " / ".join(descriptions) + + def __str__(self): + return self.name + + class Meta: + ordering = ["slug"] + + +class DepartmentDescription(models.Model): + department = models.ForeignKey( + Department, on_delete=models.CASCADE, related_name="description" + ) + name = models.CharField(max_length=150) + description = models.TextField(blank=True) + language = models.CharField(choices=LANGUAGES) + + +class Exchange(models.Model): + begin = models.IntegerField(unique=True) + end = models.IntegerField(unique=True) + enrollment_deadline = models.DateField() + active = models.BooleanField() + + def __str__(self): + return f"{self.begin}-{self.end}" + + class Meta: + unique_together = ["begin", "end"] + + +class ExchangeDescription(models.Model): + exchange = models.ForeignKey(Exchange, on_delete=models.CASCADE) + text = models.TextField() + language = models.CharField(choices=LANGUAGES) + + +class ExchangeSession(models.Model): + exchange = models.ForeignKey(Exchange, on_delete=models.CASCADE) + department = models.ForeignKey(Department, on_delete=models.CASCADE) + assigned = models.ManyToManyField( + Person, blank=True, related_name="exchange_assignees" + ) + + participants_min = models.IntegerField() + participants_max = models.IntegerField() + + session_count = models.IntegerField() + + organizers = models.ManyToManyField( + Person, blank=True, related_name="exchange_organizers" + ) + + @property + def assigned_count(self): + return self.assigned.count() + + @property + def titles(self): + titles = set(d.title for d in self.description.all()) + if not titles: + return None + return " / ".join(titles) + + @property + def subtitles(self): + subtitles = set(d.subtitle for d in self.description.all()) + if not subtitles: + return None + return " / ".join(subtitles) + + def get_prefer_dutch_name(self): + for description in self.description.all(): + d: ExchangeSessionDescription = description + if d.language == "nl": + if d.subtitle: + return f"{d.title} {d.subtitle}" + if d.title: + return d.title + + return self.__str__() + + def __str__(self): + if self.subtitles: + return f"{self.exchange} {self.titles} ({self.subtitles})" + elif self.titles: + return f"{self.exchange} {self.titles}" + else: + return f"{self.exchange} {self.department}" + + +class ExchangeSessionDescription(models.Model): + exchange = models.ForeignKey( + ExchangeSession, on_delete=models.CASCADE, related_name="description" + ) + title = models.CharField(blank=True) + subtitle = models.CharField(blank=True) + intro = models.TextField() + program = models.TextField() + language = models.CharField(choices=LANGUAGES) + date = models.CharField() + location = models.CharField() + + +class Registration(models.Model): + requestor = models.ForeignKey(Person, on_delete=models.CASCADE) + session = models.ForeignKey( + ExchangeSession, + blank=True, + null=True, + on_delete=models.CASCADE, + help_text="Keep empty for assigning to a random session", + ) + priority = models.IntegerField() + date_time = models.DateTimeField() + + +@receiver(pre_save, sender=Department) +def to_lower_slug(sender, instance: Department, **kwargs): + instance.slug = ( + re.sub(r"[-&\(\)\s]+", "_", instance.slug).lower().strip("_") + if isinstance(instance.slug, str) + else "" + ) + + +@receiver(pre_save, sender=User) +def to_lower_username(sender, instance: User, **kwargs): + instance.username = ( + instance.username.lower() if isinstance(instance.username, str) else "" + ) + + +@receiver(pre_save, sender=Exchange) +def active_exchange(sender, instance: Exchange, **kwargs): + """Only one exchange can be active""" + if instance.active: + # deactivate all other exchanges + for exchange in Exchange.objects.all(): + if exchange.active and exchange.begin != instance.begin: + exchange.active = False + exchange.save() + + +@receiver(post_save, sender=User) +def add_person(sender, instance: User, **kwargs): + """Add a person for every user""" + + try: + Person.objects.get(user=instance) + except Person.DoesNotExist: + person = Person() + person.user = instance + person.save() diff --git a/backend/example/tests.py b/backend/registration/tests.py similarity index 100% rename from backend/example/tests.py rename to backend/registration/tests.py diff --git a/backend/registration/views.py b/backend/registration/views.py new file mode 100644 index 0000000..a6b4b97 --- /dev/null +++ b/backend/registration/views.py @@ -0,0 +1,73 @@ +from typing import List +from registration.models import ( + Department, + DepartmentDescription, + Exchange, + ExchangeSession, + ExchangeSessionDescription, + Person, + Registration, +) +from rest_framework.decorators import api_view +from rest_framework.response import Response + + +@api_view() +def available_sessions(request): + exchange = Exchange.objects.get(active=True) + response = [] + for session in ExchangeSession.objects.filter(exchange=exchange): + organizers: List[Person] = list(session.organizers.all()) + descriptions = list(ExchangeSessionDescription.objects.filter(exchange=session)) + response.append( + { + "pk": session.pk, + "descriptions": [ + { + "date": description.date, + "language": description.language, + "location": description.location, + "intro": description.intro, + "program": description.program, + "title": description.title, + "subtitle": description.subtitle, + } + for description in descriptions + ], + "organizers": [ + {"fullName": organizer.full_name, "url": organizer.url} + for organizer in organizers + ], + "participantsMin": session.participants_min, + "participantsMax": session.participants_max, + "sessionCount": session.session_count, + "full": ( + Registration.objects.filter(session=session, priority=1).count() + >= session.participants_max + ), + } + ) + return Response(response) + + +@api_view() +def departments(request): + departments = Department.objects.all() + response = [] + for department in departments: + response.append( + { + "slug": department.slug, + "descriptions": [ + { + "name": description.name, + "text": description.description, + "language": description.language, + } + for description in DepartmentDescription.objects.filter( + department=department + ) + ], + } + ) + return Response(response) diff --git a/backend/wisselwerking/common_settings.py b/backend/wisselwerking/common_settings.py index 9082c27..f5ad2a5 100644 --- a/backend/wisselwerking/common_settings.py +++ b/backend/wisselwerking/common_settings.py @@ -8,7 +8,7 @@ 'django.contrib.staticfiles', 'rest_framework', 'revproxy', - 'example' + 'registration' ] MIDDLEWARE = [ @@ -37,3 +37,5 @@ USE_I18N = True USE_TZ = True + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/backend/wisselwerking/urls.py b/backend/wisselwerking/urls.py index db9d050..d5ef082 100644 --- a/backend/wisselwerking/urls.py +++ b/backend/wisselwerking/urls.py @@ -13,38 +13,41 @@ 1. Import the include() function: from django.conf.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.conf import settings from django.urls import path, re_path, include from django.contrib import admin from django.views.generic import RedirectView from rest_framework import routers +from registration.views import available_sessions as AvailableSessions, departments from .index import index from .proxy_frontend import proxy_frontend from .i18n import i18n -from example.views import hooray as ExampleView # DELETEME, see below - api_router = routers.DefaultRouter() # register viewsets with this router - if settings.PROXY_FRONTEND: - spa_url = re_path(r'^(?P.*)$', proxy_frontend) + spa_url = re_path(r"^(?P.*)$", proxy_frontend) else: - spa_url = re_path(r'', index) + spa_url = re_path(r"", index) urlpatterns = [ - path('api/example/', ExampleView), # this is just an example, please delete and utilize router above. - path('admin', RedirectView.as_view(url='/admin/', permanent=True)), - path('api', RedirectView.as_view(url='/api/', permanent=True)), - path('api-auth', RedirectView.as_view(url='/api-auth/', permanent=True)), - path('admin/', admin.site.urls), - path('api/', include(api_router.urls)), - path('api-auth/', include( - 'rest_framework.urls', - namespace='rest_framework', - )), - path('api/i18n/', i18n), + path("admin", RedirectView.as_view(url="/admin/", permanent=True)), + path("api", RedirectView.as_view(url="/api/", permanent=True)), + path("api-auth", RedirectView.as_view(url="/api-auth/", permanent=True)), + path("admin/", admin.site.urls), + path("api/available_sessions/", AvailableSessions), + path("api/departments/", departments), + path("api/", include(api_router.urls)), + path( + "api-auth/", + include( + "rest_framework.urls", + namespace="rest_framework", + ), + ), + path("api/i18n/", i18n), spa_url, # catch-all; unknown paths to be handled by a SPA ] diff --git a/capacities.csv b/capacities.csv deleted file mode 100644 index 72a36c4..0000000 --- a/capacities.csv +++ /dev/null @@ -1,25 +0,0 @@ -keuze;aantal -Bestuurssecretaris;2 -Business Manager van het Descartes Centre;3 -CAT/Educate-it;6 -Centre for Digital Humanities;40 -Coördinatoren Bedrijfsvoering;10 -Externe relaties;999 -Facultaire Ethische Toetsingscommissie GW;5 -Faculteitsdirecteur;1 -Finance & Control;5 -Honours Trajectum Utrecht (HTU);12 -Hoofd Onderwijsondersteuning en Studentzaken (OSZ);2 -HR;8 -International Office - inkomende studenten;4 -International Office - uitgaande studenten;4 -Labs - Institute for Language Sciences (incl. Babylab);6 -Onderwijsbeleid;10 -Onderwijscoördinator;4 -Onderwijssecretariaat;4 -Onderzoeksbeleid;5 -Osiris key-users;4 -Studentencommunicatie;4 -Studieadviseurs;999 -Studiepunt;3 -U-Talent alfa-gamma;8 diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 306812a..a36d4ae 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,15 +1,20 @@ import { Routes } from '@angular/router'; -import { HomeComponent } from './home/home.component'; +import { OverviewComponent } from './overview/overview.component'; +import { RegistrationComponent } from './registration/registration.component'; const routes: Routes = [ { - path: 'home', - component: HomeComponent, + path: 'overview', + component: OverviewComponent, + }, + { + path: 'registration', + component: RegistrationComponent, }, { path: '', - redirectTo: '/home', + redirectTo: '/overview', pathMatch: 'full' } ]; diff --git a/frontend/src/app/exchange-session/exchange-session.component.html b/frontend/src/app/exchange-session/exchange-session.component.html new file mode 100644 index 0000000..28ae679 --- /dev/null +++ b/frontend/src/app/exchange-session/exchange-session.component.html @@ -0,0 +1,46 @@ +

{{title}}

+

{{intro}}

+
+ +
+

Programma

+

{{program}}

+ + + {{languages}} + + + {{participantCount}} + + + {{sessionCount}} + + + {{date}} + + + {{location}} + + @if (contact) { + + {{contact}} + + } +
+
+@if (session?.full) { + +} +@else { + +} diff --git a/frontend/src/app/exchange-session/exchange-session.component.scss b/frontend/src/app/exchange-session/exchange-session.component.scss new file mode 100644 index 0000000..5322bf8 --- /dev/null +++ b/frontend/src/app/exchange-session/exchange-session.component.scss @@ -0,0 +1,7 @@ +details { + margin-top: 1em; + + summary { + cursor: pointer; + } +} diff --git a/frontend/src/app/exchange-session/exchange-session.component.spec.ts b/frontend/src/app/exchange-session/exchange-session.component.spec.ts new file mode 100644 index 0000000..44b3788 --- /dev/null +++ b/frontend/src/app/exchange-session/exchange-session.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ExchangeSessionComponent } from './exchange-session.component'; + +describe('ExchangeSessionComponent', () => { + let component: ExchangeSessionComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ExchangeSessionComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ExchangeSessionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/exchange-session/exchange-session.component.ts b/frontend/src/app/exchange-session/exchange-session.component.ts new file mode 100644 index 0000000..c5277a0 --- /dev/null +++ b/frontend/src/app/exchange-session/exchange-session.component.ts @@ -0,0 +1,85 @@ +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewContainerRef } from '@angular/core'; +import { ExchangeSession, Language } from '../models'; +import { ProgramDetailLineComponent } from '../program-detail-line/program-detail-line.component'; +import { faCheck } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'wsl-exchange-session', + standalone: true, + imports: [CommonModule, ProgramDetailLineComponent, FontAwesomeModule], + templateUrl: './exchange-session.component.html', + styleUrl: './exchange-session.component.scss' +}) +export class ExchangeSessionComponent implements OnChanges { + faCheck = faCheck; + + @Input() + session?: ExchangeSession; + + @Input() + interested: boolean = false; + + @Output() + interestedChange: EventEmitter = new EventEmitter(); + + title: string = ''; + subtitle: string = ''; + intro: string = ''; + program: string = ''; + date: string = ''; + location: string = ''; + languages: string = ''; + participantCount: string = ''; + sessionCount: string = ''; + contact: string = ''; + nativeElement: HTMLElement; + + constructor(private readonly viewRef: ViewContainerRef) { + this.nativeElement = this.viewRef.element.nativeElement; + } + + clickInterested() { + this.interestedChange.next(!this.interested); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!this.session) { + return; + } + + if (this.session.participantsMin === this.session.participantsMax) { + this.participantCount = this.session.participantsMin.toString(); + } else if (this.session.participantsMax > 90) { + this.participantCount = 'Onbeperkt'; + } else { + this.participantCount = `${this.session.participantsMin} à ${this.session.participantsMax}`; + } + + if (this.session.sessionCount > 90) { + this.sessionCount = 'Onbeperkt'; + } else { + this.sessionCount = this.session.sessionCount.toString(); + } + + this.title = this.session.title; + + const languages: Language[] = []; + + for (const description of this.session?.descriptions) { + if (description.language == 'nl' || !this.title) { + this.date = description.date; + this.subtitle = description.subtitle + this.intro = description.intro; + this.program = description.program; + this.location = description.location; + } + + languages.push(description.language); + } + + this.languages = languages.sort().map(lang => lang === 'nl' ? 'Nederlands' : 'Engels').join(' en '); + this.contact = this.session.organizers.map(person => person.fullName).sort().join('; '); + } +} diff --git a/frontend/src/app/home/home.component.html b/frontend/src/app/home/home.component.html deleted file mode 100644 index d747ace..0000000 --- a/frontend/src/app/home/home.component.html +++ /dev/null @@ -1,5 +0,0 @@ -

Home

-

NOICE! It works!

-
- Although? If you are seeing this (and not a happy GIF), something is wrong... :( -
diff --git a/frontend/src/app/home/home.component.ts b/frontend/src/app/home/home.component.ts deleted file mode 100644 index 879840f..0000000 --- a/frontend/src/app/home/home.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { BackendService } from './../services/backend.service'; - -@Component({ - selector: 'wsl-home', - templateUrl: './home.component.html', - styleUrls: ['./home.component.scss'], - standalone: true -}) -export class HomeComponent implements OnInit { - hooray?: string; - - constructor(private backend: BackendService) { } - - ngOnInit(): void { - // This is just an example call to /api/example/ - this.backend.get('example').then(hoorays => { - if (hoorays.length) { - this.hooray = hoorays[0].message; - } - }); - } - -} diff --git a/frontend/src/app/menu/menu.component.html b/frontend/src/app/menu/menu.component.html index 426f1da..34f3702 100644 --- a/frontend/src/app/menu/menu.component.html +++ b/frontend/src/app/menu/menu.component.html @@ -17,13 +17,16 @@ diff --git a/frontend/src/app/models.ts b/frontend/src/app/models.ts new file mode 100644 index 0000000..5de03ed --- /dev/null +++ b/frontend/src/app/models.ts @@ -0,0 +1,39 @@ +export type Language = 'nl' | 'en'; +export interface Person { + fullName: string, + url: string +} + +export interface Department { + slug: string, + descriptions: DepartmentDescription[] +} + +export interface DepartmentDescription { + name: string, + text: string, + language: Language +} + +export interface ExchangeSessionDescription { + date: string + language: Language + location: string, + title: string, + subtitle: string, + intro: string, + program: string +} + +export interface ExchangeSession { + title: string; + sortTitle: string; + department: Department, + descriptions: ExchangeSessionDescription[], + participantsMax: number, + participantsMin: number, + pk: number, + sessionCount: number, + organizers: Person[], + full: boolean +} diff --git a/frontend/src/app/overview/overview.component.html b/frontend/src/app/overview/overview.component.html new file mode 100644 index 0000000..10cb74c --- /dev/null +++ b/frontend/src/app/overview/overview.component.html @@ -0,0 +1,69 @@ +
+
+

Wisselwerking Geesteswetenschappen

+
+

+ Ben jij nieuwsgierig naar de werkzaamheden van je collega’s of wil je meer inzicht krijgen in de + processen + van + het team waar jij veel mee samenwerkt? Doe dan mee aan wisselwerking en ga op bezoek bij je collega's. + Bekijk + hieronder de programma’s van de deelnemende teams. +

+ +

Wanneer en voor wie?

+

De wisselwerkingen vinden plaats van december 2024 tot de zomer van 2025, voor personeel van de faculteit + Geesteswetenschappen.

+ +

Bij wie kun je op bezoek?

+ @if (sessions) { + + } +
+ + @for (session of sessions; track session.pk) { +
+ + +
+ } +
+
+
+
+ @if (interestedList) { +

Meedoen?

+

+ Je kunt je aanmelden tot en met maandag 11 november 2024. +

+
    + @for (session of interestedList; track session.pk) { +
  • {{session.title}}
  • + } +
+ } + Aanmelden +
+
+
+ +
+
+
diff --git a/frontend/src/app/overview/overview.component.scss b/frontend/src/app/overview/overview.component.scss new file mode 100644 index 0000000..671d004 --- /dev/null +++ b/frontend/src/app/overview/overview.component.scss @@ -0,0 +1,11 @@ +.sticky { + position: sticky; + top: 0; +} + +.scroll-to-top { + position: fixed; + bottom: 20px; + right: 30px; + z-index: 99; +} diff --git a/frontend/src/app/home/home.component.spec.ts b/frontend/src/app/overview/overview.component.spec.ts similarity index 65% rename from frontend/src/app/home/home.component.spec.ts rename to frontend/src/app/overview/overview.component.spec.ts index 7426be7..f34b432 100644 --- a/frontend/src/app/home/home.component.spec.ts +++ b/frontend/src/app/overview/overview.component.spec.ts @@ -1,20 +1,20 @@ import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { HomeComponent } from './home.component'; +import { OverviewComponent } from './overview.component'; describe('HomeComponent', () => { - let component: HomeComponent; - let fixture: ComponentFixture; + let component: OverviewComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [HomeComponent, HttpClientTestingModule] + imports: [OverviewComponent, HttpClientTestingModule] }) .compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(HomeComponent); + fixture = TestBed.createComponent(OverviewComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/frontend/src/app/overview/overview.component.ts b/frontend/src/app/overview/overview.component.ts new file mode 100644 index 0000000..952b8d1 --- /dev/null +++ b/frontend/src/app/overview/overview.component.ts @@ -0,0 +1,66 @@ +import { Component, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core'; +import { BackendService } from '../services/backend.service'; +import { ExchangeSessionComponent } from "../exchange-session/exchange-session.component"; +import { ExchangeSession } from '../models'; +import { faCheck, faCircleChevronUp } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { RegistrationService } from '../services/registration.service'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'wsl-overview', + templateUrl: './overview.component.html', + styleUrls: ['./overview.component.scss'], + standalone: true, + imports: [CommonModule, FontAwesomeModule, RouterLink, ExchangeSessionComponent] +}) +export class OverviewComponent implements OnDestroy { + subscriptions = new Subscription(); + + faCheck = faCheck; + faCircleChevronUp = faCircleChevronUp; + interested: { [pk: ExchangeSession['pk']]: boolean } = {}; + interestedList: ExchangeSession[] = []; + + sessions?: ExchangeSession[]; + + @ViewChildren(ExchangeSessionComponent) + sessionElements?: QueryList; + + constructor(private backend: BackendService, private registrationService: RegistrationService) { + this.subscriptions.add(registrationService.interested$.subscribe(value => { this.interested = value; })); + this.subscriptions.add(registrationService.sessionPriorities$.subscribe(value => { this.interestedList = value.map(item => item.session); })); + this.subscriptions.add(registrationService.sessions$.subscribe(value => { this.sessions = value; })); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + scrollUp() { + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + } + + scrollTo(session: ExchangeSession) { + if (!this.sessionElements) { + return; + } + + for (const el of this.sessionElements) { + if (el.session === session) { + console.log(el); + el.nativeElement.scrollIntoView({ behavior: 'smooth' }); + return; + } + } + } + + updateList(pk: ExchangeSession['pk'], interested: boolean) { + this.registrationService.update(pk, interested); + } +} diff --git a/frontend/src/app/program-detail-line/program-detail-line.component.html b/frontend/src/app/program-detail-line/program-detail-line.component.html new file mode 100644 index 0000000..71dec86 --- /dev/null +++ b/frontend/src/app/program-detail-line/program-detail-line.component.html @@ -0,0 +1,12 @@ +
+ {{label}} +
+
+
+
+
+ +
+
+
+
diff --git a/frontend/src/app/program-detail-line/program-detail-line.component.scss b/frontend/src/app/program-detail-line/program-detail-line.component.scss new file mode 100644 index 0000000..60b0bd6 --- /dev/null +++ b/frontend/src/app/program-detail-line/program-detail-line.component.scss @@ -0,0 +1,3 @@ +.field-label { + flex-grow: 2; +} diff --git a/frontend/src/app/program-detail-line/program-detail-line.component.spec.ts b/frontend/src/app/program-detail-line/program-detail-line.component.spec.ts new file mode 100644 index 0000000..5786075 --- /dev/null +++ b/frontend/src/app/program-detail-line/program-detail-line.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProgramDetailLineComponent } from './program-detail-line.component'; + +describe('ProgramDetailLineComponent', () => { + let component: ProgramDetailLineComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProgramDetailLineComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProgramDetailLineComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/program-detail-line/program-detail-line.component.ts b/frontend/src/app/program-detail-line/program-detail-line.component.ts new file mode 100644 index 0000000..8ca2182 --- /dev/null +++ b/frontend/src/app/program-detail-line/program-detail-line.component.ts @@ -0,0 +1,14 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'wsl-program-detail-line', + standalone: true, + imports: [], + templateUrl: './program-detail-line.component.html', + styleUrl: './program-detail-line.component.scss', + host: { 'class': 'field is-horizontal' } +}) +export class ProgramDetailLineComponent { + @Input() + label?: string; +} diff --git a/frontend/src/app/registration/registration.component.html b/frontend/src/app/registration/registration.component.html new file mode 100644 index 0000000..316a985 --- /dev/null +++ b/frontend/src/app/registration/registration.component.html @@ -0,0 +1,163 @@ +
+

+ Aanmeldformulier Wisselwerking +

+ +

Geef je eerste en eventueel ook tweede of derde keuze door. Je inschrijving is pas definitief als je een + bevestiging + hebt ontvangen van de aanbieder van de wisselwerking. Houd er rekening mee dat je eerste keuze vol kan zijn.

+ +
+ + + + + + + + + + + + + +
+
+ +
+
+
+
+ +
+
+
+
+
+
diff --git a/frontend/src/app/registration/registration.component.scss b/frontend/src/app/registration/registration.component.scss new file mode 100644 index 0000000..01c5888 --- /dev/null +++ b/frontend/src/app/registration/registration.component.scss @@ -0,0 +1,12 @@ +// hide number arrows +/* Chrome, Safari, Edge, Opera */ +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type=number] { + -moz-appearance: textfield; +} diff --git a/frontend/src/app/registration/registration.component.spec.ts b/frontend/src/app/registration/registration.component.spec.ts new file mode 100644 index 0000000..8d5b355 --- /dev/null +++ b/frontend/src/app/registration/registration.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RegistrationComponent } from './registration.component'; + +describe('RegistrationComponent', () => { + let component: RegistrationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistrationComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(RegistrationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/registration/registration.component.ts b/frontend/src/app/registration/registration.component.ts new file mode 100644 index 0000000..968e683 --- /dev/null +++ b/frontend/src/app/registration/registration.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { ExchangeSessionPriority, RegistrationService } from '../services/registration.service'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { fa1, fa2, fa3, fa4, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { ExchangeSession } from '../models'; + +@Component({ + selector: 'wsl-registration', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + templateUrl: './registration.component.html', + styleUrl: './registration.component.scss' +}) +export class RegistrationComponent { + priorityIcons = { + 1: fa1, + 2: fa2, + 3: fa3, + 4: fa4 + }; + + faTimes = faTimes; + sessions$ = this.registrationService.sessions$; + sessionPriorities$ = this.registrationService.sessionPriorities$; + departments$ = this.registrationService.departments(); + + constructor(private registrationService: RegistrationService) { + + } + + updatePriority(session: ExchangeSession['pk'], priority: number) { + this.registrationService.updatePriority(session, priority - 1); + } +} diff --git a/frontend/src/app/services/backend.service.ts b/frontend/src/app/services/backend.service.ts index 5caf9f9..fef366f 100644 --- a/frontend/src/app/services/backend.service.ts +++ b/frontend/src/app/services/backend.service.ts @@ -9,6 +9,10 @@ import { BACKEND_URL } from '../app.config'; providedIn: 'root' }) export class BackendService { + private cache: { + [objectUrl: string]: Promise + } = {}; + protected apiUrl: Promise | null = null; constructor(protected config: ConfigService, protected http: HttpClient, @Inject(BACKEND_URL) private backendUrl: string) { @@ -20,16 +24,20 @@ export class BackendService { * (i.e. whatever comes after, for example, '/api/'). * Note that this method will add a '/' at the end of the url if it does not exist. */ - async get(objectUrl: string): Promise { - const baseUrl = await this.getApiUrl(); - if (!objectUrl.endsWith('/')) { objectUrl = `${objectUrl}/`; } - const url: string = encodeURI(baseUrl + objectUrl); - - try { - return await lastValueFrom(this.http.get(url)); - } catch (error) { - return await this.handleError(error); + async get(objectUrl: string, cache = true): Promise { + if (!objectUrl.endsWith('/')) { + objectUrl = `${objectUrl}/`; } + return cache && this.cache[objectUrl] || (this.cache[objectUrl] = (async () => { + const baseUrl = await this.getApiUrl(); + const url: string = encodeURI(baseUrl + objectUrl); + + try { + return await lastValueFrom(this.http.get(url)); + } catch (error) { + return await this.handleError(error); + } + })()); } getApiUrl(): Promise { diff --git a/frontend/src/app/services/registration.service.spec.ts b/frontend/src/app/services/registration.service.spec.ts new file mode 100644 index 0000000..5f3dfc3 --- /dev/null +++ b/frontend/src/app/services/registration.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { RegistrationService } from './registration.service'; + +describe('RegistrationService', () => { + let service: RegistrationService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(RegistrationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/services/registration.service.ts b/frontend/src/app/services/registration.service.ts new file mode 100644 index 0000000..7592420 --- /dev/null +++ b/frontend/src/app/services/registration.service.ts @@ -0,0 +1,104 @@ +import { Injectable } from '@angular/core'; +import { Department, ExchangeSession } from '../models'; +import { BehaviorSubject } from 'rxjs'; +import { BackendService } from './backend.service'; + +export const MIN_PRIORITY = 1; +export const MAX_PRIORITY = 4; +export type PriorityValue = 1 | 2 | 3 | 4; + +export interface ExchangeSessionPriority { + session: ExchangeSession, + priority: PriorityValue +} + +@Injectable({ + providedIn: 'root' +}) +export class RegistrationService { + private interested = new BehaviorSubject<{ [pk: ExchangeSession['pk']]: boolean }>({}); + private interestedPriorities = new BehaviorSubject([]); + private sessions = new BehaviorSubject([]); + + interested$ = this.interested.asObservable(); + sessionPriorities$ = this.interestedPriorities.asObservable(); + sessions$ = this.sessions.asObservable(); + + constructor(private backend: BackendService) { + this.backend.get('available_sessions').then(sessions => { + this.sessions.next((sessions.map((session: any) => { + // add title + session.title = this.sessionTitle(session); + session.sortTitle = (session.title).replace(/[^A-Za-z]/g, ''); + return session; + })).sort((a, b) => { + if (a.sortTitle === b.sortTitle) { + return 0; + } else if (a.sortTitle < b.sortTitle) { + return -1; + } else { + return 1; + } + })); + }); + } + + private sessionTitle(session: ExchangeSession) { + let title: string = ''; + for (const description of session.descriptions) { + if (description.language == 'nl' || !title) { + title = description.title; + } + } + return title; + } + + private departmentTitle(department: Department) { + let title: string = ''; + for (const description of department.descriptions) { + if (description.language == 'nl' || !title) { + title = description.name; + } + } + return title; + } + + async departments() { + const departments = await this.backend.get('departments'); + return departments.map(department => ({ + ...department, + title: this.departmentTitle(department) + })); + } + + update(pk: number, value: boolean) { + const interested = { ...this.interested.value, [pk]: value }; + const interestedPriorities = (<[any, boolean][]>Object.entries(interested)).filter(([_, value]) => value).map(([pk, _]) => { + return { + session: this.sessions.value.find(session => pk == session.pk), + priority: Math.max(MIN_PRIORITY, Math.min(MAX_PRIORITY, this.interestedPriorities.value.find(priority => pk == priority.session.pk)?.priority ?? Object.keys(interested).length)) + }; + }).filter(session => !!session.session); + + this.interested.next(interested); + this.interestedPriorities.next(interestedPriorities); + } + + updatePriority(pk: number, priority: number) { + const maxPriority = Math.min(this.interestedPriorities.value.length, MAX_PRIORITY); + const interestedPriorities = [...this.interestedPriorities.value.map(item => { + if (item.session.pk == pk) { + if (priority > maxPriority) { + priority = MIN_PRIORITY; + } else if (priority < MIN_PRIORITY) { + priority = maxPriority; + } + item.priority = priority; + } + + return item; + })]; + + this.interestedPriorities.next(interestedPriorities); + } +} diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 250cf97..7d335a8 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -6,6 +6,14 @@ $blue: #5287C6); @use 'bulma/bulma'; +$highlight: var(--bulma-warning); +$highlight-text: var(--bulma-warning-invert); + +.highlight { + background: $highlight; + color: $highlight-text; +} + @media print { .is-hidden-print, diff --git a/frontend/src/variables.scss b/frontend/src/variables.scss index dad05f1..66cdb74 100644 --- a/frontend/src/variables.scss +++ b/frontend/src/variables.scss @@ -1 +1,10 @@ @forward 'bulma/sass/utilities'; + +body { + --bulma-body-family: Merriweather, serif; +} + +.title, +.subtitle { + font-family: Open Sans, sans-serif; +} \ No newline at end of file diff --git a/history.py b/history.py deleted file mode 100644 index 8ce4a46..0000000 --- a/history.py +++ /dev/null @@ -1,6 +0,0 @@ -import sys -from wisselwerking.history import read_history -previous_years_dir = sys.argv[1] - -history = read_history(previous_years_dir) -history.to_csv() diff --git a/magic.py b/magic.py deleted file mode 100644 index 956cf47..0000000 --- a/magic.py +++ /dev/null @@ -1,415 +0,0 @@ -#!/usr/bin/env python3 -import csv -import sys -import re -import os -import glob -from typing import Dict, List, Tuple - -from wisselwerking.history import Enrollment, EnrollmentCollection, read_history, rename_dept -from wisselwerking.settings import \ - capacity_file, \ - output_file, \ - ASSIGNED_CHOICE, \ - CAPACITY_CHOICE, \ - CAPACITY_VALUE, \ - TEAM, \ - MAIL_COLUMN, \ - ENROLLMENT_SOURCE, \ - ENROLLMENT_FIRSTNAME, \ - ENROLLMENT_LASTNAME, \ - ENROLLMENT_MAIL, \ - ENROLLMENT_DEPT, \ - ENROLLMENT_CHOICES, \ - RANDOM_CHOICE, \ - NONE_CHOICE, \ - NO_ASSIGNMENT - -filename = sys.argv[1] -previous_years_dir = sys.argv[2] - - -# -# Start assigning! -# - -enrollments: List[Dict[str, str]] = [] -capacities = {} -counter = {} - -assignments: List[Tuple[Dict[str, str], str]] = [] - - -def format_name(enrollment, include_lastname=False): - first_name = str.join(' ', - (part.capitalize() for part in enrollment[ENROLLMENT_FIRSTNAME].strip().split(' '))) - if include_lastname: - parts = [] - for part in enrollment[ENROLLMENT_LASTNAME].strip().split(' '): - if part.lower() in ['van', 'von', 'de', 'der', 'den', 'die']: - parts.append(part.lower()) - else: - parts.append(part.capitalize()) - return first_name + ' ' + str.join(' ', parts) - - return first_name - - -def mail_template(assigned, enrollment): - name = format_name(enrollment) - first_choice = enrollment[ENROLLMENT_CHOICES[0]].strip() - second_choice = enrollment[ENROLLMENT_CHOICES[1]].strip() - third_choice = enrollment[ENROLLMENT_CHOICES[2]].strip() - if assigned == first_choice or first_choice == RANDOM_CHOICE: - # first choice - content = f"""Je bent geplaatst voor de Wisselwerking {assigned}. We hebben je gegevens doorgegeven aan de contactpersoon van deze Wisselwerking. Deze zal contact met je opnemen om verdere afspraken te maken over je deelname. - -Heel veel plezier bij je wisselwerking!""" - elif assigned == second_choice or assigned == third_choice or \ - RANDOM_CHOICE in [second_choice, third_choice]: - # second, third choice - if assigned == third_choice: - ordinal = "derde" - elif assigned == second_choice: - ordinal = "tweede" - else: - # random! - ordinal = "vrije" - - content = f"""Helaas was bij jouw eerste keuze voor de Wisselwerking bij {first_choice} geen plek meer. Je bent nu geplaatst bij je {ordinal} keuze: {assigned}. - -We hebben je gegevens doorgegeven aan de contactpersoon van deze Wisselwerking. Deze zal contact met je opnemen om verdere afspraken te maken over je deelname. - -Heel veel plezier bij je wisselwerking!""" - else: - # nothing - content = f"""Je hebt je aangemeld voor de Wisselwerking {first_choice}. Helaas waren deze en eventuele verdere keuzes vol. Het is nog wel mogelijk om aan van de volgende wisselwerkingen mee te doen: - -{list_available()} - -Zou je ons z.s.m. kunnen mailen als je, je voor een van deze wisselwerkingen nog wilt aanmelden?""" - - return f""" -Beste {name}, - -{content} - -Hartelijke groet, - -Team Wisselwerking Geesteswetenschappen -{TEAM} -""".strip() - - -def get_capacity(choice) -> int: - if choice == RANDOM_CHOICE: - return 999 - try: - capacity = capacities[choice] - if capacity is None: - raise KeyError - return capacity - except KeyError: - while True: - value = input(f"Capacity for {choice}? ") - try: - parsed = int(value) - capacities[choice] = parsed - return parsed - except ValueError: - print("NOPE") - pass - - -def save_capacities(): - with open(capacity_file, mode="w", encoding="utf-8-sig") as csv_file: - fieldnames = [CAPACITY_CHOICE, CAPACITY_VALUE] - writer = csv.DictWriter(csv_file, fieldnames=fieldnames, delimiter=';') - - writer.writeheader() - for (choice, value) in capacities.items(): - writer.writerow({ - CAPACITY_CHOICE: choice, - CAPACITY_VALUE: value - }) - - -if os.path.exists(capacity_file): - with open(capacity_file, mode="r", encoding="utf-8-sig") as csv_file: - csv_reader = csv.DictReader(csv_file, delimiter=';') - - for row in csv_reader: - try: - capacity = int(row[CAPACITY_VALUE]) - except ValueError: - capacity = None - except TypeError: - capacity = None - capacities[row[CAPACITY_CHOICE]] = capacity - -unique_emails = set() - -with open(filename, mode="r", encoding='iso8859-15') as csv_file: - csv_reader = csv.DictReader(csv_file, delimiter=';') - form_fieldnames = csv_reader.fieldnames - line_count = 0 - - for row in csv_reader: - if row["_fd_Source"] != "Testing": - mail = row[ENROLLMENT_MAIL].lower().strip() - if mail in unique_emails: - print("DUBBELE DEELNEMER: " + mail) - else: - # The top row is the last entry - enrollments.insert(0, row) - line_count += 1 - unique_emails.add(mail) - -history = read_history(previous_years_dir) - -# Make sure all possible choices are known -for enrollment in enrollments: - for choice in map(lambda key: enrollment[key], ENROLLMENT_CHOICES): - if not choice or choice.strip() in NONE_CHOICE: - continue - else: - counter[choice.strip()] = 0 - - -def assign_choice(enrollment: Dict[str, str], choice: str): - try: - counter[choice] += 1 - except KeyError: - counter[choice] = 1 - assignments.append((enrollment, choice)) - unassigned.remove(enrollment) - - if choice in choices and counter[choice] >= get_capacity(choice): - choices.remove(choice) - - -def show_historic_counts(): - print(""" - TOEWIJZINGEN VAN VORIGE WISSELWERKINGEN: - """) - historic_counts = {} - for item in history.list_assigned(): - try: - historic_counts[item] += 1 - except KeyError: - historic_counts[item] = 1 - - for item in sorted(historic_counts): - print(f"{str(historic_counts[item]).rjust(3)} {item}") - - -def show_counts(show_unassigned=True): - print(""" - AANTAL AANMELDINGEN: - """) - - choices = sorted(set(capacities.keys()).union(counter.keys())) - maxlength = sorted(len(choice) for choice in choices)[-1] - sum = 0 - empty = [] - - for choice in choices: - count = counter.get(choice, 0) - if count == 0: - if choice != RANDOM_CHOICE: - empty.append(choice) - else: - try: - capacity = capacities[choice] - except KeyError: - capacity = "?" - print(f"{str(count).rjust(3)} van {str(capacity).ljust(3)} {choice}") - sum += count - - print("=" * (maxlength + 4)) - print(f"{str(sum).rjust(3)} TOTAAL") - - if empty: - print(""" - WISSELWERKINGEN ZONDER TOEWIJZINGEN: - """) - for choice in empty: - print(choice) - - if show_unassigned and unassigned: - print(f""" - {len(unassigned)} WISSELWERKERS ZONDER TOEWIJZINGEN: - """) - for enrollment in unassigned: - print(enrollment[ENROLLMENT_MAIL]) - - -def list_available(): - choices = sorted(set(capacities.keys())) - available = [] - - for choice in choices: - count = counter.get(choice, 0) - try: - capacity = capacities[choice] - if count < capacity: - available.append(choice) - except KeyError: - pass - - return '\n'.join(f'- {choice}' for choice in available) - - -choices = list(sorted(set(capacities.keys()).union(counter.keys()))) -for item, capacity in capacities.items(): - # possible to close a department - if capacity == 0: - choices.remove(item) -unassigned = list(enrollments) - -priority = [] -# Walk through the choices in iterations -# Give priority on order of choice and within that on order of enrollment -for key in ENROLLMENT_CHOICES: - for enrollment in enrollments: - choice = enrollment[key] - if choice and choice not in NONE_CHOICE: - priority.append((enrollment, choice.strip())) - -try: - for enrollment, enrollment_choice in priority: - if enrollment not in unassigned: - continue - for choice in list(choices): - if enrollment_choice == choice: - assign_choice(enrollment, choice) -except KeyboardInterrupt: - # still store the updated capacities - save_capacities() - raise - -# assign the random members -choices.remove(RANDOM_CHOICE) - - -def reassign_random(assignments: List[Tuple[Dict[str, str], str]], history: EnrollmentCollection): - first = True - for enrollment in list(enrollment for (enrollment, choice) in assignments if choice == RANDOM_CHOICE): - unassigned.append(enrollment) - email = enrollment[ENROLLMENT_MAIL] - if first: - print("\n\n===TUSSENSTAND===\n\n") - show_historic_counts() - show_counts(False) - first = False - previous = list(history.by_email(email)) - depts = set([rename_dept(row[ENROLLMENT_DEPT])] + list(map(lambda x: x.from_dept, previous))) - print(f"\n\n{email} ({'; '.join(depts)}) moet verrast worden") - if previous: - print("Deed eerder de volgende wisselwerkingen: " + - ", ".join(x.assigned_dept for x in previous )) - else: - print("Wisselwerking-newbie!") - - while True: - choice = input("Wijs een andere wisselwerking toe: ") - if (0 if choice not in counter else counter[choice]) < get_capacity(choice): - for item in assignments: - check_enrolment, _ = item - if check_enrolment == enrollment: - assignments.remove(item) - break - assign_choice(enrollment, choice) - counter[RANDOM_CHOICE] -= 1 - break - else: - print("Zit al vol!") - - -reassign_random(assignments, history) - -save_capacities() - -show_counts() - -# Store the assignments -with open(output_file, mode="w", encoding="utf-8-sig") as csv_file: - fieldnames = [ASSIGNED_CHOICE, ENROLLMENT_MAIL, MAIL_COLUMN] + \ - [field for field in form_fieldnames if field != MAIL_COLUMN] - writer = csv.DictWriter(csv_file, fieldnames=fieldnames, delimiter=';') - - writer.writeheader() - for (row, assigned) in assignments: - writer.writerow({ - ASSIGNED_CHOICE: assigned, - "mail": mail_template(assigned, row), - **row - }) - for enrollment in unassigned: - writer.writerow({ - ASSIGNED_CHOICE: NO_ASSIGNMENT, - "mail": mail_template(NO_ASSIGNMENT, enrollment), - **enrollment - }) - -# Store the assignments per choice - to mail the organizers -output_prepath = str.join('.', output_file.split('.')[:-1]) - - -def output_text_file(choice: str, escape=True): - return output_prepath + '.' + (choice if not escape else re.sub(r'[\*\(\) \-\.\&\/]+', '-', choice)) + '.txt' - - -existing_files = glob.glob(output_text_file('*', False)) - -for choice in sorted(counter): - if choice == RANDOM_CHOICE: - continue - count = counter[choice] - target = output_text_file(choice) - try: - existing_files.remove(target) - except ValueError: - pass - with open(target, mode="w", encoding="utf-8-sig") as txt_file: - choice_assignments = [] - for (row, assigned) in assignments: - if assigned == choice: - choice_assignments.append( - f"{format_name(row, True)} <{row[ENROLLMENT_MAIL]}> ({rename_dept(row[ENROLLMENT_DEPT])})\n") - - if count > 0: - txt_file.writelines([f"""Beste organisator, - -Leuk dat je je hebt opgegeven om een wisselwerking te organiseren! Voor de wisselwerking {choice} hebben de volgende {count} person(en) zich aangemeld: - -"""] + - choice_assignments + - [f""" -Zou je zo snel mogelijk contact willen opnemen met deze mensen om afspraken te maken over de wisselwerking? - -Heel veel plezier bij de wisselwerking! - -Hartelijke groet, - -Team Wisselwerking Geesteswetenschappen -{TEAM} -"""]) - else: - txt_file.writelines([f"""Beste organisator, - -Helaas heeft dit jaar niemand zich aangemeld voor de Wisselwerking {choice}. - -Dank dat je een Wisselwerking wilde organiseren. We hopen dat je volgend jaar weer meedoet! - -Hartelijke groet, - -Team Wisselwerking Geesteswetenschappen -{TEAM} -"""]) - -# Clear existing files, which are no longer assigned -for existing_file in existing_files: - os.remove(existing_file) - -print("DONE! Plaats toewijzingen.csv op de O-schijf") diff --git a/renames.csv b/renames.csv index 83efcbf..1c71579 100644 --- a/renames.csv +++ b/renames.csv @@ -5,6 +5,7 @@ Onderwijsbeleid;Beleidsondersteuning onderwijs - vrijdag 18 maart 2022 (14.00 to Onderwijsbeleid;Beleidsondersteuning onderwijs - vrijdag 19 november 2021 (14.00 - 17.00) Bestuurssecretaris;BO - Bestuurssecretaris Bestuurssecretaris;Bestuurssecretariaat GW +Bestuurssecretaris;Bestuurssecretaris F&R Business Manager van het Descartes Centre;BO - Business Manager Descartes Business Manager van het Descartes Centre;BO - Business Manager Descartes Centre Business Manager van het Descartes Centre;BO-Wat doet de Business Manager van het Descartes Centre? @@ -19,9 +20,11 @@ Centre for Digital Humanities;Digital Humanities Centre for Digital Humanities;Digital Humanities Lab Centre for Digital Humanities;ICT & Media Centre for Digital Humanities;ICT & Media, Digital Humanities Lab +Communicatie & Marketing;C&M Communicatie & Marketing;C&M - Achter de schermen - Onderwijsmarketing Communicatie & Marketing;C&M - Communicatie en Marketing Communicatie & Marketing;Communicatie en Marketing +Communicatie & Marketing;Hoofd Communicatie & Marketing Coördinatoren Bedrijfsvoering;BO - Coördinatoren Bedrijfsvoering Coördinatoren Bedrijfsvoering;Coördinator bedrijfsvoering Cultuurgeschiedenis;Cultuurgeschiedenis (CMI) @@ -44,34 +47,44 @@ Departement Filosofie en Religiewetenschappen;GW/FenR Departement Filosofie en Religiewetenschappen;dept F&R Economische en Sociale Geschiedenis;ESG Economische en Sociale Geschiedenis;ESG GW -Educate-it;BO - Educate-it -Educate-it;BO Educate-it -Educate-it;Educate-it GW +CAT/Educate-it;Educate-it +CAT/Educate-it;BO - Educate-it +CAT/Educate-it;BO Educate-it +CAT/Educate-it;Educate-it GW +CAT/Educate-it;CAT/Educate-it Ethische Toetsingscommissie;Ethische Toetsingscommissie gw Ethische Toetsingscommissie;Facultaire Ethische Toetsingscommissie GW Examensecretariaat;Examensecretariaat OSZ Examensecretariaat;OSZ - Examensecretariaat Examensecretariaat;examensecretariaat GW Faculteitsdirecteur;DIR - Faculteitsdirecteur Rob Grift +Faculteitsdirecteur;DIR - Rob Grift +Faculteitsdirecteur;DIR- Rob Grift Finance & control;F & C Finance & control;F&C Finance & control;F&C & RSO - In vogelvlucht +Finance & control;F&C en RSO - In vogelvlucht Finance & control;F&C en RSO - F&C en BIV in vogelvlucht Finance & control;Finance & Control +Geschiedenis en Kunstgeschiedenis;Dep. Geschiedenis en Kunstgeschiedenis Geschiedenis en Kunstgeschiedenis;Departement GKG Geschiedenis en Kunstgeschiedenis;GKG Geschiedenis en Kunstgeschiedenis;Geschiedenis en Kunstgeschiedenis (GKG) Geschiedenis en Kunstgeschiedenis;Geschiedenis en kunstgeschiedenis Geschiedenis en Kunstgeschiedenis;Kunstgeschiedenis Hoofd Onderwijsondersteuning en Studentzaken (OSZ);Hoofd Onderwijsondersteuning en Studentzaken +Honours Trajectum Utrecht;Honours Trajectum Utrecht (HTU) Human Resources;HR Human Resources;HR - Introductiebijeenkomst Human Resources;HR GW +Human Resources;HR - Introductiebijeenkomst (let op: al op 29 maart!) +International Office;International Office: meet the world - september 2021 International Office;IO International Office;IO - International Office - Kijkje in de keuken International Office;IO - International Office - Meet the world International Office;IO - Kijkje in de keuken International Office;IO - Meet the World +International Office;International Office: pre-departure International Office;International Office - Kijkje in de keuken International Office;International Office - Meet the world International Office;International Office - inkomende studenten @@ -83,12 +96,17 @@ International Office;International Office: meet the world - februari 2021 International Office;International Office: pre-departure meeting International Office;OSZ Interntional Office International Office;international office +International Office;International Office Humanities +Isis Editorial Office;Isis Isis Editorial Office;CB Editorial Office Isis Isis Editorial Office;BO - Editorial Office 'Isis' +Onderwijsbeleid;OSZ - Beleid in de praktijk Onderwijsbeleid;Beleid in de praktijk: onderwijs Onderwijsbeleid;Beleidsondersteuning onderwijs Onderwijsbeleid;Onderwijsbeleid in de praktijk - vrijdag 27 november Onderwijsbeleid;SO&O - Beleid in de praktijk +Onderwijscoördinator;Onderwijscoördinator FenR +Onderwijscoördinator;GW-Onderwijscoördinatoren Onderwijscoördinator;GW-Onderwijscoördinatoren Onderwijscoördinator;OSZ - Onderwijscoördinatoren Onderwijscoördinator;OSZ/Onderwijscoördinatoren @@ -100,6 +118,7 @@ Onderwijscoördinator;SO&O - Onderwijscoördinatoren - optie A Onderwijscoördinator;SO&O - Onderwijscoördinatoren - optie B Onderwijscoördinator;SO&O - Onderwijscoördinatoren - optie C Onderwijscoördinator;SO&O/Onderwijscoördinatie +Onderwijssecretariaat;Onderwijssecretariaat (OSZ) Onderwijssecretariaat;OSZ - Onderwijssecretariaat: Wel achter de schermen maar voor... Onderwijssecretariaat;OSZ onderwijssecretariaat Onderwijssecretariaat;Onderwijssecretariaat GW @@ -108,6 +127,8 @@ Onderwijssecretariaat;Onderwijssecretariaat: Wel achter de schermen maar voor Onderwijssecretariaat;SO&O - Onderwijssecretariaat Onderwijssecretariaat;SO&O - Onderwijssecretariaat: Wel achter de schermen maar voor... Onderwijssecretariaat;SO&O - Wel achter de schermen maar voor... (Onderwijssecretariaat) +Onderwijssecretariaat;SO&O - Wel achter de schermen maar voor…. +Onderzoekscoördinatoren;Onderzoekscoördinator Onderzoekscoördinatoren;Coördinator OSK (Onderzoekscoördinatoren GW) Onderzoekscoördinatoren;OSZ - Onderzoekscoördinatoren Onderzoekscoördinatoren;Onderzoekscoordinatie GW @@ -115,10 +136,14 @@ Onderzoekscoördinatoren;Onderzoekscoördinatoren GW Onderzoekscoördinatoren;SO&O - Onderzoekcoördinatoren Onderzoekscoördinatoren;SO&O - Onderzoekscoördinatoren Onderzoeksbeleid;Beleid in de praktijk: onderzoek +Osiris key-users;Keyuser Osiris Osiris key-users;Key-user Osiris Osiris key-users;OSZ - Key Users Osiris Osiris key-users;Osiris Key-users Osiris key-users;SO&O - Key Users Osiris +Research Support Office;Resources +Studieadviseurs;Studieadviseur Aukje Molenaar +Studieadviseurs;Studieadviea Studieadviseurs;OSZ - De student aan het woord (Studieadviseurs) Studieadviseurs;OSZ - Studieadvies Studieadviseurs;SO&O - De student aan het woord (Studieadviseurs) @@ -126,6 +151,7 @@ Studieadviseurs;SO&O - Studieadviseurs Studieadviseurs;Studieadvies GW Studieadviseurs;Studieadviseur Maaike Wouda Studieadviseurs;studieadvies +Studiepunt;Studiepunt GW Studiepunt;OSZ (Studiepunt) Studiepunt;OSZ - Be the Student (Studiepunt, Examensecretariaat en Ambtelijk Secretariaat) Studiepunt;OSZ - Studiepunt @@ -136,10 +162,16 @@ Taal & Communicatie;Taal & Communicatie Taal & Communicatie;Taal & Communicatie (TLC, GW) Taal & Communicatie;Taal en Communicatie Taal & Communicatie;Taal en communicatie +UiL OTS;UIL-OTS Labs +UiL OTS;Babylab UiL OTS;TLC UIL OTS UiL OTS;UiL OTS labs/TLC +UiL OTS;ILS labs/ILS/TLC +UiL OTS;Labs - Institute for Language Sciences (incl. Babylab) +UiL OTS;Labs (incl. Babylab) - Institute for Language Sciences Universiteitsbibliotheek;UB Universiteitsbibliotheek;Universiteitsbibliotheek (educatiespecialist) Universiteitsbibliotheek;Universiteitsbibliotheek Utrecht Universiteitsbibliotheek;Vakspecialisten GW -Universiteitsbibliotheek;Vakspecialisten GW: Universiteitsbibliotheek \ No newline at end of file +Universiteitsbibliotheek;Vakspecialisten GW: Universiteitsbibliotheek +U-Talent alfa-gamma;Educatie Geesteswetenschappen diff --git a/wisselwerking/history.py b/wisselwerking/history.py deleted file mode 100644 index 8661939..0000000 --- a/wisselwerking/history.py +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env python3 -import csv -import os -import re -from typing import Dict, Tuple, List -from .settings import ASSIGNED_CHOICE, ENROLLMENT_MAIL, ENROLLMENT_DEPT, HISTORY_YEARS, HISTORY_HOW_MANY - - -class Enrollment: - def __init__(self, email: str, years: Tuple[int, int], from_dept: str, assigned_dept: str): - self.email = email - self.years = years - self.from_dept = from_dept - self.assigned_dept = assigned_dept - - -class EnrollmentCollection: - def __init__(self, items: List[Enrollment]): - self.items = items - self.ids = {} - - def to_rows(self): - for enrollment in self.items: - yield [enrollment.email, f'{enrollment.years[0]}-{enrollment.years[1]}', enrollment.from_dept, enrollment.assigned_dept] - - def list_from_depts(self): - depts = set() - for enrollment in self.items: - depts.add(enrollment.from_dept) - return depts - - def list_assigned(self): - depts = set() - for enrollment in self.items: - depts.add(enrollment.assigned_dept) - return depts - - def by_email(self, email): - for item in self.items: - if item.email == email: - yield item - - def __get_id(self, email): - try: - return self.ids[email] - except KeyError: - new_id = len(self.ids) + 1 - self.ids[email] = new_id - return new_id - - def to_csv(self): - self.items.sort(key=lambda enrollment: enrollment.years[0]) - - participant_count: Dict[int, int] = {} - - with open('history.csv', 'w', encoding='utf-8-sig') as csv_file: - writer = csv.DictWriter(csv_file, fieldnames=[ - 'id', 'count', HISTORY_HOW_MANY, HISTORY_YEARS, ENROLLMENT_DEPT, ASSIGNED_CHOICE], delimiter=';') - - writer.writeheader() - for enrollment in self.items: - participant_id = self.__get_id(enrollment.email) - try: - how_many = participant_count[participant_id] + 1 - except KeyError: - participant_count[participant_id] = 0 - how_many = 1 - participant_count[participant_id] += 1 - writer.writerow({ - 'id': participant_id, - 'count': 1, # makes pivot tables easier to create - HISTORY_HOW_MANY: how_many, - HISTORY_YEARS: f'{enrollment.years[0]}-{enrollment.years[1]}', - ENROLLMENT_DEPT: enrollment.from_dept, - ASSIGNED_CHOICE: enrollment.assigned_dept - }) - - # new participants each year - per_year: Dict[str, List[int]] = {} - per_year_enrollment: Dict[str, List[Enrollment]] = {} - - for enrollment in self.items: - years = f'{enrollment.years[0]}-{enrollment.years[1]}' - participant_id = self.__get_id(enrollment.email) - try: - per_year[years].append(participant_id) - per_year_enrollment[years].append(enrollment) - except KeyError: - per_year[years] = [participant_id] - per_year_enrollment[years] = [enrollment] - - all_previous_years = list(per_year.keys())[:-1] - with open('history_new_participants.csv', 'w', encoding='utf-8-sig') as csv_file: - writer = csv.DictWriter(csv_file, fieldnames=[ - HISTORY_YEARS] + all_previous_years + ['completely_new'], delimiter=';') - - writer.writeheader() - previous_years: List[str] = [] - for years, participants in per_year.items(): - new_count = 0 - prev_years_counts = dict.fromkeys(all_previous_years, 0) - for p in participants: - for prev_years in previous_years: - if p in per_year[prev_years]: - prev_years_counts[prev_years] += 1 - break - else: - new_count += 1 - - writer.writerow({ - HISTORY_YEARS: years, - **prev_years_counts, - 'completely_new': new_count - }) - - previous_years.insert(0, years) - - # how many times do people participate over the years? - histogram: Dict[int, int] = {} - for participant_id, participant_count in participant_count.items(): - try: - histogram[participant_count] += 1 - except KeyError: - histogram[participant_count] = 1 - - with open('history_histogram.csv', 'w', encoding='utf-8-sig') as csv_file: - writer = csv.DictWriter(csv_file, fieldnames=[ - 'times', 'count'], delimiter=';') - - writer.writeheader() - for times, count in histogram.items(): - writer.writerow({ - 'times': times, - 'count': count - }) - - # how many different departments participated? - with open('history_depts_histogram.csv', 'w', encoding='utf-8-sig') as csv_file: - writer = csv.DictWriter(csv_file, fieldnames=[ - HISTORY_YEARS, 'assigned_depts', 'from_depts'], delimiter=';') - - writer.writeheader() - for years, enrollments in per_year_enrollment.items(): - assigned_depts = set() - from_depts = set() - for e in enrollments: - assigned_depts.add(e.assigned_dept) - from_depts.add(e.from_dept) - - writer.writerow({ - HISTORY_YEARS: years, - 'assigned_depts': len(assigned_depts), - 'from_depts': len(from_depts) - }) - - -# rename old courses to new names (if known) -renames: Dict[str, str] = {} -with open("renames.csv", mode="r", encoding="utf-8-sig") as csv_file: - csv_reader = csv.DictReader(csv_file, delimiter=';') - - for row in csv_reader: - old = row['old'].lower().strip() - new = row['new'].strip() - renames[old] = new - renames[new.lower()] = new - - -def read_history(base_path: str, all_history=None) -> EnrollmentCollection: - if all_history is None: - all_history: List[Enrollment] = [] - - for dir in os.listdir(base_path): - if dir.lower().startswith('wisselwerking'): - year_history = read_history_year( - dir, os.path.join(base_path, dir, "toewijzingen.csv")) - - for enrollment in year_history: - all_history.append(enrollment) - elif dir.lower().startswith('archief'): - read_history(os.path.join(base_path, dir), all_history) - - return EnrollmentCollection(all_history) - - -def rename_dept(department: str) -> str: - department = department.replace('\u2013', '-') - department = re.sub(r'\s+', ' ', department) - department = department.strip() - try: - return renames[department.lower()] - except KeyError: - return department - - -def read_history_year(dir: str, filepath: str) -> List[Enrollment]: - if not os.path.isfile(filepath): - print(f"{dir} overgeslagen") - return [] - - history: List[Enrollment] = [] - years = list(map(lambda x: int(x), re.findall(r'\d{4}', dir))) - with open(filepath, mode="r", encoding="utf-8-sig") as csv_file: - csv_reader = csv.DictReader(csv_file, delimiter=';') - - for row in csv_reader: - email = row[ENROLLMENT_MAIL].lower() - dept = rename_dept(row[ENROLLMENT_DEPT]) - assigned = rename_dept(row['toegewezen']) - history.append(Enrollment( - email, - years, - dept, - assigned - )) - - return history diff --git a/wisselwerking/settings.py b/wisselwerking/settings.py deleted file mode 100644 index 9cbefc3..0000000 --- a/wisselwerking/settings.py +++ /dev/null @@ -1,24 +0,0 @@ -capacity_file = "capacities.csv" -output_file = "toewijzingen.csv" - -ASSIGNED_CHOICE = "toegewezen" - -CAPACITY_CHOICE = "keuze" -CAPACITY_VALUE = "aantal" - -TEAM = "Desiree Capel, Nicoline Fokkema, Yvonne de Jong en Sheean Spoel" -MAIL_COLUMN = "mail" - -ENROLLMENT_SOURCE = "_fd_Source" -ENROLLMENT_FIRSTNAME = "voornaam" -ENROLLMENT_LASTNAME = "achternaam" -ENROLLMENT_MAIL = "e_mailadres" -ENROLLMENT_DEPT = "afdeling" -ENROLLMENT_CHOICES = ["eerste_keuze", "tweede_keuze", "derde_keuze"] - -HISTORY_YEARS = 'jaren' -HISTORY_HOW_MANY = 'hoeveelste_keer' - -RANDOM_CHOICE = "» Verras me" -NONE_CHOICE = ["Maak je keuze", "", "--", "---"] -NO_ASSIGNMENT = "**GEEN**"