diff --git a/Dockerfile b/Dockerfile index 0a001fe..80fd832 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,11 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends fonts-dejavu gcc libc-dev git gpg libmagic1 libpq-dev postgresql-client xz-utils && \ pip install -U pip setuptools --no-cache-dir && \ rm -rf /root/.cache /var/cache/* + +# Check and downgrade pip if necessary +RUN pip --version && \ + pip install --upgrade "pip<24.1" && \ + pip --version # install python requirements and do cleanup COPY requirements.txt requirements.txt diff --git a/src/spz/__init__.py b/src/spz/__init__.py index cecedb3..9ae2b1f 100644 --- a/src/spz/__init__.py +++ b/src/spz/__init__.py @@ -36,7 +36,6 @@ class CustomFlask(Flask): """ jinja_options = dict(Flask.jinja_options, trim_blocks=True, lstrip_blocks=True, auto_reload=False) - app = CustomFlask(__name__, instance_relative_config=True) # Configuration loading @@ -84,6 +83,7 @@ def rlrc_comment(): # add Jinja helpers app.jinja_env.globals['include_raw'] = lambda filename: Markup(app.jinja_loader.get_source(app.jinja_env, filename)[0]) app.jinja_env.globals['rlrc_comment'] = rlrc_comment +app.jinja_env.globals.update(zip=zip) # Assets handling; keep the spz.assets module in sync with the static directory assets_env = Environment(app) @@ -129,9 +129,9 @@ def rlrc_comment(): # Rich Text Editor setup ckeditor = CKEditor(app) - # Register all views here from spz import views, errorhandlers, pdf # NOQA +from spz.administration import admin_views routes = [ ('/', views.index, ['GET', 'POST']), @@ -191,6 +191,23 @@ def rlrc_comment(): ('/internal/login', views.login, ['GET', 'POST']), ('/internal/logout', views.logout, ['GET', 'POST']), ('/internal/auth/reset_password/', views.reset_password, ['GET', 'POST']), + + ('/internal/administration/teacher', admin_views.administration_teacher, ['GET', 'POST']), + ('/internal/administration/teacher/', admin_views.administration_teacher_lang, ['GET', 'POST']), + ('/internal/administration/teacher//add', admin_views.add_teacher, ['GET', 'POST']), + ('/internal/administration/teacher/edit/', admin_views.edit_teacher, ['GET', 'POST']), + ('/internal/administration/teacher/void', admin_views.teacher_void, ['GET']), + ('/internal/teacher', admin_views.teacher, ['GET', 'POST']), + ('/internal/grades/', admin_views.grade, ['GET', 'POST']), + ('/internal/grades//edit', admin_views.edit_grade, ['GET', 'POST']), + ('/internal/grades//edit_view', admin_views.edit_grade_view, ['GET', 'POST']), + ('/internal/teacher//attendance/', admin_views.attendances, ['GET', 'POST']), + ('/internal/teacher//attendance//edit/', admin_views.edit_attendances, ['GET', 'POST']), + + ('/api/campus_portal/export/', views.campus_portal_grades, ['GET']), + ('/internal/campus_portal/export', views.campus_export_language, ['GET', 'POST']), + ('/internal/campus_portal/export/', views.campus_export_course, ['GET', 'POST']), + ] for rule, view_func, methods in routes: diff --git a/src/spz/administration/__init__.py b/src/spz/administration/__init__.py new file mode 100644 index 0000000..82cb1d5 --- /dev/null +++ b/src/spz/administration/__init__.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +"""static functions related to administration tasks + +This module contains methods for: + - teacher management + - course management +""" + +from flask import flash +from spz import models, db +from spz.mail import generate_status_mail + +from flask_babel import gettext as _ + + +def get_course_ids(): + course_ids_tuple = db.session.query(models.Role.course_id).filter( + models.Role.course_id.isnot(None)) \ + .filter(models.Role.role == models.Role.COURSE_TEACHER) \ + .distinct().all() + # transform query tuples into right integer format + """flash('Vergebene Kurse sind:') + for course_id in course_ids_tuple: + course = models.Course.query.get(course_id[0]) + flash(f'{course.full_name}: {course_id[0]}')""" + return [course_id[0] for course_id in course_ids_tuple] + + +class TeacherManagement: + @staticmethod + def remove_course(teacher, course, user_id): + role_to_remove = models.Role.query. \ + join(models.User.roles). \ + filter(models.Role.user_id == user_id). \ + filter(models.Role.course_id == course.id). \ + first() + if role_to_remove: + # remove complete table row from role table + db.session.delete(role_to_remove) + else: + raise ValueError(_('Folgender Kurs "{}" war kein Kurs des/der Lehrbeauftragten.' + ' Wurde der richtige Kurs ausgewählt?'.format(course.full_name))) + + return course + + @staticmethod + def add_course(teacher, course): + own_courses_id = [course.id for course in teacher.teacher_courses] + if course.id in own_courses_id: + raise ValueError( + _('Der/die Lehrbeauftragte hat diesen Kurs schon zugewiesen. Doppelzuweisung nicht möglich!')) + TeacherManagement.check_availability(course) + teacher.roles.append(models.Role(course=course, role=models.Role.COURSE_TEACHER)) + + @staticmethod + def check_availability(course): + teachers = db.session.query(models.User) \ + .join(models.Role, models.User.roles) \ + .filter(models.Role.role == models.Role.COURSE_TEACHER).all() + course_ids = get_course_ids() + if course.id in course_ids: + for teacher in teachers: + if teacher.is_course_teacher(course): + #flash(f'{course.full_name} ({course.id}) is already assigned to {teacher.full_name}') + raise ValueError('{0} ist schon vergeben an {1}.'.format(course.full_name, teacher.full_name)) + + @staticmethod + def unassigned_courses(language_id): + # courses with assigned teachers only + unassigned_courses = db.session.query(models.Course) \ + .outerjoin(models.Role, + (models.Role.course_id == models.Course.id) & (models.Role.role == models.Role.COURSE_TEACHER)) \ + .join(models.Language) \ + .filter(models.Language.id == language_id) \ + .filter(models.Role.id == None) + + return unassigned_courses diff --git a/src/spz/administration/admin_views.py b/src/spz/administration/admin_views.py new file mode 100644 index 0000000..62e085c --- /dev/null +++ b/src/spz/administration/admin_views.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- + +"""The application's administration views. + + Manages the mapping between routes and their activities for the administrators. +""" +import socket +from flask import request, redirect, render_template, url_for, flash +from flask_login import current_user, login_required, login_user, logout_user +from flask_mail import Message + +from spz import app +from spz import models, db, log +from spz.administration import TeacherManagement +from spz.decorators import templated +from spz.auth.password_reset import send_password_reset_to_user +import spz.forms as forms + +from flask_babel import gettext as _ + + +@templated('internal/administration/teacher_overview_base.html') +def administration_teacher(): + if current_user.is_teacher: + return redirect(url_for('teacher')) + # Aliasing might be necessary if Role or User is joined through different paths + # An outer join retrieves records that have matching values in one of the tables, and also those records from the + # primary table that have no matches in the joined table. + languages_info = db.session.query( + models.Language.id, + models.Language.name, + db.func.count(models.Course.id).label('course_count'), + db.func.count(db.distinct(models.Role.user_id)).label('teacher_count') + ).outerjoin( + models.Course, models.Language.id == models.Course.language_id # Ensure all languages are included + ).outerjoin( + models.Role, (models.Role.course_id == models.Course.id) & (models.Role.role == models.Role.COURSE_TEACHER) + ).group_by( + models.Language.id, models.Language.name + ).all() + + languages_data = [{ + 'id': l_id, + 'name': name, + 'course_count': course_count if course_count else 0, + 'teacher_count': teacher_count if teacher_count else 0, + 'teacher_rate_per_course': teacher_count / course_count if course_count else 0, + } for l_id, name, course_count, teacher_count in languages_info] + + return dict(language=languages_data) + + +@templated('internal/administration/teacher_overview_lang.html') +def administration_teacher_lang(id): + lang = models.Language.query.get_or_404(id) + form = forms.ResetLanguagePWs(lang) + + teacher = models.User.query \ + .join(models.Role, models.User.roles) \ + .join(models.Course, models.Role.course_id == models.Course.id) \ + .filter(models.Course.language_id == id) \ + .filter(models.Role.role == 'COURSE_TEACHER') \ + .distinct().all() + + # courses with assigned teachers + unassigned_courses = TeacherManagement.unassigned_courses(id) + + if form.validate_on_submit(): + if len(teacher) == 0: + flash(_('Es gibt keine Lehrbeauftragten für diese Sprache. Keine Emails wurden verschickt.'), 'info') + return redirect(url_for('administration_teacher_lang', id=id)) + + reset_pws = form.get_send_mail() + # reset passwords for all teachers of the language + if reset_pws: + try: + for t in teacher: + send_password_reset_to_user(t) + flash(_('Emails mit Passwort Links wurden erfolgreich an alle Lehrbeauftragten verschickt.'), 'success') + except (AssertionError, socket.error, ConnectionError) as e: + flash(_('Emails zum Passwort Reset konnten nicht verschickt werden: %(error)s', error=e), + 'negative') + return redirect(url_for('administration_teacher_lang', id=id)) + + return dict(language=lang, teacher=teacher, unassigned_courses=unassigned_courses, form=form) + + +@templated('internal/administration/add_teacher.html') +def add_teacher(id): + lang = models.Language.query.get_or_404(id) + form = forms.AddTeacherForm(id) + + if form.validate_on_submit(): + teacher = form.get_teacher() + + # check, if course is already assigned to a teacher + courses = form.get_courses() + try: + for course in courses: + # if course is not available, error is thrown + TeacherManagement.check_availability(course) + except Exception as e: + flash(_('Der Kurs ist schon vergeben. Es kann nur eine*n Lehrbeauftragte*n je Kurs geben: %(error)s', + error=e), 'negative') + return dict(language=lang, form=form) + + send_pw_mail = form.get_send_mail() + if teacher is None: + roles = [] + teacher_courses = form.get_courses() + for course in teacher_courses: + roles.append(models.Role(course=course, role=models.Role.COURSE_TEACHER)) + teacher = models.User(email=form.get_mail(), + tag=form.get_tag(), + active=True, + roles=roles + ) + teacher.first_name = form.get_first_name() + teacher.last_name = form.get_last_name() + try: + db.session.add(teacher) + db.session.commit() + except Exception as e: + db.session.rollback() + flash(_('Es gab einen Fehler beim Hinzufügen des Lehrbeauftragten: %(error)s', error=e), 'negative') + return dict(form=form) + + if send_pw_mail: + # send password reset mail, if writing to database was successfully + try: + send_password_reset_to_user(teacher) + except (AssertionError, socket.error, ConnectionError) as e: + flash(_('Eine Mail zum Passwort Reset konnte nicht verschickt werden: %(error)s', error=e), + 'negative') + + return redirect(url_for('administration_teacher_lang', id=lang.id)) + + # update course choices depending on the visited language page + form.update_courses(id) + return dict(language=lang, form=form) + + +@templated('internal/administration/edit_teacher.html') +def edit_teacher(id): + teacher = models.User.query.get_or_404(id) + form = forms.EditTeacherForm(teacher) + + if form.validate_on_submit(): + + try: + + changes = False + + if teacher.first_name != form.first_name.data: + teacher.first_name = form.first_name.data + changes = True + + if teacher.last_name != form.last_name.data: + teacher.last_name = form.last_name.data + changes = True + + if teacher.email != form.mail.data: + teacher.email = form.mail.data + changes = True + + if teacher.tag != form.tag.data: + teacher.tag = form.tag.data + changes = True + + if changes: + db.session.commit() + flash(_('Der/die Lehrbeauftragte wurde aktualisiert (pers. Daten)'), 'success') + else: + flash(_('Es gab keine Änderung der persönlichen Daten.'), 'info') + + add_to_course = form.get_add_to_course() + remove_from_course = form.get_remove_from_course() + + reset_password = form.get_send_mail() + + if remove_from_course: + try: + success = TeacherManagement.remove_course(teacher, remove_from_course, teacher.id) + flash( + _('Der/die Lehrbeauftragte wurde vom Kurs "(%(name)s)" entfernt', + name=remove_from_course.full_name), + 'success') + db.session.commit() + except Exception as e: + db.session.rollback() + flash(_('Der/die Lehrbeauftragte konnte nicht aus dem Kurs entfernt werden: %(error)s', error=e), + 'negative') + + if add_to_course: + try: + TeacherManagement.add_course(teacher, add_to_course) + flash( + _('Der/die Lehrbeauftragte wurde zum Kurs {} hinzugefügt.'.format(add_to_course.full_name)), + 'success' + ) + db.session.commit() + except Exception as e: + db.session.rollback() + flash( + _('Der/die Lehrbeauftragte konnte nicht für den Kurs eingetragen werden: %(error)s', + error=e), + 'negative') + + if reset_password: + try: + send_password_reset_to_user(teacher) + flash( + _('Eine Mail zum Passwort Zurücksetzen wurde an {} geschickt.'.format(teacher.full_name)), + 'success') + except (AssertionError, socket.error, ConnectionError) as e: + flash(_('Eine Mail zum Passwort Reset konnte nicht verschickt werden: %(error)s', error=e), + 'negative') + + return redirect(url_for('edit_teacher', id=teacher.id)) + + except Exception as e: + db.session.rollback() + flash(_('Der Bewerber konnte nicht aktualisiert werden: %(error)s', error=e), 'negative') + return dict(form=form) + + form.populate(teacher) + return dict(teacher=teacher, form=form) + + +@templated('internal/teacher.html') +def teacher(): + return dict(user=current_user) + + +@templated('internal/administration/grade.html') +def grade(course_id): + course = models.Course.query.get_or_404(course_id) + + exam_date = app.config['EXAM_DATE'] + + return dict(course=course, exam_date=exam_date) + + +@templated('internal/administration/edit_grade.html') +def edit_grade(course_id): + course = models.Course.query.get_or_404(course_id) + if not current_user.is_admin_or_superuser and not current_user.is_course_teacher(course): + return redirect(url_for('internal')) + # !!! course.course_list returns only active applicants (not on waiting list) + # populate grade fields with applicant parameters + grade_list = forms.create_grade_form(course.course_list) + form = grade_list(request.form) + + exam_date = app.config['EXAM_DATE'] + + # ToDo: assign course ects when applicant registers for course + # temporary quickfix + for applicant in course.course_list: + if applicant.ects_points == 0: + applicant.ects_points = course.ects_points + db.session.commit() + + if request.method == 'POST' and form.validate(): + try: + changes = False + for applicant in course.course_list: + grade_field = getattr(form, f'grade_{applicant.id}', None) + if grade_field and grade_field.data != applicant.grade: + applicant.grade = grade_field.data + changes = True + + ects_field_name = f'ects_{applicant.id}' + if ects_field_name in request.form: + submitted_ects = int(request.form[ects_field_name]) + if submitted_ects != applicant.ects_points: + applicant.ects_points = submitted_ects + changes = True + + if changes: + db.session.commit() + flash('Noten wurden erfolgreich gespeichert!', 'success') + else: + flash('Es gab keine Änderungen zu speichern.', 'info') + except Exception as e: + db.session.rollback() + flash(_('Es gab einen Fehler beim Speichern der Noten: %(error)s', error=e), 'negative') + + return redirect(url_for('edit_grade_view', course_id=course_id)) + + return dict(course=course, form=form, exam_date=exam_date) + + +@templated('internal/administration/edit_grade_view.html') +def edit_grade_view(course_id): + course = models.Course.query.get_or_404(course_id) + exam_date = app.config['EXAM_DATE'] + + if request.method == 'POST': + try: + changes = False + for applicant in course.course_list: + view_field_name = f'view_{applicant.id}' + submitted_view = request.form.get(view_field_name) + if submitted_view is not None: + hide_view = (int(submitted_view) == 0) + if hide_view != applicant.hide_grade: + applicant.hide_grade = hide_view + changes = True + if changes: + db.session.commit() + flash('Als bestanden eingetragene Noten wurden erfolgreich gespeichert!', 'success') + else: + flash('Es gab keine Änderungen zu speichern.', 'info') + except Exception as e: + db.session.rollback() + flash(_('Es ist ein Fehler beim Abspeichern der Bestanden-Attribute aufgetreten: %(error)s', error=e), + 'negative') + + return redirect(url_for('grade', id=id, course_id=course_id)) + + return dict(course=course, exam_date=exam_date) + + +@templated('internal/administration/attendances.html') +def attendances(id, course_id): + teacher_db = models.User.query.get_or_404(id) + course = models.Course.query.get_or_404(course_id) + + weeks = app.config['WEEKS'] + # weeks = [i for i in range(week_num)] + return dict(teacher=teacher_db, course=course, weeks=int(weeks)) + + +@templated('internal/administration/edit_attendances.html') +def edit_attendances(id, course_id, class_id): + teacher_db = models.User.query.get_or_404(id) + course = models.Course.query.get_or_404(course_id) + + return dict(teacher=teacher_db, course=course, class_id=class_id) + + +@templated('internal/administration/teacher_void.html') +def teacher_void(): + if current_user.is_teacher: + return redirect(url_for('teacher')) + + all_users = models.User.query.all() + + # Filter users who do not have COURSE_TEACHER or SUPERUSER roles + users_without_roles = [ + user for user in all_users + if ( + not any(role.role == models.Role.COURSE_TEACHER for role in user.roles) and + any(role.role == models.Role.COURSE_ADMIN for role in user.roles) + ) or ( + not any( + role.role in [models.Role.COURSE_TEACHER, models.Role.COURSE_ADMIN, models.Role.SUPERUSER] for role + in user.roles) + ) + ] + + return dict(users=users_without_roles) diff --git a/src/spz/campusportal/export_token.py b/src/spz/campusportal/export_token.py new file mode 100644 index 0000000..ecd9786 --- /dev/null +++ b/src/spz/campusportal/export_token.py @@ -0,0 +1,19 @@ +from datetime import datetime, timedelta +from spz import app, tasks + +import jwt + +def generate_export_token_for_courses(courses): + return jwt.encode({'courses': courses, 'exp': datetime.utcnow() + timedelta(hours=1)}, + key=app.config['SECRET_KEY'], algorithm="HS256") + +def get_courses_from_export_token(export_token): + try: + courses = jwt.decode(export_token, key=app.config['SECRET_KEY'], algorithms="HS256")['courses'] + + if isinstance(courses, list): + return courses + + return False + except Exception as e: + return False diff --git a/src/spz/export/excel.py b/src/spz/export/excel.py index 468a555..a8ce30d 100644 --- a/src/spz/export/excel.py +++ b/src/spz/export/excel.py @@ -139,7 +139,8 @@ def check_for_expressions(self): 'course.alternative', 'course.name_english', 'semester', - 'exam_date' + 'exam_date', + 'course.teacher_name' ] # in case of integers they need to be converted to a string if type(key) is int: diff --git a/src/spz/forms/__init__.py b/src/spz/forms/__init__.py index 50de669..0237a5c 100644 --- a/src/spz/forms/__init__.py +++ b/src/spz/forms/__init__.py @@ -9,12 +9,13 @@ from datetime import datetime from sqlalchemy import func, and_, or_, not_ -from flask_wtf import FlaskForm +from flask_wtf import FlaskForm, Form from flask_login import current_user from markupsafe import Markup from wtforms import widgets, StringField, SelectField, SelectMultipleField, IntegerField, Label -from wtforms import TextAreaField, BooleanField, DecimalField, MultipleFileField +from wtforms import TextAreaField, BooleanField, DecimalField, MultipleFileField, FieldList, FormField, HiddenField from flask_ckeditor import CKEditorField +from wtforms.validators import DataRequired from spz import app, models, token @@ -36,7 +37,15 @@ 'UniqueForm', 'TagForm', 'SignoffForm', - 'ExportCourseForm' + 'ExportCourseForm', + 'CourseForm', + 'AddTeacherForm', + 'EditTeacherForm', + 'CourseForm', + 'VacanciesForm', + 'DeleteCourseForm', + 'TriStateField', + 'TriStateLabel' ] @@ -855,8 +864,242 @@ def __init__(self, languages=[], *args, **kwargs): (f.id, f.descriptive_name) for f in models.ExportFormat.list_formatters(languages=languages) ] + def update_course_list(self, user): + # fetch courses depending on user + if user.is_admin_or_superuser: + new_choices = cached.all_courses_to_choicelist() + if self.courses.choices != new_choices: + self.courses.choices = new_choices + else: + courses = getattr(user, 'teacher_courses', []) + new_choices = [(course.id, course.full_name) for course in courses] + if self.courses.choices != new_choices: + self.courses.choices = new_choices + + +class AddTeacherForm(FlaskForm): + """Represents the form to add teachers to database. + + """ + first_name = StringField( + 'Vorname', + [validators.Length(1, 60, 'Länge muss zwischen 1 und 60 Zeichen sein')] + ) + last_name = StringField( + 'Nachname', + [validators.Length(1, 60, 'Länge muss zwischen 1 and 60 Zeichen sein')] + ) + + mail = StringField( + 'E-Mail', + [ + validators.Length(max=120, message='Länge muss zwischen 1 und 120 Zeichen sein'), + validators.EmailPlusValidator() + ] + ) + + confirm_mail = StringField( + 'E-Mail bestätigen', + [validators.EqualTo('mail', message='E-Mailadressen müssen übereinstimmen.')] + ) + + tag = StringField( + 'Mitarbeiterkürzel', + [ + validators.Length(max=10, message='Länge darf maximal 10 Zeichen sein') + ] + ) + + courses = SelectMultipleField( + 'Kurse', + [validators.DataRequired('Mindestens ein Kurs muss ausgewählt werden')], + coerce=int + ) + + send_mail = BooleanField( + 'Passwort-Mail verschicken' + ) + + def __init__(self, language_id, *args, **kwargs): + super(AddTeacherForm, self).__init__(*args, **kwargs) + self.courses.choices = cached.language_to_choicelist(language_id, True) + + def update_courses(self, language_id): + self.courses.choices = cached.language_to_choicelist(language_id, True) + + def get_first_name(self): + return self.first_name.data + + def get_last_name(self): + return self.last_name.data + + def get_mail(self): + return self.mail.data + + def get_tag(self): + return self.tag.data + + def get_send_mail(self): + return self.send_mail.data + + def get_courses(self): + return [models.Course.query.get(id) for id in self.courses.data] + + def get_teacher(self): + existing = models.User.query.filter( + func.lower(models.User.email) == func.lower(self.get_mail()) + ).first() + if existing: + return existing + else: + return None + + +class EditTeacherForm(FlaskForm): + """Represents the form for editing a teacher and his/her courses and languages. + + """ + first_name = StringField( + 'Vorname', + [validators.Length(1, 60, 'Länge muss zwischen 1 und 60 Zeichen sein')] + ) + last_name = StringField( + 'Nachname', + [validators.Length(1, 60, 'Länge muss zwischen 1 and 60 sein')] + ) + mail = StringField( + 'E-Mail', + [ + validators.Length(max=120, message='Länge muss zwischen 1 und 120 Zeichen sein'), + validators.EmailPlusValidator() + ] + ) + tag = StringField( + 'Mitarbeiterkürzel', + [ + validators.Optional(), + validators.Length(max=30, message='Länge darf maximal 30 Zeichen sein') + ] + ) + + add_to_course = SelectField( + 'Kurs hinzufügen', + [validators.Optional()], + coerce=int, + choices=[] + ) + remove_from_course = SelectField( + 'Kurs löschen', + [validators.Optional()], + coerce=int, + choices=[] + ) + + send_mail = BooleanField( + 'Passwort zurücksetzen und Mail verschicken' + ) + + def __init__(self, teacher, *args, **kwargs): + super(EditTeacherForm, self).__init__(*args, **kwargs) + self.teacher = teacher + + self.add_to_course.choices = cached.all_courses_to_choicelist() + self.remove_from_course.choices = cached.all_courses_to_choicelist() + + def populate(self, teacher): + self.teacher = teacher + self.first_name.data = self.teacher.first_name + self.last_name.data = self.teacher.last_name + self.mail.data = self.teacher.email + self.tag.data = self.teacher.tag + + def get_teacher(self): + return self.teacher + + def get_courses(self): + sorted_courses = sorted(self.teacher.teacher_courses, key=lambda x: x.full_name) + return sorted_courses + + def get_languages(self): + language_ids = [] + languages = [] + if self.teacher is not None: + for course in self.teacher.teacher_courses: + if course.language_id not in language_ids: + language_ids.append(course.language_id) + db_lang = models.Language.query.get_or_404(course.language_id) + languages.append(db_lang) + + return languages if languages else None + + def get_add_to_course(self): + return models.Course.query.get(self.add_to_course.data) if self.add_to_course.data else None + + def get_remove_from_course(self): + return models.Course.query.get(self.remove_from_course.data) if self.remove_from_course.data else None + + def get_send_mail(self): + return self.send_mail.data + class CourseForm(FlaskForm): """ A form to select different participants in that specific course """ identifier = StringField() + + +def create_grade_form(applicants): + """ + Dynamically creates a GradeForm class with fields for each applicant. + """ + + class GradeForm(FlaskForm): + pass + + for applicant in applicants: + field_name = f'grade_{applicant.id}' + setattr(GradeForm, field_name, + IntegerField("Note", validators=[validators.Optional(), validators.NumberRange(min=0, max=100)], + default=applicant.grade)) + + return GradeForm + + +class AttendanceForm(FlaskForm): + attendance = HiddenField("attendance_id") + + + +class CampusExportForm(FlaskForm): + """ + Represents the form for exporting the grades of the applicants to the Campus System. + """ + + courses = SelectField( + 'Kurse', + coerce=str + ) + + def __init__(self, grouped_by_level, *args, **kwargs): + super(CampusExportForm, self).__init__(*args, **kwargs) + self.courses.choices = cached.grouped_by_level_to_choicelist(grouped_by_level) + + def get_courses(self): + return self.courses.data + + def update_course(self, grouped_by_level): + self.courses.choices = cached.grouped_by_level_to_choicelist(grouped_by_level) + +class ResetLanguagePWs(FlaskForm): + """Represents the form for send pws to all teachers of a language.""" + + def __init__(self, language, *args, **kwargs): + super(ResetLanguagePWs, self).__init__(*args, **kwargs) + self.language = language + self.send_mail.label.text = f'Passwort für alle Dozenten von {language.name} zurücksetzen' + + send_mail = BooleanField() + + def get_send_mail(self): + return self.send_mail.data + diff --git a/src/spz/forms/cached.py b/src/spz/forms/cached.py index cc8c628..8d734b0 100644 --- a/src/spz/forms/cached.py +++ b/src/spz/forms/cached.py @@ -40,6 +40,7 @@ def origins_to_choicelist(): in models.Origin.query.order_by(models.Origin.id.asc()) ] + @cache.cached(key_prefix='internal_origins') def internal_origins_to_choicelist(): return [ @@ -48,6 +49,7 @@ def internal_origins_to_choicelist(): in models.Origin.query.filter(models.Origin.is_internal == True).order_by(models.Origin.id.asc()) ] + @cache.cached(key_prefix='external_origins') def external_origins_to_choicelist(): return [ @@ -56,15 +58,39 @@ def external_origins_to_choicelist(): in models.Origin.query.filter(models.Origin.is_internal == False).order_by(models.Origin.id.asc()) ] + @cache.cached(key_prefix='languages') def languages_to_choicelist(): return [ - (x.id, '{0}'.format(x.name)) + (x.id, '{0}'.format(x.full_name)) for x in models.Language.query.order_by(models.Language.name.asc()) ] +@cache.cached(key_prefix='language') +def language_to_choicelist(lang_id, has_teacher=False): # shows only courses from selected language + if not has_teacher: + return [ + (x.id, '{0}'.format(x.full_name)) + for x + in models.Course.query.filter(models.Course.language_id == lang_id).order_by(models.Course.id.asc()) + ] + else: + unassigned_courses = [ + (course.id, '{0}'.format(course.full_name)) + for course + in db.session.query(models.Course) + .outerjoin(models.Role, + (models.Role.course_id == models.Course.id) & (models.Role.role == models.Role.COURSE_TEACHER)) + .filter(models.Course.language_id == lang_id) + .filter(models.Role.id == None) + .order_by(models.Course.id.asc()) + ] + + return unassigned_courses + + @cache.cached(key_prefix='gers') def gers_to_choicelist(): return [ @@ -116,3 +142,11 @@ def all_courses_to_choicelist(): (course.id, '{0}'.format(course.full_name)) for course in courses ] + + +@cache.cached(key_prefix='courses_grouped_by_level') +def grouped_by_level_to_choicelist(grouped_courses: dict): + choices = [] + for level, courses in grouped_courses.items(): + choices.append((courses[0].level, '{0}'.format(courses[0].name))) + return choices diff --git a/src/spz/models.py b/src/spz/models.py index 0141521..443bd8d 100644 --- a/src/spz/models.py +++ b/src/spz/models.py @@ -209,6 +209,12 @@ class Applicant(db.Model): discounted = db.Column(db.Boolean) is_student = db.Column(db.Boolean) + # internal representation of the grade is in % + grade = db.Column(db.Integer, nullable=True) # TODO store grade encrypted + ects_points = db.Column(db.Integer, nullable=True, default=0) + # if a student only wants 'bestanden' instead of the grade value, is set to true + hide_grade = db.Column(db.Boolean, nullable=False, default=False) + # See {add,remove}_course_attendance member functions below attendances = db.relationship("Attendance", backref="applicant", cascade='all, delete-orphan', lazy="joined") @@ -243,6 +249,45 @@ def __lt__(self, other): def full_name(self): return '{} {}'.format(self.first_name, self.last_name) + @property + def tag_is_digit(self): + if self.tag is None: + return False + try: + int(self.tag) + return True + except ValueError: + return False + + @property + def sanitized_grade(self): + if self.grade is None: + return "" + return self.grade + + @property + def full_grade(self): + if self.grade is None: + return "-" + conversion_table = [ + (98, "1"), + (95, "1,3"), + (90, "1,7"), + (85, "2"), + (79, "2,3"), + (73, "2,7"), + (68, "3"), + (62, "3,3"), + (56, "3,7"), + (50, "4") + ] + + for percentage, grade in conversion_table: + if self.grade >= percentage: + return grade + + return "nicht bestanden" + def add_course_attendance(self, *args, **kwargs): attendance = Attendance(*args, **kwargs) self.attendances.append(attendance) @@ -254,7 +299,6 @@ def remove_course_attendance(self, course): self.attendances.remove(attendance) return len(remove) > 0 - def best_rating(self): """Results best rating, prioritize sticky entries.""" results_priority = [ @@ -275,7 +319,35 @@ def best_rating(self): return 0 + def rating_to_ger(self, percent): + """ + Converts the percentage value of the English test to the corresponding GER Level (German Language Level). + + returns: GER Level as string + """ + conversion_table = [ + (90, "C2"), + (80, "C1"), + (65, "B2"), + (50, "B1"), + (20, "A2") + ] + + for percentage, ger in conversion_table: + if percent >= percentage: + return ger + + return "" + + @property + def get_test_ger(self): + """ + Returns the GER level for the best test result. + """ + return self.rating_to_ger(self.best_rating()) + """ Discount (factor) for the next course beeing entered """ + def current_discount(self): attends = len([attendance for attendance in self.attendances if not attendance.waiting]) if self.is_student and attends == 0: @@ -390,8 +462,9 @@ class Course(db.Model): )) def __init__( - self, language, level, alternative, limit, price, level_english=None, ger=None, rating_highest=100, rating_lowest=0, collision=[], - ects_points=2): + self, language, level, alternative, limit, price, level_english=None, ger=None, rating_highest=100, + rating_lowest=0, collision=[], + ects_points=2): self.language = language self.level = level self.alternative = alternative @@ -422,6 +495,7 @@ def has_rating_restrictions(self): :param is_unpaid: Whether the course fee is still (partially) unpaid :param is_free: Whether the course is fully discounted """ + def filter_attendances(self, waiting=None, is_unpaid=None, is_free=None): result = [] for att in self.attendances: @@ -496,6 +570,7 @@ def name_english(self): return '{0} {1}'.format(self.language.name_english, self.level_english) """ active attendants without debt """ + @property def course_list(self): list = [attendance.applicant for attendance in self.filter_attendances(waiting=False)] @@ -521,6 +596,17 @@ def status(self): else: return self.Status.VACANCIES + @property + def teacher_name(self): + teacher_role = Role.query.join(User).filter( + Role.course_id == self.id, + Role.role == Role.COURSE_TEACHER + ).first() + + if teacher_role and teacher_role.user: + return teacher_role.user.full_name + return "" + @total_ordering class Language(db.Model): @@ -560,6 +646,7 @@ def __init__(self, name, reply_to, signup_begin, signup_rnd_window_end, signup_m self.signup_end = signup_end self.signup_auto_end = signup_auto_end self.name_english = name_english + def __repr__(self): return '' % self.name @@ -859,20 +946,21 @@ class User(db.Model): __tablename__ = 'user' id = db.Column(db.Integer, primary_key=True) - first_name = db.Column(db.String(120), nullable=True) - last_name = db.Column(db.String(120), nullable=True) + first_name = db.Column(db.String(120), nullable=True, default=None) + last_name = db.Column(db.String(120), nullable=True, default=None) tag = db.Column(db.String(30), unique=False, nullable=True) email = db.Column(db.String(120), unique=True) active = db.Column(db.Boolean, default=True) pwsalted = db.Column(db.LargeBinary(32), nullable=True) - roles = db.relationship('Role') + roles = db.relationship('Role', backref='user') - def __init__(self, email, active, roles): + def __init__(self, email, active, roles, tag=None): """Create new user without password.""" self.email = email self.active = active self.pwsalted = None self.roles = roles + self.tag = tag def reset_password(self): """Reset password to random one and return it.""" @@ -910,7 +998,8 @@ def is_superuser(self): @property def admin_courses(self): - return (role.course for role in [r for r in self.roles if r.role == Role.COURSE_ADMIN]) + admin_courses = (role.course for role in [r for r in self.roles if r.role == Role.COURSE_ADMIN]) + return sorted(admin_courses, key=lambda x: x.full_name) @property def teacher_courses(self): @@ -983,6 +1072,10 @@ def get_by_login(email, pw): User.pwsalted == salted )).first() + @property + def full_name(self): + return '{} {}'.format(self.first_name, self.last_name) + @total_ordering class LogEntry(db.Model): @@ -1073,6 +1166,7 @@ def list_formatters(languages=[]): ExportFormat.language_id.in_(language_ids) )).all() + class OAuthToken(db.Model): """Token used to store data while oidc flow with kit server @@ -1094,4 +1188,3 @@ def __init__(self, state, code_verifier): self.code_verifier = code_verifier self.request_has_been_made = False self.is_student = False - diff --git a/src/spz/templates/baselayout.html b/src/spz/templates/baselayout.html index a2a8676..62dab19 100644 --- a/src/spz/templates/baselayout.html +++ b/src/spz/templates/baselayout.html @@ -1,83 +1,130 @@ - - - - KIT Sprachenzentrum - + + + + KIT Sprachenzentrum + - {% assets "all_css" %} - - {% endassets %} + {% assets "all_css" %} + + {% endassets %} - + - {% block head %} - {% endblock head %} - - - -
-