from base64 import b64encode from io import BytesIO from pathlib import Path import numpy as np from flask import flash, has_request_context, redirect, url_for from flask_admin import Admin as FlaskAdmin from flask_admin import expose from flask_admin.contrib.sqla.fields import QuerySelectField, QuerySelectMultipleField from flask_admin.contrib.sqla.filters import BooleanEqualFilter, FilterEqual from flask_admin.helpers import get_form_data from flask_admin.menu import MenuLink from flask_login import current_user from flask_security.changeable import admin_change_password from flask_security.utils import hash_password from flask_wtf import FlaskForm from flask_wtf.file import FileAllowed, FileField, FileRequired from matplotlib.figure import Figure from sqlalchemy import and_, not_, or_, select from werkzeug.utils import secure_filename from wtforms.fields import ( BooleanField, DateField, DecimalField, IntegerField, RadioField, StringField, SubmitField, TextAreaField, ) from wtforms.validators import URL, DataRequired, Email, NumberRange, Optional from wtforms.widgets import NumberInput from . import user_datastore from .admin_link_formatters import ( admin_formatter, appointment_date_formatter, appointment_formatter, assistant_formatter, experiment_formatter, experiment_mark_formatter, group_experiment_formatter, group_formatter, part_formatter, part_student_formatter, part_student_part_formatter, part_with_semester_formatter, program_formatter, semester_experiment_formatter, semester_experiment_with_semester_formatter, semester_formatter, student_formatter, user_formatter, ) from .advlabdb_independent_funs import ( deep_getattr, email_formatter, experiment_marks_missing_formatter, flashRandomPassword, str_without_semester_formatter, ) from .assistantModelViews import assistantSpace from .custom_classes import ( SecureAdminBaseView, SecureAdminIndexView, SecureAdminModelView, ) from .database_import import importFromFile from .exceptions import ModelViewException from .model_dependent_funs import ( generate_new_password_field, initActiveSemesterMenuLinks, mark_field, user_info_fields, ) from .model_independent_funs import randomPassword from .models import ( MAX_MARK, MAX_YEAR, MIN_MARK, MIN_YEAR, Admin, Appointment, Assistant, Experiment, ExperimentMark, Group, GroupExperiment, Part, PartStudent, Program, Role, Semester, SemesterExperiment, Student, User, db, ) adminSpace = FlaskAdmin( name="Admin@AdvLabDB", url="/admin", template_mode="bootstrap4", index_view=SecureAdminIndexView(name="Home", url="/admin", endpoint="admin"), ) def semesterExperimentQueryFactory(): return SemesterExperiment.query.where(SemesterExperiment.semester == current_user.active_semester) class SemesterRowFilter(FilterEqual): def get_options(self, view): if not has_request_context(): return tuple() semesters = Semester.sortedSemestersStartingWithNewest() return tuple((semester.id, str(semester)) for semester in semesters) class UserView(SecureAdminModelView): class SemesterFilter(SemesterRowFilter): def apply(self, query, value, alias=None): return query.where(User.active_semester_id == int(value)) class CreateForm(FlaskForm): @staticmethod def roleQueryFactory(): return Role.query @staticmethod def semesterQueryFactory(): return Semester.query @staticmethod def default_roles(): return [user_datastore.find_role("assistant")] email = StringField( "Email", validators=[DataRequired(), Email()], ) roles = QuerySelectMultipleField( "Roles", query_factory=roleQueryFactory, validators=[DataRequired()], default=default_roles, ) first_name = StringField( "First Name", validators=[DataRequired()], ) last_name = StringField( "Last Name", validators=[DataRequired()], ) phone_number, mobile_phone_number, building, room = user_info_fields() semester_experiments = QuerySelectMultipleField( "Semester Experiments", query_factory=semesterExperimentQueryFactory, allow_blank=True, blank_text="-", description="Only needed if the user has the assistant role.", ) active = BooleanField( "Active", default=True, ) active_semester = QuerySelectField( "Active Semester", query_factory=semesterQueryFactory, validators=[DataRequired()], default=Semester.lastSemester, description="Not fixed and users (including assistants) can change it.", ) class EditForm(CreateForm): semester_experiments = None generate_new_password = generate_new_password_field() column_list = [ "first_name", "last_name", "email", "active", "roles", "active_semester", "current_login_at", ] column_details_list = column_list + [ "phone_number", "mobile_phone_number", "building", "room", "create_datetime", "update_datetime", ] column_searchable_list = [ "first_name", "last_name", "email", ] column_filters = ( SemesterFilter(User, "Active Semester"), "active", ) refreshFiltersCache = True column_editable_list = [ "active", ] column_formatters = { "email": email_formatter, "active_semester": semester_formatter, } _skip_session_addition_on_model_creation = True def customCreateModel(self, form): password = randomPassword() hashedPassword = hash_password(password) email = form.email.data.lower() roles = [role.name for role in form.roles.data] model = user_datastore.create_user( email=email, password=hashedPassword, roles=roles, first_name=form.first_name.data, last_name=form.last_name.data, phone_number=form.phone_number.data, mobile_phone_number=form.mobile_phone_number.data, building=form.building.data, room=form.room.data, active=form.active.data, active_semester=form.active_semester.data, ) flashRandomPassword(model.email, password) return model def on_model_delete(self, model): if model == current_user: raise ModelViewException("Tried to delete yourself as user!") def on_model_change(self, form, model, is_created): if not is_created: if model == current_user: if not form.active.data: raise ModelViewException("Tried to deactivate yourself as user!") if not model.has_role("admin"): raise ModelViewException("Tried to remove your admin role!") if hasattr(form, "generate_new_password") and form.generate_new_password.data: password = randomPassword() flashRandomPassword(model.email, password) admin_change_password( model, password, notify=False ) # Password is automatically hashed with this function if model.has_role("assistant") and model.assistant is None: semester_experiments = form.semester_experiments.data if form.semester_experiments else [] assistant = Assistant(user=model, semester_experiments=semester_experiments) self.session.add(assistant) if model.has_role("admin") and model.admin is None: flash("Admin role was assigned!", "danger") admin = Admin(user=model) self.session.add(admin) def after_model_change(self, form, model, is_created): if is_created: flash( f"{model.email} registered with role(s): {', '.join(role.name for role in model.roles)}.", category="success", ) class SemesterView(SecureAdminModelView): class CreateForm(FlaskForm): @staticmethod def defaultFormLabel(): last_semester = Semester.lastSemester() if last_semester.label == "WS": return "SS" else: return "WS" @staticmethod def defaultFormYear(): last_semester = Semester.lastSemester() if last_semester.label == "WS": return last_semester.year + 1 else: return last_semester.year label = RadioField( "Semester", choices=["WS", "SS"], validators=[DataRequired()], default=defaultFormLabel, ) year = IntegerField( "Year", validators=[DataRequired(), NumberRange(MIN_YEAR, MAX_YEAR)], default=defaultFormYear, description=f"Between {MIN_YEAR} and {MAX_YEAR}.", ) transfer_parts = BooleanField( "Transfer parts", description="This option transfers the parts you have in your active semester. Make sure that your active semester is the last semester before creating a new one!", default=True, ) transfer_assistants = BooleanField( "Transfer Assistants", description="This option transfers assistants of your active semester to active experiments in the new semester. Make sure that your active semester is the last semester before creating a new one! Active experiments are transferred anyway. If you do not want an experiment to be transferred, set it to inactive before creating the new semester. Experiments which are switched to active before creating the new semester will be created in the new semester without assistants. It is recommended to check the assistants of all experiments after creating a new semester.", default=True, ) can_edit = False can_delete = False column_display_all_relations = True column_list = [ "label", "year", "parts", ] column_searchable_list = [ "label", "year", ] column_default_sort = [ ("year", True), ("label", True), ] column_formatters = { "parts": part_formatter, "semester_experiments": semester_experiment_formatter, "active_users": user_formatter, "groups": group_formatter, } column_formatters_export = { "parts": str_without_semester_formatter, "semester_experiments": str_without_semester_formatter, "groups": str_without_semester_formatter, } def customCreateModel(self, form): return Semester.initFromOldSemester( label=form.label.data, year=form.year.data, oldSemester=current_user.active_semester, transferParts=form.transfer_parts.data, transferAssistants=form.transfer_assistants.data, ) @staticmethod def addMenuLink(space, newSemester): categoryText = "Active semester" link = MenuLink( name=str(newSemester), url=url_for("main.set_semester") + "?semester_id=" + str(newSemester.id), category=categoryText, ) category = space._menu_categories.get(categoryText) link.parent = category category._children.insert(0, link) def after_model_change(self, form, model, is_created): current_user.setActiveSemester(model) SemesterView.addMenuLink(adminSpace, model) SemesterView.addMenuLink(assistantSpace, model) def programQueryFactory(): return Program.query class PartView(SecureAdminModelView): column_display_all_relations = True column_sortable_list: list[str] = [] column_list = [ "program", "number", ] form_columns = column_list form_extra_fields = { "program": QuerySelectField( "Program", query_factory=programQueryFactory, validators=[DataRequired()], allow_blank=True, blank_text="-", ) } form_args = { "number": {"widget": NumberInput(min=1)}, } column_formatters = { "program": program_formatter, "semester": semester_formatter, "part_students": part_student_formatter, } def query_modifier(self, query): return query.where(Part.semester == current_user.active_semester) def customCreateModel(self, form): return Part(program=form.program.data, number=form.number.data, semester=current_user.active_semester) class StudentView(SecureAdminModelView): column_display_all_relations = True column_list = [ "student_number", "first_name", "last_name", "uni_email", "contact_email", "part_students", ] column_descriptions = { "contact_email": "The preferred contact email address if entered by the student", } column_sortable_list = [ "student_number", "first_name", "last_name", ] column_searchable_list = column_sortable_list + [ "uni_email", "contact_email", ] form_excluded_columns = [ "part_students", ] form_args = { "student_number": {"widget": NumberInput(min=0)}, "uni_email": {"validators": [Email()]}, "contact_email": {"validators": [Email()]}, } column_formatters = { "uni_email": email_formatter, "contact_email": email_formatter, "part_students": part_student_part_formatter, } @staticmethod def part_students_export_formatter(view, context, model, name): part_students = deep_getattr(model, name) if part_students is None: return "" return ", ".join(str(part_student.part) for part_student in part_students) column_formatters_export = { "part_students": part_students_export_formatter, } def partQueryFactory(): return Part.query.where(Part.semester == current_user.active_semester) def groupQueryFactory(): return Group.query.where(Group.semester == current_user.active_semester) class PartRowFilter(FilterEqual): def get_options(self, view): if not has_request_context(): return tuple() parts = db.session.execute(select(Part).where(Part.semester == current_user.active_semester)).scalars() return tuple((part.id, part.str_without_semester()) for part in parts) class PartStudentView(SecureAdminModelView): class PartFilter(PartRowFilter): def apply(self, query, value, alias=None): return query.where(PartStudent.part_id == int(value)) class CreateForm(FlaskForm): @staticmethod def studentQueryFactory(): return Student.query student = QuerySelectField( "Student", query_factory=studentQueryFactory, validators=[DataRequired()], allow_blank=True, blank_text="-", ) part = QuerySelectField( "Part", query_factory=partQueryFactory, validators=[DataRequired()], allow_blank=True, blank_text="-", ) group = QuerySelectField( "Group", query_factory=groupQueryFactory, allow_blank=True, blank_text="-", ) class EditForm(CreateForm): student = None part = None column_display_all_relations = True column_filters = ( PartFilter(PartStudent, "Part"), "student.student_number", "student.first_name", "student.last_name", "group.number", "experiment_marks", ) refreshFiltersCache = True column_formatters = { "student": student_formatter, "group": group_formatter, "part": part_formatter, "experiment_marks": experiment_mark_formatter, } column_formatters_export = { "group": str_without_semester_formatter, "part": str_without_semester_formatter, } def query_modifier(self, query): return query.join(Part).where(Part.semester == current_user.active_semester) def on_model_change(self, form, model, is_created): PartStudent.check(model.part, model.group) def partStudentQueryFactory(): return PartStudent.query.join(Part).where(Part.semester == current_user.active_semester) class ProgramRowFilter(FilterEqual): def get_options(self, view): if not has_request_context(): return tuple() programs = db.session.execute(select(Program)).scalars() return tuple((program.id, str(program)) for program in programs) class GroupView(SecureAdminModelView): class ProgramFilter(ProgramRowFilter): def apply(self, query, value, alias=None): return query.where(Group.program_id == int(value)) @staticmethod def formFactory(is_created, group): if is_created: def query_factory(): return partStudentQueryFactory().where(PartStudent.group == None) else: def query_factory(): return partStudentQueryFactory().where( or_( and_(PartStudent.group == None, Part.program == group.program), PartStudent.group == group, ) ) class CustomForm(FlaskForm): part_students = QuerySelectMultipleField( "Part Students", query_factory=query_factory, validators=[DataRequired()], description="The part students have to be in the same program!", ) return CustomForm column_display_all_relations = True column_exclude_list = [ "semester", ] column_filters = ( ProgramFilter(Group, "Program"), "number", ) refreshFiltersCache = True column_formatters = { "program": program_formatter, "part_students": part_student_formatter, "group_experiments": group_experiment_formatter, } def query_modifier(self, query): return query.where(Group.semester == current_user.active_semester) def customCreateModel(self, form): return Group.customInit(form.part_students.data) def create_form(self, obj=None): formClass = GroupView.formFactory(is_created=True, group=None) return formClass(get_form_data(), obj=obj) def edit_form(self, obj=None): formClass = GroupView.formFactory(is_created=False, group=obj) return formClass(get_form_data(), obj=obj) class ExperimentView(SecureAdminModelView): class ProgramFilter(ProgramRowFilter): def apply(self, query, value, alias=None): return query.where(Experiment.program_id == int(value)) column_display_all_relations = True column_filters = ( ProgramFilter(Experiment, "Program"), "active", ) refreshFiltersCache = True column_list = [ "number", "program", "title", "active", ] column_descriptions = { "active": "Active experiments are present in new semesters", } column_searchable_list = [ "number", "title", ] form_columns = column_list + [ "description", "wiki_link", "building", "room", "responsibility", "duration_in_days", ] column_editable_list = [ "active", ] form_args = { "number": {"widget": NumberInput(min=1)}, "wiki_link": {"validators": [URL()]}, "duration_in_days": {"widget": NumberInput(min=1)}, } form_extra_fields = { "program": QuerySelectField( "Program", query_factory=programQueryFactory, validators=[DataRequired()], allow_blank=True, blank_text="-", ) } column_formatters = { "program": program_formatter, "semester_experiments": semester_experiment_with_semester_formatter, } def assistantQueryFactory(): return Assistant.query.join(User).where(User.active == True) def weighting_field(label: str, default: float): return DecimalField( label, validators=[DataRequired(), NumberRange(0, 1)], default=default, description="Between 0 and 1.", places=2, widget=NumberInput(step=0.01), ) class SemesterExperimentView(SecureAdminModelView): class ProgramFilter(ProgramRowFilter): def apply(self, query, value, alias=None): return query.join(Experiment).where(Experiment.program_id == int(value)) class CreateForm(FlaskForm): @staticmethod def experimentQueryFactory(): return Experiment.query.where(Experiment.active == True) experiment = QuerySelectField( "Experiment", query_factory=experimentQueryFactory, validators=[DataRequired()], allow_blank=True, blank_text="-", ) oral_weighting = weighting_field("Oral weighting", 0.5) protocol_weighting = weighting_field("Protocol weighting", 0.5) final_weighting = weighting_field("Final weighting", 1.0) assistants = QuerySelectMultipleField( "Assistants", query_factory=assistantQueryFactory, ) class EditForm(CreateForm): experiment = None column_display_all_relations = True column_list = [ "experiment", "assistants", ] column_filters = (ProgramFilter(SemesterExperiment, "Program"),) refreshFiltersCache = True column_searchable_list = [ "experiment.number", "experiment.title", ] column_formatters = { "experiment": experiment_formatter, "semester": semester_formatter, "assistants": assistant_formatter, "group_experiments": group_experiment_formatter, } def query_modifier(self, query): return query.where(SemesterExperiment.semester == current_user.active_semester) def customCreateModel(self, form): return SemesterExperiment( semester=current_user.active_semester, oral_weighting=form.oral_weighting.data, protocol_weighting=form.protocol_weighting.data, final_weighting=form.final_weighting.data, experiment=form.experiment.data, assistants=form.assistants.data, ) def on_model_change(self, form, model, is_created): model.checkAndRoundWeightings() def update_model(self, form, model): weightingsChanged = ( form.oral_weighting.data != model.oral_weighting or form.protocol_weighting.data != model.protocol_weighting or form.final_weighting.data != model.final_weighting ) updateSuccessful = super().update_model(form, model) if updateSuccessful and weightingsChanged: # Custom after_model_change model.updateFinalExperimentAndPartMarks() return updateSuccessful def userHasRoleFilterFactory(column, role_name): class UserHasRoleFilter(BooleanEqualFilter): def apply(self, query, value, alias=None): query = query.join(User) if bool(int(value)): # value is string "0" or "1" return query.join(User.roles).where(Role.name == role_name) else: return query.where(not_(User.roles.any(Role.name == role_name))) return UserHasRoleFilter(column, f"Currently has {role_name} role") class AssistantView(SecureAdminModelView): @staticmethod def assistantUserQueryFactory(): return User.query.join(User.roles).where(Role.name == "assistant") column_display_all_relations = True column_list = [ "user", "semester_experiments", ] column_searchable_list = [ "user.first_name", "user.last_name", ] column_filters = ( userHasRoleFilterFactory(Assistant, "assistant"), "user.active", ) form_excluded_columns = [ "experiment_marks", "appointments", ] form_extra_fields = { "user": QuerySelectField( "User", query_factory=assistantUserQueryFactory, validators=[DataRequired()], allow_blank=True, blank_text="-", ), "semester_experiments": QuerySelectMultipleField( "Semester Experiments", query_factory=semesterExperimentQueryFactory, allow_blank=True, blank_text="-", ), } column_formatters = { "user": user_formatter, "semester_experiments": semester_experiment_with_semester_formatter, "appointments": appointment_formatter, "experiment_marks": experiment_mark_formatter, } class AdminView(SecureAdminModelView): can_export = False can_set_page_size = False can_create = False can_edit = False can_delete = False column_list = [ "user", ] column_filters = ( userHasRoleFilterFactory(Admin, "admin"), "user.active", ) column_formatters = { "user": user_formatter, } class ExperimentRowFilter(FilterEqual): def get_options(self, view): if not has_request_context(): return tuple() activeExperiments = db.session.execute(select(Experiment).where(Experiment.active == True)).scalars() return tuple( ( f"{activeExperiment.number},{activeExperiment.program_id}", f"{activeExperiment.number} {activeExperiment.program}", ) for activeExperiment in activeExperiments ) @staticmethod def get_values(value): values = value.split(",") experimentNumber = int(values[0]) programId = int(values[1]) return (experimentNumber, programId) def group_experiment_note_field(): return TextAreaField( "Note", validators=[Optional()], description="This note can be seen and edited by assistants that are responsible for this semester experiment.", ) def appointment_fields(number=None): if number is None: # Used in AppointmentView label_addition = "" date_validator = DataRequired() date_description = None else: # Used in GroupExperimentView label_addition = f"Appointment-{number} " date_validator = Optional() if number == 1: date_description = "Set if you already want to add an appointment. Otherwise, leave it blank and you can do it later under the Appointment tab." else: date_description = "Add another appointment (see above)." date = DateField( label_addition + "Date", validators=[date_validator], description=date_description, ) special = BooleanField( label_addition + "Special", default=False, description="A special appointment should take place in the semester break.", ) assistant = QuerySelectField( label_addition + "Assistant", query_factory=assistantQueryFactory, allow_blank=True, blank_text="Auto assign if experiment has only one assistant", ) return date, special, assistant class GroupExperimentView(SecureAdminModelView): class ExperimentFilter(ExperimentRowFilter): def apply(self, query, value, alias=None): experimentNumber, programId = ExperimentRowFilter.get_values(value) return ( query.join(SemesterExperiment) .join(Experiment) .where(Experiment.program_id == programId, Experiment.number == experimentNumber) ) class EditForm(FlaskForm): note = group_experiment_note_field() class CreateForm(FlaskForm): group = QuerySelectField( "Group", query_factory=groupQueryFactory, validators=[DataRequired()], allow_blank=True, blank_text="-", ) semester_experiment = QuerySelectField( "Semester Experiment", query_factory=semesterExperimentQueryFactory, validators=[DataRequired()], allow_blank=True, blank_text="-", ) appointment1_date, appointment1_special, appointment1_assistant = appointment_fields(1) appointment2_date, appointment2_special, appointment2_assistant = appointment_fields(2) note = group_experiment_note_field() column_display_all_relations = True column_filters = ( ExperimentFilter(GroupExperiment, "Experiment"), "group.number", "appointments", "experiment_marks", ) refreshFiltersCache = True column_default_sort = ("experiment_marks_missing", True) column_formatters = { "semester_experiment": semester_experiment_formatter, "group": group_formatter, "appointments": appointment_date_formatter, "experiment_marks": experiment_mark_formatter, "experiment_marks_missing": experiment_marks_missing_formatter, } @staticmethod def appointments_export_formatter(view, context, model, name): appointments = deep_getattr(model, name) if appointments is None: return "" return ", ".join(str(appointment.date) for appointment in appointments) column_formatters_export = { "semester_experiment": str_without_semester_formatter, "group": str_without_semester_formatter, "appointments": appointments_export_formatter, } def query_modifier(self, query): return query.join(Group).where(Group.semester == current_user.active_semester) def customCreateModel(self, form): return GroupExperiment(semester_experiment=form.semester_experiment.data, group=form.group.data) def on_model_change(self, form, model, is_created): if is_created: for date, special, assistant in ( (form.appointment1_date.data, form.appointment1_special.data, form.appointment1_assistant.data), (form.appointment2_date.data, form.appointment2_special.data, form.appointment2_assistant.data), ): if date is not None: appointment = Appointment(date=date, special=special, group_experiment=model, assistant=assistant) self.session.add(appointment) def after_model_change(self, form, model, is_created): if is_created: if model.appointments: flash(f"Appointments {model.appointments} added.", "success") def groupExperimentQueryFactory(): return GroupExperiment.query.join(SemesterExperiment).where( SemesterExperiment.semester == current_user.active_semester ) class AssistantRowFilter(FilterEqual): def get_options(self, view): if not has_request_context(): return tuple() activeAssistants = assistantQueryFactory() return tuple((assistant.id, str(assistant)) for assistant in activeAssistants) class AppointmentView(SecureAdminModelView): class ExperimentFilter(ExperimentRowFilter): def apply(self, query, value, alias=None): experimentNumber, programId = ExperimentRowFilter.get_values(value) return query.join(Experiment).where( Experiment.program_id == programId, Experiment.number == experimentNumber ) class AssistantFilter(AssistantRowFilter): def apply(self, query, value, alias=None): return query.where(Appointment.assistant_id == int(value)) class CreateAndEditForm(FlaskForm): group_experiment = QuerySelectField( "Group Experiment", query_factory=groupExperimentQueryFactory, validators=[DataRequired()], allow_blank=True, blank_text="-", ) date, special, assistant = appointment_fields() column_descriptions = { "special": "A special appointment should take place in the semester break", } column_filters = ( ExperimentFilter(Appointment, "Experiment"), AssistantFilter(Appointment, "Assistant"), "group_experiment.group", "date", "special", ) refreshFiltersCache = True column_editable_list = [ "date", "special", ] column_formatters = { "group_experiment": group_experiment_formatter, "assistant": assistant_formatter, } def query_modifier(self, query): return ( query.join(GroupExperiment) .join(SemesterExperiment) .where(SemesterExperiment.semester == current_user.active_semester) ) def customCreateModel(self, form): return Appointment( date=form.date.data, special=form.special.data, group_experiment=form.group_experiment.data, assistant=form.assistant.data, ) def customUpdateModel(self, form, model): if form.date is None: # For editable model.special = form.special.data elif form.special is None: # For editable model.date = form.date.data else: # Not editable, full form model.custom_update( date=form.date.data, special=form.special.data, group_experiment=form.group_experiment.data, assistant=form.assistant.data, ) return True class ExperimentMarkView(SecureAdminModelView): class AssistantFilter(AssistantRowFilter): def apply(self, query, value, alias=None): return query.where(ExperimentMark.assistant_id == int(value)) class AdminFilter(FilterEqual): def get_options(self, view): if not has_request_context(): return tuple() admins = db.session.execute(select(Admin).join(User).where(User.active == True)).scalars() return tuple((admin.id, str(admin)) for admin in admins) def apply(self, query, value, alias=None): return query.where(ExperimentMark.admin_id == int(value)) class ExperimentFilter(ExperimentRowFilter): def apply(self, query, value, alias=None): experimentNumber, programId = ExperimentRowFilter.get_values(value) return query.join(Experiment).where( Experiment.program_id == programId, Experiment.number == experimentNumber ) class ProgramFilter(ProgramRowFilter): def apply(self, query, value, alias=None): return query.join(PartStudent).join(Part).where(Part.program_id == int(value)) class PartFilter(PartRowFilter): def apply(self, query, value, alias=None): return query.join(PartStudent).where(PartStudent.part_id == int(value)) class CreateForm(FlaskForm): part_student = QuerySelectField( "Part Student", query_factory=partStudentQueryFactory, validators=[DataRequired()], allow_blank=True, blank_text="-", ) group_experiment = QuerySelectField( "Group Experiment", query_factory=groupExperimentQueryFactory, validators=[DataRequired()], allow_blank=True, blank_text="-", ) class EditForm(FlaskForm): oral_mark = mark_field("Oral") protocol_mark = mark_field("Protocol") column_descriptions = { "oral_mark": f"Between {MIN_MARK} and {MAX_MARK}", "protocol_mark": f"Between {MIN_MARK} and {MAX_MARK}", "final_experiment_mark": "Calculated automatically with oral and protocol marks and weightings", "assistant": "The last assistant who edited the mark", "admin": "The last admin who edited the mark", } column_filters = ( AssistantFilter(ExperimentMark, "Assistant"), AdminFilter(ExperimentMark, "Admin"), ExperimentFilter(ExperimentMark, "Experiment"), ProgramFilter(ExperimentMark, "Program"), PartFilter(ExperimentMark, "Part"), "group_experiment.group", "oral_mark", "protocol_mark", "final_experiment_mark", ) refreshFiltersCache = True column_default_sort = [("oral_mark", False), ("protocol_mark", False)] column_formatters = { "part_student": part_student_formatter, "group_experiment": group_experiment_formatter, "assistant": assistant_formatter, "admin": admin_formatter, } def query_modifier(self, query): return ( query.join(GroupExperiment) .join(SemesterExperiment) .where(SemesterExperiment.semester == current_user.active_semester) ) def customCreateModel(self, form): return ExperimentMark(part_student=form.part_student.data, group_experiment=form.group_experiment.data) def customUpdateModel(self, form, model): form_oral_mark = form.oral_mark.data form_protocol_mark = form.protocol_mark.data if (form_oral_mark != model.oral_mark) or (form_protocol_mark != model.protocol_mark): model.set_oral_protocol_mark(form_oral_mark, form_protocol_mark) model.admin = current_user.admin return True return False class ProgramView(SecureAdminModelView): can_export = False can_set_page_size = False column_display_all_relations = True column_list = [ "label", ] column_details_exclude_list = [ "groups", ] column_sortable_list: list[str] = [] form_excluded_columns = [ "parts", "experiments", "groups", ] column_formatters = { "parts": part_with_semester_formatter, "experiments": experiment_formatter, } class ImportView(SecureAdminBaseView): class FileForm(FlaskForm): file = FileField( label="Import file", validators=[FileRequired(), FileAllowed(["txt"], "Only txt files are allowed!")], description="The import file has to be a text file (with .txt at the end) encoded in UTF-8. It has to strictly follow the required format.", ) submit = SubmitField( label="Upload and import", ) @expose("/", methods=("GET", "POST")) def index(self): form = ImportView.FileForm() if form.validate_on_submit(): f = form.file.data filename = secure_filename(f.filename) directory = "db/import_files" Path(directory).mkdir(exist_ok=True) filePath = directory + f"/{filename}" f.save(filePath) try: importFromFile(filePath) except Exception as ex: flash(str(ex), "error") return redirect(url_for("main.index")) return self.render("import.html", form=form) class ActionsView(SecureAdminBaseView): class ActionsForm(FlaskForm): manualUpdateFinalExperimentAndPartMarksSubmit = SubmitField( label="Manually update final experiment and part marks", ) @expose("/", methods=("GET", "POST")) def index(self): form = ActionsView.ActionsForm() if form.validate_on_submit(): if form.manualUpdateFinalExperimentAndPartMarksSubmit.data: for semesterExperiment in current_user.active_semester.semester_experiments: semesterExperiment.updateFinalExperimentAndPartMarks() flash("Manually updated all final experiment and part marks", "success") return redirect(url_for("main.index")) return self.render("actions.html", form=form) class AnalysisView(SecureAdminBaseView): class AnalysisForm(FlaskForm): assistantMarksSubmit = SubmitField( label="Assistant's marks", ) finalPartMarksSubmit = SubmitField( label="Final part marks", ) @staticmethod def htmlFig(fig): buf = BytesIO() fig.savefig(buf, format="png") return b64encode(buf.getbuffer()).decode("ascii") @staticmethod def markHist(data, title): fig = Figure() ax = fig.subplots() ax.set_xlim(MIN_MARK - 0.5, MAX_MARK + 0.5) ax.set_xticks(np.arange(MAX_MARK + 1)) ax.set_xlabel("Mark") N = data.size title += f"\nN = {N}" if N > 0: ax.hist( data, bins=np.arange(MAX_MARK) - 0.5, ) ax.set_yticks(np.arange(N + 1)) title += f" | mean = {round(np.mean(data), 1)}" else: ax.set_yticks(np.arange(2)) ax.set_title(title) return AnalysisView.htmlFig(fig) @staticmethod def get_experiment_marks(assistant, attr): data = [] for experimentMark in assistant.experiment_marks: mark = getattr(experimentMark, attr) if mark is not None: data.append(mark) return np.array(data) @staticmethod def markHists(markType, activeAssistants): attr = markType.lower() + "_mark" markTypeTitleAddition = f" | {markType} marks" marks = [AnalysisView.get_experiment_marks(assistant, attr) for assistant in activeAssistants] hists = [ AnalysisView.markHist( data=marks[i], title=str(activeAssistants[i]) + markTypeTitleAddition, ) for i in range(len(marks)) ] hists.append( AnalysisView.markHist( data=np.hstack(marks), title="All" + markTypeTitleAddition, ) ) return hists @staticmethod def get_final_part_marks(part): data = [] for partStudent in part.part_students: mark = partStudent.final_part_mark if mark is not None: data.append(mark) return np.array(data) @expose("/", methods=("GET", "POST")) def index(self): form = AnalysisView.AnalysisForm() if form.validate_on_submit(): if form.assistantMarksSubmit.data: activeAssistants = assistantQueryFactory() oralMarkHists = AnalysisView.markHists("Oral", activeAssistants) protocolMarkHists = AnalysisView.markHists("Protocol", activeAssistants) return self.render( "analysis/assistant_marks.html", histIndices=range(len(oralMarkHists)), oralMarkHists=oralMarkHists, protocolMarkHists=protocolMarkHists, ) if form.finalPartMarksSubmit.data: parts = current_user.active_semester.parts activeSemesterFinalPartMarksHists = [ AnalysisView.markHist( data=AnalysisView.get_final_part_marks(part), title=str(part), ) for part in parts ] semesters = Semester.sortedSemestersStartingWithNewest() meanFinalPartMarks = np.flip( [ np.mean( np.hstack( [ [partStudent.final_part_mark for partStudent in part.part_students] for part in semester.parts ] ) ) for semester in semesters ] ) fig = Figure() lenMeanFinalPartMarks = len(meanFinalPartMarks) if lenMeanFinalPartMarks > 0: ax = fig.subplots() x = range(1, lenMeanFinalPartMarks + 1) ax.plot( x, meanFinalPartMarks, marker="d", ) # TODO: Change ticks to semester labels # TODO: Check linestyle ax.set_xticks(x) ax.set_xlim(0.5, x[-1] + 0.5) meanFinalPartMarksPlot = AnalysisView.htmlFig(fig) return self.render( "analysis/final_part_marks.html", activeSemesterFinalPartMarksHists=activeSemesterFinalPartMarksHists, meanFinalPartMarksPlot=meanFinalPartMarksPlot, ) return self.render("analysis/analysis.html", form=form) class DocsView(SecureAdminBaseView): @expose("/") def index(self): return self.render("docs/docs.html", role="admin") def init_admin_model_views(app): adminSpace.init_app(app) adminSpace.add_view(StudentView(Student, url="student")) adminSpace.add_view(PartStudentView(PartStudent, url="part_student")) adminSpace.add_view(GroupView(Group, url="group")) adminSpace.add_view(GroupExperimentView(GroupExperiment, url="group_experiment")) adminSpace.add_view(AppointmentView(Appointment, url="appointment")) adminSpace.add_view(ExperimentMarkView(ExperimentMark, url="experiment_mark")) adminSpace.add_view(ExperimentView(Experiment, url="experiment")) adminSpace.add_view(SemesterExperimentView(SemesterExperiment, url="semester_experiment")) adminSpace.add_view(SemesterView(Semester, url="semester")) adminSpace.add_view(PartView(Part, url="part")) adminSpace.add_view(AssistantView(Assistant, url="assistant")) adminSpace.add_view(AdminView(Admin, url="admin")) adminSpace.add_view(UserView(User, url="user")) adminSpace.add_view(ProgramView(Program, url="program")) adminSpace.add_view(ImportView(name="Import", url="import")) adminSpace.add_view(ActionsView(name="Actions", url="actions")) adminSpace.add_view(AnalysisView(name="Analysis", url="analysis")) adminSpace.add_view(DocsView(name="Docs", url="docs")) initActiveSemesterMenuLinks(adminSpace, app)