1
0
Fork 0
mirror of https://codeberg.org/Mo8it/AdvLabDB.git synced 2024-12-04 22:40:30 +00:00

Compare commits

...

30 commits

Author SHA1 Message Date
0242b4389d Fix analysis 2022-09-24 19:29:56 +02:00
a70e01114c flake8 2022-09-24 16:18:12 +02:00
b895d1f8f1 Replace render_kw 2022-09-24 16:17:03 +02:00
ffcbda905d Add deactivate assistants action 2022-09-24 16:16:48 +02:00
4747f8a073 Update deps 2022-09-23 12:59:48 +02:00
e530dceab4 Add warning about done semester with missing experiment marks 2022-09-23 12:55:32 +02:00
8d7ea5cae3 Fix count number of missing final experiment marks 2022-09-23 12:55:05 +02:00
e1f004de7b Update pre-commit hooks 2022-09-22 20:56:07 +02:00
249f635772 Remove number_of_all_experiment_marks from assistants home page 2022-09-22 20:30:43 +02:00
83f4726296 Replace get_count and execute().scalars() 2022-09-22 20:26:22 +02:00
d533db66ab Add password forgetting notice for assistants 2022-09-22 14:59:43 +02:00
623100fffc Update DB.drawio 2022-09-22 14:56:02 +02:00
a562c2ee33 Add Tooltip to active semester 2022-09-21 17:14:25 +02:00
950823585e Handle unauthorized action 2022-09-21 16:52:15 +02:00
cf8279616e Add user settings 2022-09-21 16:52:04 +02:00
5d0f99a381 Remove redundant admin templates 2022-09-20 18:15:56 +02:00
e5c266c481 Custom login template 2022-09-20 18:11:25 +02:00
4d8947b89e Update deps 2022-09-20 15:09:45 +02:00
399ca4b198 Allow admins to choose a done semester as active semester 2022-09-20 15:09:03 +02:00
429ea37c40 flake8 2022-09-19 19:14:45 +02:00
77385c0861 Add set_old_semesters_done and set_done 2022-09-19 19:11:08 +02:00
7fe8024202 Update deps 2022-09-19 17:13:02 +02:00
cf433cb692 Remove admin or assistant instance if role was removed and there is no dependency 2022-09-19 17:05:16 +02:00
adbe8d0eb7 Only show active semester after user update for own user 2022-09-19 17:04:32 +02:00
a5b5c4156c Show assistants the weightings 2022-09-19 15:49:57 +02:00
ce3de0f6de Try to set last semester as active if current semester is done 2022-09-19 15:24:36 +02:00
c35e2507e1 Add inaccessible_callback to BaseView 2022-09-19 15:20:18 +02:00
c0a4f215e8 Add set_last_semester_as_active 2022-09-19 15:19:25 +02:00
8020762261 Prevent assistants from viewing or changing marks in semesters marked as done 2022-09-19 14:24:00 +02:00
bc0da570cd Fix limit before filter bug 2022-09-19 14:20:33 +02:00
51 changed files with 990 additions and 1971 deletions

View file

@ -10,13 +10,13 @@ repos:
- id: trailing-whitespace
- repo: https://github.com/asottile/pyupgrade
rev: v2.37.3
rev: v2.38.0
hooks:
- id: pyupgrade
args: [--py39-plus]
- repo: https://github.com/crate-ci/typos
rev: v1.12.4
rev: v1.12.7
hooks:
- id: typos

43
advlabdb/actions.py Normal file
View file

@ -0,0 +1,43 @@
from flask import flash
from flask_login import current_user
from sqlalchemy import select
from .models import Assistant, Semester, User, db
def update_final_experiment_and_part_marks():
for semesterExperiment in current_user.active_semester.semester_experiments:
semesterExperiment.updateFinalExperimentAndPartMarks()
flash("Manually updated all final experiment and part marks.", "success")
def deactivate_assistants():
user_ids_to_deactivate = db.session.scalars(
select(Assistant.user_id)
.join(User)
.where(User.active == True)
.except_(
select(Assistant.user_id).join(Assistant.semester_experiments).join(Semester).where(Semester.done == False)
)
)
no_users_deactivated = True
try:
for user_id in user_ids_to_deactivate:
user = db.session.get(User, user_id)
user.active = False
flash(f"User {user} deactivated!", "warning")
no_users_deactivated = False
db.session.commit()
except Exception as ex:
flash(str(ex), "danger")
db.session.rollback()
no_users_deactivated = True
if no_users_deactivated:
flash("No users to deactivate.", "info")

View file

@ -1,9 +1,6 @@
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 import flash, has_request_context, redirect
from flask_admin import Admin as FlaskAdmin
from flask_admin import expose
from flask_admin.contrib.sqla.fields import QuerySelectField, QuerySelectMultipleField
@ -15,7 +12,6 @@ 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 (
@ -32,6 +28,7 @@ from wtforms.validators import URL, DataRequired, Email, NumberRange, Optional
from wtforms.widgets import NumberInput
from . import data_dir, user_datastore
from .actions import deactivate_assistants, update_final_experiment_and_part_marks
from .admin_link_formatters import (
admin_formatter,
appointment_date_formatter,
@ -59,6 +56,7 @@ from .advlabdb_independent_funs import (
flashRandomPassword,
str_without_semester_formatter,
)
from .analysis import assistant_marks_analysis, final_part_marks_analysis
from .custom_classes import (
SecureAdminBaseView,
SecureAdminIndexView,
@ -128,7 +126,7 @@ class UserView(SecureAdminModelView):
@staticmethod
def semesterQueryFactory():
return Semester.query.order_by(Semester.id.desc()).where(Semester.done == False)
return Semester.query.order_by(Semester.id.desc())
@staticmethod
def default_roles():
@ -255,8 +253,10 @@ class UserView(SecureAdminModelView):
def on_model_change(self, form, model, is_created):
if not is_created:
if model == current_user:
# Prevent locking out
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!")
@ -268,16 +268,35 @@ class UserView(SecureAdminModelView):
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 []
user_assistant = model.assistant
if model.has_role("assistant"):
if user_assistant is None:
# Create assistant instance after new role assignment
semester_experiments = form.semester_experiments.data if form.semester_experiments else []
assistant = Assistant(user=model, semester_experiments=semester_experiments)
self.session.add(assistant)
assistant = Assistant(user=model, semester_experiments=semester_experiments)
self.session.add(assistant)
elif (
user_assistant is not None
and not user_assistant.semester_experiments
and not user_assistant.appointments
and not user_assistant.experiment_marks
):
# Delete assistant instance if there is no dependency
# Useful for undoing an unwanted role assignment
self.session.delete(user_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)
user_admin = model.admin
if model.has_role("admin"):
if user_admin is None:
# Create admin instance after new role assignment
flash(f"Admin role was assigned to {model}!", "danger")
admin = Admin(user=model)
self.session.add(admin)
elif user_admin is not None and not user_admin.experiment_marks:
# Delete admin instance if there is no dependency
# Useful for undoing an unwanted role assignment
self.session.delete(user_admin)
# Lower email
model.email = model.email.lower()
@ -289,7 +308,8 @@ class UserView(SecureAdminModelView):
category="success",
)
else:
flash(f"Active semester is {model.active_semester}.", "warning")
if model == current_user:
flash(f"Active semester is {model.active_semester}.", "warning")
class SemesterView(SecureAdminModelView):
@ -332,6 +352,11 @@ class SemesterView(SecureAdminModelView):
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,
)
set_old_semesters_done = BooleanField(
"Set all old semesters as done",
description="Setting semesters as done prevents assistants from changing or even seeing marks in these semesters. Choose this option only if all marks in all old semesters are already set.",
default=False,
)
can_delete = False
column_display_all_relations = True
@ -368,7 +393,7 @@ class SemesterView(SecureAdminModelView):
form_args = {
"done": {
"description": "Marking a semester as done does not let assistants work in the semester anymore. This is useful if all marks are already set and prevents assistants from changing or even seeing them after the semester is marked as done. Setting a semester as done sets older semesters as done, too. Only set the semester as done if all marks in the semester and all previous semesters are already set."
"description": "Setting a semester as done prevents assistants from changing or even seeing marks in this semester. Setting a semester as done sets older semesters as done, too. Only set the semester as done if all marks in the semester and all previous semesters are already set."
},
}
@ -383,30 +408,16 @@ class SemesterView(SecureAdminModelView):
def on_model_change(self, form, model, is_created):
if is_created:
current_user.active_semester = model
flash(f"Active semester changed to the new semester {model}!", "warning")
if form.set_old_semesters_done.data:
last_semester = db.session.get(Semester, model.id - 1)
last_semester.set_done(model)
else:
current_user.active_semester = model
flash(f"Active semester changed to the new semester {model}!", "warning")
else:
if model.done:
next_semester = db.session.get(Semester, model.id + 1)
set_next_semester = next_semester is not None
# Set all previous semesters as done
for id in range(1, model.id + 1):
semester = db.session.get(Semester, id)
if semester == model or not semester.done:
semester.done = True
if set_next_semester:
# Set active_semester to next_semester
users_in_semester_done = db.session.execute(
select(User).where(User.active_semester == semester)
).scalars()
for user in users_in_semester_done:
user.active_semester = next_semester
if user == current_user:
flash(f"Active semester changed to the next semester {next_semester}!", "warning")
model.set_done(next_semester)
def programQueryFactory():
@ -529,7 +540,7 @@ class PartRowFilter(FilterEqual):
if not has_request_context():
return tuple()
parts = db.session.execute(select(Part).where(Part.semester == current_user.active_semester)).scalars()
parts = db.session.scalars(select(Part).where(Part.semester == current_user.active_semester))
return tuple((part.id, part.str_without_semester()) for part in parts)
@ -607,7 +618,7 @@ class ProgramRowFilter(FilterEqual):
if not has_request_context():
return tuple()
programs = db.session.execute(select(Program)).scalars()
programs = db.session.scalars(select(Program))
return tuple((program.id, str(program)) for program in programs)
@ -921,7 +932,7 @@ class ExperimentRowFilter(FilterEqual):
if not has_request_context():
return tuple()
activeExperiments = db.session.execute(select(Experiment).where(Experiment.active == True)).scalars()
activeExperiments = db.session.scalars(select(Experiment).where(Experiment.active == True))
return tuple(
(
f"{activeExperiment.number},{activeExperiment.program_id}",
@ -1178,7 +1189,7 @@ class ExperimentMarkView(SecureAdminModelView):
if not has_request_context():
return tuple()
admins = db.session.execute(select(Admin).join(User).where(User.active == True)).scalars()
admins = db.session.scalars(select(Admin).join(User).where(User.active == True))
return tuple((admin.id, str(admin)) for admin in admins)
def apply(self, query, value, alias=None):
@ -1330,8 +1341,15 @@ class ImportView(SecureAdminBaseView):
class ActionsView(SecureAdminBaseView):
class ActionsForm(FlaskForm):
manualUpdateFinalExperimentAndPartMarksSubmit = SubmitField(
label="Manually update final experiment and part marks",
update_final_experiment_and_part_marks = BooleanField(
label="Manually update all final experiment and part marks in the active semester",
)
deactivate_assistants = BooleanField(
label="Deactivate assistants that do not have experiments in a semester that is not set as done",
)
submit = SubmitField(
label="Submit",
render_kw={"class": "btn btn-primary btn-block"},
)
@expose("/", methods=("GET", "POST"))
@ -1339,167 +1357,34 @@ class ActionsView(SecureAdminBaseView):
form = ActionsView.ActionsForm()
if form.validate_on_submit():
if form.manualUpdateFinalExperimentAndPartMarksSubmit.data:
for semesterExperiment in current_user.active_semester.semester_experiments:
semesterExperiment.updateFinalExperimentAndPartMarks()
if form.update_final_experiment_and_part_marks.data:
update_final_experiment_and_part_marks()
if form.deactivate_assistants.data:
deactivate_assistants()
flash("Manually updated all final experiment and part marks", "success")
return redirect(url_for("main.index"))
return redirect(self.url)
return self.render("actions.jinja.html", form=form)
class AnalysisView(SecureAdminBaseView):
class AnalysisForm(FlaskForm):
assistantMarksSubmit = SubmitField(
label="Assistant's marks",
assistant_marks_submit = SubmitField(
label="Active assistant's marks in all semesters",
)
finalPartMarksSubmit = SubmitField(
label="Final part marks",
final_part_marks_submit = SubmitField(
label="Final part marks in active semester",
)
@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.jinja.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.jinja.html",
activeSemesterFinalPartMarksHists=activeSemesterFinalPartMarksHists,
meanFinalPartMarksPlot=meanFinalPartMarksPlot,
)
if form.assistant_marks_submit.data:
return assistant_marks_analysis(self)
elif form.final_part_marks_submit.data:
return final_part_marks_analysis(self)
return self.render("analysis/analysis.jinja.html", form=form)
@ -1511,8 +1396,6 @@ class DocsView(SecureAdminBaseView):
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"))
@ -1532,4 +1415,7 @@ def init_admin_model_views(app):
adminSpace.add_view(AnalysisView(name="Analysis", url="analysis"))
adminSpace.add_view(DocsView(name="Docs", url="docs"))
adminSpace.add_link(MenuLink(name="User settings", url="/user-settings"))
adminSpace.add_link(MenuLink(name="Logout", url="/logout"))
adminSpace.init_app(app)

142
advlabdb/analysis.py Normal file
View file

@ -0,0 +1,142 @@
from base64 import b64encode
from io import BytesIO
import numpy as np
from flask_login import current_user
from matplotlib.figure import Figure
from matplotlib.ticker import MaxNLocator
from sqlalchemy import select
from .models import MAX_MARK, MIN_MARK, Assistant, Semester, User, db
def html_fig(fig):
buf = BytesIO()
fig.savefig(buf, format="png")
return b64encode(buf.getbuffer()).decode("ascii")
def mark_hist(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))
# Only integer ticks
ax.yaxis.set_major_locator(MaxNLocator(integer=True))
ax.set_xlabel("Mark")
N = data.size
title += f"\nN = {N}"
if N > 0:
ax.hist(
data,
bins=np.arange(MAX_MARK + 2) - 0.5,
)
title += f" | mean = {round(np.mean(data), 1)}"
ax.set_title(title)
return html_fig(fig)
def get_experiment_marks(assistant, attr):
data = []
for experiment_mark in assistant.experiment_marks:
mark = getattr(experiment_mark, attr)
if mark is not None:
data.append(mark)
return np.array(data)
def mark_hists(markType, active_assistants):
attr = markType.lower() + "_mark"
mark_type_title_addition = f" | {markType} marks"
marks = [get_experiment_marks(assistant, attr) for assistant in active_assistants]
hists = [
mark_hist(
data=marks[i],
title=str(active_assistants[i]) + mark_type_title_addition,
)
for i in range(len(marks))
]
hists.append(
mark_hist(
data=np.hstack(marks),
title="All" + mark_type_title_addition,
)
)
return hists
def assistant_marks_analysis(cls):
active_assistants = db.session.scalars(select(Assistant).join(User).where(User.active == True)).all()
oral_mark_hists = mark_hists("Oral", active_assistants)
protocol_mark_hists = mark_hists("Protocol", active_assistants)
return cls.render(
"analysis/assistant_marks.jinja.html",
hist_indices=range(len(oral_mark_hists)),
oral_mark_hists=oral_mark_hists,
protocol_mark_hists=protocol_mark_hists,
)
def get_final_part_marks(part):
data = []
for part_student in part.part_students:
mark = part_student.final_part_mark
if mark is not None:
data.append(mark)
return np.array(data)
def final_part_marks_analysis(cls):
parts = current_user.active_semester.parts
active_semester_final_part_marks_hists = [
mark_hist(
data=get_final_part_marks(part),
title=part.str(),
)
for part in parts
]
semesters = db.session.scalars(select(Semester)).all()
mean_final_part_marks = np.array(
[np.mean(np.hstack([get_final_part_marks(part) for part in semester.parts])) for semester in semesters]
)
fig = Figure()
len_mean_final_part_marks = mean_final_part_marks.size
ax = fig.subplots()
x = range(len_mean_final_part_marks)
ax.plot(
x,
mean_final_part_marks,
marker="d",
)
ax.set_xticks(x, [semester.str() for semester in semesters])
ax.set_xlabel("Semester")
ax.set_ylabel("Mean final experiment mark")
ax.set_title("Mean final experiment mark over all semesters")
mean_final_part_mark_plot = html_fig(fig)
return cls.render(
"analysis/final_part_marks.jinja.html",
active_semester_final_part_marks_hists=active_semester_final_part_marks_hists,
mean_final_part_mark_plot=mean_final_part_mark_plot,
)

View file

@ -7,6 +7,7 @@ from flask_admin.model.template import EndpointLinkRowAction
from flask_login import current_user
from flask_security.changeable import admin_change_password
from flask_wtf import FlaskForm
from markupsafe import Markup
from wtforms.validators import DataRequired
from .advlabdb_independent_funs import (
@ -39,6 +40,26 @@ assistantSpace = FlaskAdmin(
class AssistantGroupExperimentView(SecureAssistantModelView):
def is_accessible(self):
if not super().is_accessible():
return False
active_semester = current_user.active_semester
if active_semester.done:
semester_changed = current_user.set_last_semester_as_active()
if not semester_changed:
flash(
Markup(
f"Active semester {active_semester} is set as done. Therefore, you are not allowed to view or edit any marks in this semester. You should change your active semester in <a href='/user-settings'>user settings</a> if possible."
),
"danger",
)
return False
return True
column_display_actions = True
column_list = [
@ -109,7 +130,8 @@ class AssistantGroupExperimentView(SecureAssistantModelView):
if group_experiment not in self.get_query():
reportBadAttempt("Assistant {current_user} tried to edit {group_experiment}")
raise ModelViewException("Unauthorized action!")
self.handle_view_exception(ModelViewException("Unauthorized action!"))
return redirect(self.url)
form, appointments, experiment_marks = assistant_group_experiment_form_factory(current_user, group_experiment)
@ -172,7 +194,7 @@ class AssistantGroupExperimentView(SecureAssistantModelView):
return self.render(
"assistant_group_experiment_form.jinja.html",
form=form,
experiment_label=group_experiment.semester_experiment.experiment.str(),
semester_experiment=group_experiment.semester_experiment,
group_number=group_experiment.group.number,
appointment_fields=appointment_fields,
experiment_mark_zip=zip(
@ -189,14 +211,14 @@ class AssistantUserView(SecureAssistantModelView):
@staticmethod
def semesterQueryFactory():
# Show only last two semesters to assistants
return Semester.query.order_by(Semester.id.desc()).limit(2).where(Semester.done == False)
return Semester.query.order_by(Semester.id.desc()).where(Semester.done == False).limit(2)
active_semester = QuerySelectField(
"Active Semester",
query_factory=semesterQueryFactory,
validators=[DataRequired()],
default=Semester.lastSemester,
description="You should change the active semester to the last semester. Do not forget to click save!",
description="You should change the active semester to the last semester. Do not forget to click save! Only last two semesters are shown that are not set as done.",
)
phone_number, mobile_phone_number, building, room = user_info_fields()
@ -239,10 +261,14 @@ class AssistantDocsView(SecureAssistantBaseView):
def init_assistant_model_views(app):
assistantSpace.init_app(app)
assistantSpace.add_view(AssistantGroupExperimentView(GroupExperiment, url="group_experiment"))
assistantSpace.add_view(AssistantUserView(User, url="user"))
assistantSpace.add_view(AssistantDocsView(name="Docs", url="docs"))
# Don't add to menu
# Has to be placed before assistantSpace.init_app
assistantSpace._views.append(AssistantUserView(User, url="user"))
assistantSpace.add_link(MenuLink(name="User settings", url="/user-settings"))
assistantSpace.add_link(MenuLink(name="Logout", url="/logout"))
assistantSpace.init_app(app)

View file

@ -82,4 +82,3 @@ def set_config(app, data_dir: Path):
app.config["SECURITY_PASSWORD_SALT"] = secrets["SECURITY_PASSWORD_SALT"]
app.config["SECURITY_PASSWORD_LENGTH_MIN"] = settings.getint("SECURITY_PASSWORD_LENGTH_MIN", 15)
app.config["SECURITY_POST_LOGIN_VIEW"] = "/post-login"
# TODO: app.config["SECURITY_LOGIN_USER_TEMPLATE"] =

View file

@ -8,14 +8,7 @@ from sqlalchemy import func, select
from .exceptions import DatabaseException, ModelViewException
from .model_independent_funs import reportBadAttempt
from .models import (
Assistant,
ExperimentMark,
GroupExperiment,
SemesterExperiment,
db,
get_count,
)
from .models import Assistant, ExperimentMark, GroupExperiment, SemesterExperiment, db
def adminViewIsAccessible():
@ -70,25 +63,20 @@ class SecureAssistantIndexView(CustomIndexView):
@expose("/")
def index(self):
active_semester_experiment_mark_ids_stmt = (
select(ExperimentMark.final_experiment_mark)
number_of_missing_final_experiment_marks = db.session.scalar(
select(func.count())
.select_from(ExperimentMark)
.join(GroupExperiment)
.join(SemesterExperiment)
.where(SemesterExperiment.semester == current_user.active_semester)
.join(SemesterExperiment.assistants)
.where(Assistant.user == current_user)
)
number_of_all_experiment_marks = get_count(active_semester_experiment_mark_ids_stmt)
number_of_missing_final_experiment_marks = get_count(
active_semester_experiment_mark_ids_stmt.where(ExperimentMark.final_experiment_mark == None)
.where(ExperimentMark.final_experiment_mark == None)
)
return self.render(
"assistant_index.jinja.html",
number_of_missing_final_experiment_marks=number_of_missing_final_experiment_marks,
number_of_all_experiment_marks=number_of_all_experiment_marks,
)
@ -289,7 +277,8 @@ class SecureAssistantModelView(CustomModelView):
if model not in self.get_query():
reportBadAttempt("An assistant tried to change a model not in his filter!")
raise ModelViewException("Unauthorized action!")
self.handle_view_exception(ModelViewException("Unauthorized action!"))
return redirect(self.url)
def on_model_delete(self, model):
reportBadAttempt("An assistant tried to delete a model!")
@ -308,7 +297,8 @@ class SecureAssistantModelView(CustomModelView):
if model not in self.get_query():
reportBadAttempt("An assistant tried to edit a model not in his filter!")
raise ModelViewException("Unauthorized action!")
self.handle_view_exception(ModelViewException("Unauthorized action!"))
return redirect(self.url)
return super().edit_view()
@ -325,7 +315,8 @@ class SecureAssistantModelView(CustomModelView):
if model not in self.get_query():
reportBadAttempt("An assistant tried to see details of a model not in his filter!")
raise ModelViewException("Unauthorized action!")
self.handle_view_exception(ModelViewException("Unauthorized action!"))
return redirect(self.url)
return super().details_view()
@ -339,7 +330,13 @@ class SecureAssistantModelView(CustomModelView):
return super().get_details_columns()
class SecureAdminBaseView(BaseView):
class CustomBaseView(BaseView):
def inaccessible_callback(self, name, **kwargs):
# Redirect to login page if user doesn't have access
return redirect(url_for("security.login", next=request.url))
class SecureAdminBaseView(CustomBaseView):
def __init__(self, **kwargs):
url = get_url(kwargs)
super().__init__(endpoint="admin_" + url, **kwargs)
@ -348,7 +345,7 @@ class SecureAdminBaseView(BaseView):
return adminViewIsAccessible()
class SecureAssistantBaseView(BaseView):
class SecureAssistantBaseView(CustomBaseView):
def __init__(self, **kwargs):
url = get_url(kwargs)
super().__init__(endpoint="assistant_" + url, **kwargs)

View file

@ -6,7 +6,7 @@ from .model_dependent_funs import selection_mark_field
class AssistantGroupExperimentFormBase(FlaskForm):
submit = SubmitField(label="Save", render_kw={"class": "btn btn-primary btn-block"})
submit = SubmitField(label="Save")
def assistant_group_experiment_form_factory(current_user, group_experiment):
@ -15,7 +15,6 @@ def assistant_group_experiment_form_factory(current_user, group_experiment):
"Note",
default=group_experiment.note,
validators=[Optional()],
render_kw={"class": "form-control"},
)
appointments = group_experiment.appointments
@ -35,7 +34,6 @@ def assistant_group_experiment_form_factory(current_user, group_experiment):
default=appointment.date,
validators=[DataRequired()],
description=description,
render_kw={"class": "form-control"},
),
)
appointment_num += 1

View file

@ -4,7 +4,7 @@ Functions dependent on advlabdb.models.
from functools import cache
from flask import flash, url_for
from flask import flash
from flask_login import current_user
from markupsafe import Markup
from wtforms.fields import BooleanField, IntegerField, SelectField, StringField
@ -13,22 +13,13 @@ from wtforms.validators import DataRequired, NumberRange, Optional
from .models import MAX_MARK, MIN_MARK, Semester
def user_settings_url():
if current_user.has_role("admin"):
role = "admin"
else:
role = "assistant"
return url_for("main.index") + role + "/user/edit/?id=" + str(current_user.id)
def active_semester_str():
active_semester = current_user.active_semester
active_semester_str = str(active_semester)
if active_semester != Semester.lastSemester():
flash(
Markup(
f"You are in the old semester {active_semester_str}! You should change your active semester in <a href='{ user_settings_url() }'>user settings</a>."
f"You are in the old semester {active_semester_str}! You should change your active semester in <a href='/user-settings'>user settings</a>."
),
"warning",
)
@ -63,7 +54,6 @@ def selection_mark_field(mark_type: str, default):
default=default,
choices=choices,
validators=[DataRequired()],
render_kw={"class": "form-control", "style": "width:auto;"},
)

View file

@ -32,12 +32,8 @@ db = SQLAlchemy()
FsModels.set_db_info(db)
def get_count(table):
return db.session.scalar(select(func.count()).select_from(table))
def get_first(table):
return db.session.execute(table.limit(1)).scalars().first()
return db.session.scalars(table.limit(1)).first()
def roundHalfUpToInt(number):
@ -546,7 +542,7 @@ class Semester(db.Model):
if transferParts:
semester.transferPartsFrom(oldSemester)
for experiment in db.session.execute(select(Experiment).where(Experiment.active == True)).scalars():
for experiment in db.session.scalars(select(Experiment).where(Experiment.active == True)):
newSemesterExperiment = SemesterExperiment(experiment=experiment, semester=semester)
if transferAssistants:
@ -571,12 +567,48 @@ class Semester(db.Model):
if limit > 0:
stmt = stmt.limit(limit)
return db.session.execute(stmt).scalars()
return db.session.scalars(stmt)
@staticmethod
def lastSemester():
return Semester.sortedSemestersStartingWithNewest(limit=1).first()
def num_missing_experiment_marks(self):
return db.session.scalar(
select(func.count())
.select_from(ExperimentMark)
.join(GroupExperiment)
.join(SemesterExperiment)
.where(SemesterExperiment.semester == self)
.where(ExperimentMark.final_experiment_mark == None)
)
def set_done(self, next_semester=None):
set_next_semester = next_semester is not None
# Set also all previous semesters as done
for id in range(1, self.id + 1):
semester = db.session.get(Semester, id)
if semester == self or not semester.done:
num_missing_experiment_marks = self.num_missing_experiment_marks()
if num_missing_experiment_marks > 0:
flash(
f"Semester {semester} was set as done, but it has {num_missing_experiment_marks} missing experiment marks!",
"danger",
)
semester.done = True
if set_next_semester:
# Set active_semester to next_semester
users_in_semester_done = db.session.scalars(select(User).where(User.active_semester == semester))
for user in users_in_semester_done:
user.active_semester = next_semester
if user == current_user:
flash(f"Active semester changed to the next semester {next_semester}!", "warning")
class ExperimentMark(db.Model):
# A mark for a student after a specific experiment
@ -683,6 +715,32 @@ class User(db.Model, FsUserMixin):
admin = db.relationship("Admin", back_populates="user", lazy=False, uselist=False)
assistant = db.relationship("Assistant", back_populates="user", lazy=True, uselist=False)
def set_last_semester_as_active(self):
"""
Return True if changed, False otherwise.
"""
last_semester = Semester.lastSemester()
if last_semester.done:
return False
try:
self.active_semester = last_semester
db.session.commit()
except Exception as ex:
flash(str(ex), "error")
db.session.rollback()
return False
else:
flash(
f"Active semester changed to {last_semester} because your last active semester was set as done!",
"warning",
)
return True
def str(self):
return f"{self.first_name} {self.last_name}"

View file

@ -1,9 +1,8 @@
from flask import Blueprint, flash, redirect, request, url_for
from flask import Blueprint, redirect, url_for
from flask_login import current_user
from flask_security.decorators import auth_required
from .model_dependent_funs import active_semester_str
from .models import Semester, db
bp = Blueprint("main", __name__, root_path="/", template_folder="templates")
@ -36,22 +35,7 @@ def post_login():
current_active_semester = current_user.active_semester
if current_active_semester.done:
last_semester = Semester.lastSemester()
if not last_semester.done:
try:
current_user.active_semester = last_semester
db.session.commit()
except Exception as ex:
flash(str(ex), "error")
db.session.rollback()
else:
flash(
f"Active semester changed to {last_semester} because the semester {current_active_semester} was marked as done!",
"warning",
)
current_user.set_last_semester_as_active()
if current_user.has_role("admin"):
endpoint_base = "admin"
@ -64,3 +48,16 @@ def post_login():
url = url_for(endpoint_base + ".index")
return redirect(url)
@bp.route("/user-settings")
@auth_required()
def user_settings():
if current_user.has_role("admin"):
role = "admin"
else:
role = "assistant"
url = url_for("main.index") + role + "/user/edit/?id=" + str(current_user.id)
return redirect(url)

File diff suppressed because one or more lines are too long

View file

@ -7,8 +7,19 @@
<hr>
<form method="POST">
{{ form.csrf_token }}
{{ form.manualUpdateFinalExperimentAndPartMarksSubmit }}
{% for field in form %}
{% if field.widget.input_type == "checkbox" %}
<div class="form-check">
{{ field(class="form-check-input") }}
<label class="form-check-label" for="{{ field.id }}">
{{ field.label }}
</label>
</div>
{% else %}
{{ field() }}
{% endif %}
{% endfor %}
</form>
{{ footer|safe }}

View file

@ -1,34 +0,0 @@
{% import 'admin/static.html' as admin_static with context %}
{% macro dropdown(actions, btn_class='nav-link dropdown-toggle') -%}
<a class="{{ btn_class }}" data-toggle="dropdown" href="javascript:void(0)" role="button" aria-haspopup="true"
aria-expanded="false">{{ _gettext('With selected') }}<b class="caret"></b></a>
<div class="dropdown-menu">
{% for p in actions %}
<a class="dropdown-item" href="javascript:void(0)"
onclick="return modelActions.execute('{{ p[0] }}');">{{ _gettext(p[1]) }}</a>
{% endfor %}
</div>
{% endmacro %}
{% macro form(actions, url) %}
{% if actions %}
<form id="action_form" action="{{ url }}" method="POST" style="display: none">
{% if action_form.csrf_token %}
{{ action_form.csrf_token }}
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
{{ action_form.url(value=return_url) }}
{{ action_form.action() }}
</form>
{% endif %}
{% endmacro %}
{% macro script(message, actions, actions_confirmation) %}
{% if actions %}
<div id="actions-confirmation-data" style="display:none;">{{ actions_confirmation|tojson|safe }}</div>
<div id="message-data" style="display:none;">{{ message|tojson|safe }}</div>
<script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
{% endif %}
{% endmacro %}

View file

@ -1,101 +0,0 @@
{% import 'admin/layout.html' as layout with context -%}
{% import 'admin/static.html' as admin_static with context %}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% if admin_view.category %}{{ admin_view.category }} - {% endif %}{{ admin_view.name }} - {{ admin_view.admin.name }}{% endblock %}</title>
{% block head_meta %}
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">
{% endblock %}
{% block head_css %}
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/swatch/{swatch}/bootstrap.min.css'.format(swatch=config.get('FLASK_ADMIN_SWATCH', 'default')), v='4.2.1') }}"
rel="stylesheet">
{% if config.get('FLASK_ADMIN_SWATCH', 'default') == 'default' %}
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/bootstrap.min.css', v='4.2.1') }}" rel="stylesheet">
{% endif %}
<link href="{{ admin_static.url(filename='admin/css/bootstrap4/admin.css', v='1.1.1') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='bootstrap/bootstrap4/css/font-awesome.min.css', v='4.7.0') }}" rel="stylesheet">
{% if admin_view.extra_css %}
{% for css_url in admin_view.extra_css %}
<link href="{{ css_url }}" rel="stylesheet">
{% endfor %}
{% endif %}
<style>
.hide {
display: none;
}
</style>
{% endblock %}
{% block head %}
{% endblock %}
{% block head_tail %}
{% endblock %}
</head>
<body>
{% block page_body %}
<div class="container{% if config.get('FLASK_ADMIN_FLUID_LAYOUT', False) %}-fluid{% endif %}">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-2" role="navigation">
<!-- Brand and toggle get grouped for better mobile display -->
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#admin-navbar-collapse"
aria-controls="admin-navbar-collapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<!-- navbar content -->
<div class="collapse navbar-collapse" id="admin-navbar-collapse">
{% block brand %}
<a class="navbar-brand" href="{{ admin_view.admin.url }}">{{ admin_view.admin.name }}</a>
{% endblock %}
{% block main_menu %}
<ul class="nav navbar-nav mr-auto">
{{ layout.menu() }}
</ul>
{% endblock %}
{% block menu_links %}
<ul class="nav navbar-nav navbar-right">
{{ layout.menu_links() }}
</ul>
{% endblock %}
{% block access_control %}
{% endblock %}
</div>
</nav>
{% block messages %}
{{ layout.messages() }}
{% endblock %}
{# store the jinja2 context for form_rules rendering logic #}
{% set render_ctx = h.resolve_ctx() %}
{% block body %}{% endblock %}
</div>
{% endblock %}
{% block tail_js %}
<script src="{{ admin_static.url(filename='vendor/jquery.min.js', v='3.5.1') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/popper.min.js') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='bootstrap/bootstrap4/js/bootstrap.min.js', v='4.2.1') }}"
type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/moment.min.js', v='2.9.0') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/bootstrap4/util.js', v='4.3.1') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/bootstrap4/dropdown.js', v='4.3.1') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/select2/select2.min.js', v='4.2.1') }}"
type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/multi-level-dropdowns-bootstrap/bootstrap4-dropdown-ml-hack.js') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='admin/js/helpers.js', v='1.0.0') }}" type="text/javascript"></script>
{% if admin_view.extra_js %}
{% for js_url in admin_view.extra_js %}
<script src="{{ js_url }}" type="text/javascript"></script>
{% endfor %}
{% endif %}
{% endblock %}
{% block tail %}
{% endblock %}
</body>
</html>

View file

@ -1,9 +0,0 @@
{% extends 'admin/master.html' %}
{% import 'admin/lib.html' as lib with context %}
{% block body %}
{% block header %}<h3>{{ header_text }}</h3>{% endblock %}
{% block fa_form %}
{{ lib.render_form(form, dir_url) }}
{% endblock %}
{% endblock %}

View file

@ -1,191 +0,0 @@
{% extends 'admin/master.html' %}
{% import 'admin/lib.html' as lib with context %}
{% import 'admin/actions.html' as actionslib with context %}
{% block body %}
{% block breadcrums %}
<nav area-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{{ get_dir_url('.index_view', path=None) }}">{{ _gettext('Root') }}</a>
</li>
{% for name, path in breadcrumbs[:-1] %}
<li class="breadcrumb-item">
<a href="{{ get_dir_url('.index_view', path=path) }}">{{ name }}</a>
</li>
{% endfor %}
{% if breadcrumbs %}
<li class="breadcrumb-item">
<a href="{{ get_dir_url('.index_view', path=breadcrumbs[-1][1]) }}">{{ breadcrumbs[-1][0] }}</a>
</li>
{% endif %}
</ol>
</nav>
{% endblock %}
{% block file_list_table %}
<div class="table-responsive">
<table class="table table-striped table-bordered model-list">
<thead>
<tr>
{% block list_header scoped %}
{% if actions %}
<th class="list-checkbox-column">
<input type="checkbox" name="rowtoggle" class="action-rowtoggle" />
</th>
{% endif %}
<th class="">&nbsp;</th>
{% for column in admin_view.column_list %}
<th>
{% if admin_view.is_column_sortable(column) %}
{% if sort_column == column %}
<a href="{{ sort_url(column, dir_path, True) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}">
{{ admin_view.column_label(column) }}
{% if sort_desc %}
<span class="fa fa-chevron-up glyphicon glyphicon-chevron-up"></span>
{% else %}
<span class="fa fa-chevron-down glyphicon glyphicon-chevron-down"></span>
{% endif %}
</a>
{% else %}
<a href="{{ sort_url(column, dir_path) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}">{{ admin_view.column_label(column) }}</a>
{% endif %}
{% else %}
{{ _gettext(admin_view.column_label(column)) }}
{% endif %}
</th>
{% endfor %}
{% endblock %}
</tr>
</thead>
{% for name, path, is_dir, size, date in items %}
<tr>
{% block list_row scoped %}
{% if actions %}
<td>
{% if not is_dir %}
<input type="checkbox" name="rowid" class="action-checkbox" value="{{ path }}" />
{% endif %}
</td>
{% endif %}
<td>
{% block list_row_actions scoped %}
{% if admin_view.can_rename and path and name != '..' %}
{%- if admin_view.rename_modal -%}
{{ lib.add_modal_button(url=get_url('.rename', path=path, modal=True),
title=_gettext('Rename File'),
content='<i class="fa fa-pencil glyphicon glyphicon-pencil"></i>') }}
{% else %}
<a class="icon" href="{{ get_url('.rename', path=path) }}" title="{{ _gettext('Rename File') }}">
<i class="fa fa-pencil glyphicon glyphicon-pencil"></i>
</a>
{%- endif -%}
{% endif %}
{%- if admin_view.can_delete and path -%}
{% if is_dir %}
{% if name != '..' and admin_view.can_delete_dirs %}
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
{{ delete_form.path(value=path) }}
{{ delete_form.csrf_token }}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\' recursively?', name=name) }}')">
<i class="fa fa-times glyphicon glyphicon-remove"></i>
</button>
</form>
{% endif %}
{% else %}
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
{{ delete_form.path(value=path) }}
{{ delete_form.csrf_token }}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\'?', name=name) }}')">
<i class="fa fa-trash glyphicon glyphicon-trash"></i>
</button>
</form>
{% endif %}
{%- endif -%}
{% endblock %}
</td>
{% if is_dir %}
<td colspan="2">
<a href="{{ get_dir_url('.index_view', path)|safe }}">
<i class="fa fa-folder-o glyphicon glyphicon-folder-close"></i> <span>{{ name }}</span>
</a>
</td>
{% else %}
<td>
{% if admin_view.can_download %}
{%- if admin_view.edit_modal and admin_view.is_file_editable(path) -%}
{{ lib.add_modal_button(url=get_file_url(path, modal=True)|safe,
btn_class='', content=name) }}
{% else %}
<a href="{{ get_file_url(path)|safe }}">{{ name }}</a>
{%- endif -%}
{% else %}
{{ name }}
{% endif %}
</td>
{% if admin_view.is_column_visible('size') %}
<td>
{{ size|filesizeformat }}
</td>
{% endif %}
{% endif %}
{% if admin_view.is_column_visible('date') %}
<td>
{{ timestamp_format(date) }}
</td>
{% endif %}
{% endblock %}
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
{% block toolbar %}
<div class="btn-toolbar">
{% if admin_view.can_upload %}
<div class="btn-group">
{%- if admin_view.upload_modal -%}
{{ lib.add_modal_button(url=get_dir_url('.upload', path=dir_path, modal=True),
btn_class="btn btn-secondary",
content=_gettext('Upload File')) }}
{% else %}
<a class="btn btn-secondary" href="{{ get_dir_url('.upload', path=dir_path) }}">{{ _gettext('Upload File') }}</a>
{%- endif -%}
</div>
{% endif %}
{% if admin_view.can_mkdir %}
<div class="mx-1">
{%- if admin_view.mkdir_modal -%}
{{ lib.add_modal_button(url=get_dir_url('.mkdir', path=dir_path, modal=True),
btn_class="btn btn-secondary",
content=_gettext('Create Directory')) }}
{% else %}
<a class="btn btn-secondary" href="{{ get_dir_url('.mkdir', path=dir_path) }}">{{ _gettext('Create Directory') }}</a>
{%- endif -%}
</div>
{% endif %}
{% if actions %}
<div class="mx-1">
{{ actionslib.dropdown(actions, 'dropdown-toggle btn btn-secondary') }}
</div>
{% endif %}
</div>
{% endblock %}
{% block actions %}
{{ actionslib.form(actions, get_url('.action_view')) }}
{% endblock %}
{%- if admin_view.rename_modal or admin_view.mkdir_modal
or admin_view.upload_modal or admin_view.edit_modal -%}
{{ lib.add_modal_window() }}
{%- endif -%}
{% endblock %}
{% block tail %}
{{ super() }}
{{ actionslib.script(_gettext('Please select at least one file.'),
actions,
actions_confirmation) }}
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
{% endblock %}

View file

@ -1,19 +0,0 @@
{% import 'admin/static.html' as admin_static with context %}
{% import 'admin/lib.html' as lib with context %}
{% block body %}
{# content added to modal-content #}
<div class="modal-header">
{% block header %}<h3>{{ header_text }}</h3>{% endblock %}
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
{% block fa_form %}
{{ lib.render_form(form, dir_url, action=request.url, is_modal=True) }}
{% endblock %}
</div>
{% endblock %}
{% block tail %}
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
{% endblock %}

View file

@ -1,4 +0,0 @@
{% extends 'admin/master.html' %}
{% block body %}
{% endblock %}

View file

@ -1,107 +0,0 @@
{% macro menu_icon(item) -%}
{% set icon_type = item.get_icon_type() %}
{%- if icon_type %}
{% set icon_value = item.get_icon_value() %}
{% if icon_type == 'glyph' %}
<i class="glyphicon {{ icon_value }}"></i>
{% elif icon_type == 'fa' %}
<i class="fa {{ icon_value }}"></i>
{% elif icon_type == 'image' %}
<img src="{{ url_for('static', filename=icon_value) }}" alt="menu image">
{% elif icon_type == 'image-url' %}
<img src="{{ icon_value }}" alt="menu image">
{% endif %}
{% endif %}
{%- endmacro %}
{% macro menu(menu_root=None) %}
{% set is_main_nav = menu_root == None %}
{% if menu_root is none %}{% set menu_root = admin_view.admin.menu() %}{% endif %}
{%- for item in menu_root %}
{%- if item.is_category() -%}
{% set children = item.get_children() %}
{%- if children %}
{% set class_name = item.get_class_name() or '' %}
{%- if item.is_active(admin_view) %}
<li class="active dropdown{% if class_name %} {{ class_name }}{% endif %}">
{% else -%}
<li class="dropdown{% if class_name %} {{ class_name }}{% endif %}">
{%- endif %}
<a class="dropdown-toggle {% if is_main_nav %}nav-link{% else %}dropdown-item{% endif %}" data-toggle="dropdown" href="javascript:void(0)">
{% if item.class_name %}<span class="{{ item.class_name }}"></span> {% endif %}
{{ menu_icon(item) }}{{ item.name }}
{%- if 'dropdown-submenu' in class_name -%}
<i class="glyphicon glyphicon-chevron-right small"></i>
{%- else -%}
<i class="glyphicon glyphicon-chevron-down small"></i>
{%- endif -%}
</a>
<ul class="dropdown-menu">
{%- for child in children -%}
{%- if child.is_category() -%}
{{ menu(menu_root=[child]) }}
{% else %}
{% set class_name = child.get_class_name() %}
<li{% if class_name %} class="{{ class_name }}"{% endif %}>
{%- if child.is_active(admin_view) %}
<a class="dropdown-item active" href="{{ child.get_url() }}"{% if child.target %}
target="{{ child.target }}"{% endif %}>
{{ menu_icon(child) }}{{ child.name }}</a>
{% else %}
<a class="dropdown-item" href="{{ child.get_url() }}"{% if child.target %}
target="{{ child.target }}"{% endif %}>
{{ menu_icon(child) }}{{ child.name }}</a>
{%- endif %}
</li>
{%- endif %}
{%- endfor %}
</ul>
</li>
{% endif %}
{%- else %}
{%- if item.is_accessible() and item.is_visible() -%}
{% set class_name = item.get_class_name() %}
{%- if item.is_active(admin_view) %}
<li class="active{% if class_name %} {{ class_name }}{% endif %}">
{%- else %}
<li{% if class_name %} class="{{ class_name }}"{% endif %}>
{%- endif %}
<a class="nav-link" href="{{ item.get_url() }}"{% if item.target %} target="{{ item.target }}"{% endif %}>
{{ menu_icon(item) }}{{ item.name }}</a>
</li>
{%- endif -%}
{% endif -%}
{% endfor %}
{% endmacro %}
{% macro menu_links(links=None) %}
{% if links is none %}{% set links = admin_view.admin.menu_links() %}{% endif %}
{% for item in links %}
{% set class_name = item.get_class_name() %}
{% if item.is_accessible() and item.is_visible() %}
<li{% if class_name %} class="{{ class_name }}"{% endif %}>
<a class="nav-link" href="{{ item.get_url() }}"{% if item.target %} target="{{ item.target }}"{% endif %}>
{{ menu_icon(item) }}{{ item.name }}</a>
</li>
{% endif %}
{% endfor %}
{% endmacro %}
{% macro messages() %}
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
{% for category, m in messages %}
{% if category %}
{# alert-error changed to alert-danger in bootstrap 3, mapping is for backwards compatibility #}
{% set mapping = {'message': 'info', 'error': 'danger'} %}
<div class="alert alert-{{ mapping.get(category, category) }} alert-dismissable">
{% else %}
<div class="alert alert-dismissable">
{% endif %}
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
{{ m }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% endmacro %}

View file

@ -1,292 +0,0 @@
{% import 'admin/static.html' as admin_static with context %}
{# ---------------------- Pager -------------------------- #}
{% macro pager(page, pages, generator) -%}
{% if pages > 1 %}
<ul class="pagination">
{% set min = page - 3 %}
{% set max = page + 3 + 1 %}
{% if min < 0 %}
{% set max = max - min %}
{% endif %}
{% if max >= pages %}
{% set min = min - max + pages %}
{% endif %}
{% if min < 0 %}
{% set min = 0 %}
{% endif %}
{% if max >= pages %}
{% set max = pages %}
{% endif %}
{% if min > 0 %}
<li class="page-item">
<a class="page-link" href="{{ generator(0) }}">&laquo;</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="javascript:void(0)">&laquo;</a>
</li>
{% endif %}
{% if page > 0 %}
<li class="page-item">
<a class="page-link" href="{{ generator(page-1) }}">&lt;</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="javascript:void(0)">&lt;</a>
</li>
{% endif %}
{% for p in range(min, max) %}
{% if page == p %}
<li class="page-item active">
<a class="page-link" href="javascript:void(0)">{{ p + 1 }}</a>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ generator(p) }}">{{ p + 1 }}</a>
</li>
{% endif %}
{% endfor %}
{% if page + 1 < pages %}
<li class="page-item">
<a class="page-link" href="{{ generator(page + 1) }}">&gt;</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="javascript:void(0)">&gt;</a>
</li>
{% endif %}
{% if max < pages %}
<li class="page-item">
<a class="page-link" href="{{ generator(pages - 1) }}">&raquo;</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="javascript:void(0)">&raquo;</a>
</li>
{% endif %}
</ul>
{% endif %}
{%- endmacro %}
{% macro simple_pager(page, have_next, generator) -%}
<ul class="pagination">
{% if page > 0 %}
<li class="page-item">
<a href="{{ generator(page - 1) }}">&lt;</a>
</li>
{% else %}
<li class="page-item disabled">
<a href="{{ generator(0) }}">&lt;</a>
</li>
{% endif %}
{% if have_next %}
<li class="page-item">
<a href="{{ generator(page + 1) }}">&gt;</a>
</li>
{% else %}
<li class="page-item disabled">
<a href="{{ generator(page) }}">&gt;</a>
</li>
{% endif %}
</ul>
{%- endmacro %}
{# ---------------------- Modal Window ------------------- #}
{% macro add_modal_window(modal_window_id='fa_modal_window', modal_label_id='fa_modal_label') %}
<div class="modal fade" id="{{ modal_window_id }}" tabindex="-1" role="dialog" aria-labelledby="{{ modal_label_id }}">
<div class="modal-dialog modal-xl" role="document">
{# bootstrap version > 3.1.0 required for this to work #}
<div class="modal-content">
</div>
</div>
</div>
{% endmacro %}
{% macro add_modal_button(url='', title='', content='', modal_window_id='fa_modal_window', btn_class='icon') %}
<a class="{{ btn_class }}" data-target="#{{ modal_window_id }}" title="{{ title }}" href="{{ url }}" data-toggle="modal">
{{ content|safe }}
</a>
{% endmacro %}
{# ---------------------- Forms -------------------------- #}
{% macro render_field(form, field, kwargs={}, caller=None) %}
{% set direct_error = h.is_field_error(field.errors) %}
{% set prepend = kwargs.pop('prepend', None) %}
{% set append = kwargs.pop('append', None) %}
<div class="form-group {{ kwargs.get('column_class', '') }}">
<label for="{{ field.id }}" class="control-label" {% if field.widget.input_type == 'checkbox' %}style="display: block"{% endif %}>{{ field.label.text }}
{% if h.is_required_form_field(field) %}
<strong style="color: red">&#42;</strong>
{%- else -%}
&nbsp;
{%- endif %}
</label>
{% if prepend or append %}
<div class="input-group">
{%- if prepend -%}
<div class="input-group-prepend">
{{ prepend }}
</div>
{%- endif -%}
{% endif %}
{% if field.widget.input_type == 'checkbox' %}
{% set _class = kwargs.setdefault('class', 'form-control-lg') %}
{% elif field.widget.input_type == 'file' %}
{% set _class = kwargs.setdefault('class', 'form-control-file') %}
{% else %}
{% set _class = kwargs.setdefault('class', 'form-control') %}
{% endif %}
{%- if direct_error %} {% set _ = kwargs.update({'class': kwargs['class'] ~ ' is-invalid'}) %} {% endif -%}
{{ field(**kwargs) | safe }}
{%- if append -%}
<div class="input-group-append">
{{ append }}
</div>
{%- endif -%}
{% if direct_error %}
<div class="invalid-feedback">
<ul class="help-block">
{% for e in field.errors if e is string %}
<li>{{ e }}</li>
{% endfor %}
</ul>
</div>
{% elif field.description %}
<div class="help-block">{{ field.description|safe }}</div>
{% endif %}
{% if prepend or append %}
</div>
{% endif %}
{% if caller %}
{{ caller(form, field, direct_error, kwargs) }}
{% endif %}
</div>
{% endmacro %}
{% macro render_header(form, text) %}
<h3>{{ text }}</h3>
{% endmacro %}
{% macro render_form_fields(form, form_opts=None) %}
{% if form.hidden_tag is defined %}
{{ form.hidden_tag() }}
{% else %}
{% if csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
{% for f in form if f.widget.input_type == 'hidden' %}
{{ f }}
{% endfor %}
{% endif %}
{% if form_opts and form_opts.form_rules %}
{% for r in form_opts.form_rules %}
{{ r(form, form_opts=form_opts) }}
{% endfor %}
{% else %}
{% for f in form if f.widget.input_type != 'hidden' %}
{% if form_opts %}
{% set kwargs = form_opts.widget_args.get(f.short_name, {}) %}
{% else %}
{% set kwargs = {} %}
{% endif %}
{{ render_field(form, f, kwargs) }}
{% endfor %}
{% endif %}
{% endmacro %}
{% macro form_tag(form=None, action=None) %}
<form action="{{ action or '' }}" method="POST" role="form" class="admin-form" enctype="multipart/form-data">
<fieldset>
{{ caller() }}
</fieldset>
</form>
{% endmacro %}
{% macro render_form_buttons(cancel_url, extra=None, is_modal=False) %}
{% if is_modal %}
<input type="submit" class="btn btn-primary" value="{{ _gettext('Save') }}" />
{% if extra %}
{{ extra }}
{% endif %}
{% if cancel_url %}
<a href="{{ cancel_url }}" class="btn btn-danger" role="button" {% if is_modal %}data-dismiss="modal"{% endif %}>{{ _gettext('Cancel') }}</a>
{% endif %}
{% else %}
<hr>
<div class="form-group">
<div class="col-md-offset-2 col-md-10 submit-row">
<input type="submit" class="btn btn-primary" value="{{ _gettext('Save') }}" />
{% if extra %}
{{ extra }}
{% endif %}
{% if cancel_url %}
<a href="{{ cancel_url }}" class="btn btn-danger" role="button" {% if is_modal %}data-dismiss="modal"{% endif %}>{{ _gettext('Cancel') }}</a>
{% endif %}
</div>
</div>
{% endif %}
{% endmacro %}
{% macro render_form(form, cancel_url, extra=None, form_opts=None, action=None, is_modal=False) -%}
{% call form_tag(action=action) %}
{{ render_form_fields(form, form_opts=form_opts) }}
{{ render_form_buttons(cancel_url, extra, is_modal) }}
{% endcall %}
{% endmacro %}
{% macro form_css() %}
<link href="{{ admin_static.url(filename='vendor/select2/select2.css', v='4.2.1') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='vendor/select2/select2-bootstrap4.css', v='1.4.6') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker-bs4.css', v='1.3.22') }}" rel="stylesheet">
{% if config.MAPBOX_MAP_ID %}
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.css', v='1.0.2') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.css', v='0.4.6') }}" rel="stylesheet">
{% endif %}
{% if editable_columns %}
<link href="{{ admin_static.url(filename='vendor/x-editable/css/bootstrap4-editable.css', v='1.5.1.1') }}" rel="stylesheet">
{% endif %}
{% endmacro %}
{% macro form_js() %}
{% if config.MAPBOX_MAP_ID %}
<script>
window.MAPBOX_MAP_ID = "{{ config.MAPBOX_MAP_ID }}";
{% if config.MAPBOX_ACCESS_TOKEN %}
window.MAPBOX_ACCESS_TOKEN = "{{ config.MAPBOX_ACCESS_TOKEN }}";
{% endif %}
{% if config.DEFAULT_CENTER_LAT and config.DEFAULT_CENTER_LONG %}
window.DEFAULT_CENTER_LAT = "{{ config.DEFAULT_CENTER_LAT }}";
window.DEFAULT_CENTER_LONG = "{{ config.DEFAULT_CENTER_LONG }}";
{% endif %}
</script>
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='1.0.2') }}"></script>
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.4.6') }}"></script>
{% if config.MAPBOX_SEARCH %}
<script>
window.MAPBOX_SEARCH = "{{ config.MAPBOX_SEARCH }}";
</script>
<script src="https://maps.googleapis.com/maps/api/js?v=3&libraries=places&key={{ config.get('GOOGLE_MAPS_API_KEY') }}"></script>
{% endif %}
{% endif %}
<script src="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker.js', v='1.3.22') }}"></script>
{% if editable_columns %}
<script src="{{ admin_static.url(filename='vendor/x-editable/js/bootstrap4-editable.min.js', v='1.5.1.1') }}"></script>
{% endif %}
<script src="{{ admin_static.url(filename='admin/js/form.js', v='1.0.1') }}"></script>
{% endmacro %}
{% macro extra() %}
{% if admin_view.can_create %}
<input name="_add_another" type="submit" class="btn btn-secondary" value="{{ _gettext('Save and Add Another') }}" />
{% endif %}
{% if admin_view.can_edit %}
<input name="_continue_editing" type="submit" class="btn btn-secondary" value="{{ _gettext('Save and Continue Editing') }}" />
{% endif %}
{% endmacro %}

View file

@ -1 +0,0 @@
{% extends admin_base_template %}

View file

@ -1,30 +0,0 @@
{% extends 'admin/master.html' %}
{% import 'admin/lib.html' as lib with context %}
{% from 'admin/lib.html' import extra with context %} {# backward compatible #}
{% block head %}
{{ super() }}
{{ lib.form_css() }}
{% endblock %}
{% block body %}
{% block navlinks %}
<ul class="nav nav-tabs">
<li class="nav-item">
<a href="{{ return_url }}" class="nav-link">{{ _gettext('List') }}</a>
</li>
<li class="nav-item">
<a href="javascript:void(0)" class="nav-link active">{{ _gettext('Create') }}</a>
</li>
</ul>
{% endblock %}
{% block create_form %}
{{ lib.render_form(form, return_url, extra(), form_opts) }}
{% endblock %}
{% endblock %}
{% block tail %}
{{ super() }}
{{ lib.form_js() }}
{% endblock %}

View file

@ -1,52 +0,0 @@
{% extends 'admin/master.html' %}
{% import 'admin/lib.html' as lib with context %}
{% block body %}
{% block navlinks %}
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" href="{{ return_url }}">{{ _gettext('List') }}</a>
</li>
{%- if admin_view.can_create -%}
<li class="nav-item">
<a class="nav-link" href="{{ get_url('.create_view', url=return_url) }}">{{ _gettext('Create') }}</a>
</li>
{%- endif -%}
{%- if admin_view.can_edit -%}
<li class="nav-item">
<a class="nav-link" href="{{ get_url('.edit_view', id=request.args.get('id'), url=return_url) }}">{{ _gettext('Edit') }}</a>
</li>
{%- endif -%}
<li class="nav-item">
<a class="nav-link active disabled" href="javascript:void(0)">{{ _gettext('Details') }}</a>
</li>
</ul>
{% endblock %}
{% block details_search %}
<div class="form-inline fa_filter_container col-lg-6">
<label for="fa_filter">{{ _gettext('Filter') }}</label>
<input id="fa_filter" type="text" class="ml-3 form-control">
</div>
{% endblock %}
{% block details_table %}
<table class="table table-hover table-bordered searchable">
{% for c, name in details_columns %}
<tr>
<td>
<b>{{ name }}</b>
</td>
<td>
{{ get_value(model, c) }}
</td>
</tr>
{% endfor %}
</table>
{% endblock %}
{% endblock %}
{% block tail %}
{{ super() }}
<script src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
{% endblock %}

View file

@ -1,40 +0,0 @@
{% extends 'admin/master.html' %}
{% import 'admin/lib.html' as lib with context %}
{% from 'admin/lib.html' import extra with context %} {# backward compatible #}
{% block head %}
{{ super() }}
{{ lib.form_css() }}
{% endblock %}
{% block body %}
{% block navlinks %}
<ul class="nav nav-tabs">
<li class="nav-item">
<a href="{{ return_url }}" class="nav-link">{{ _gettext('List') }}</a>
</li>
{%- if admin_view.can_create -%}
<li class="nav-item">
<a href="{{ get_url('.create_view', url=return_url) }}" class="nav-link">{{ _gettext('Create') }}</a>
</li>
{%- endif -%}
<li class="nav-item">
<a href="javascript:void(0)" class="nav-link active">{{ _gettext('Edit') }}</a>
</li>
{%- if admin_view.can_view_details -%}
<li class="nav-item">
<a class="nav-link" href="{{ get_url('.details_view', id=request.args.get('id'), url=return_url) }}">{{ _gettext('Details') }}</a>
</li>
{%- endif -%}
</ul>
{% endblock %}
{% block edit_form %}
{{ lib.render_form(form, return_url, extra(), form_opts) }}
{% endblock %}
{% endblock %}
{% block tail %}
{{ super() }}
{{ lib.form_js() }}
{% endblock %}

View file

@ -1,15 +0,0 @@
{% import 'admin/model/inline_list_base.html' as base with context %}
{% macro render_field(field) %}
{{ field }}
{% if h.is_field_error(field.errors) %}
<ul class="help-block input-errors">
{% for e in field.errors if e is string %}
<li>{{ e }}</li>
{% endfor %}
</ul>
{% endif %}
{% endmacro %}
{{ base.render_inline_fields(field, template, render_field, check) }}

View file

@ -1,4 +0,0 @@
{% import 'admin/lib.html' as lib with context %}
<div class="inline-form-field">
{{ lib.render_form_fields(field.form, form_opts=form_opts) }}
</div>

View file

@ -1,45 +0,0 @@
{% macro render_inline_fields(field, template, render, check=None) %}
<div class="inline-field" id="{{ field.id }}">
{# existing inline form fields #}
<div class="inline-field-list">
{% for subfield in field %}
<div id="{{ subfield.id }}" class="inline-field card card-body bg-light">
{%- if not check or check(subfield) %}
<legend>
<small>
{{ field.label.text }} #{{ loop.index }}
<div class="pull-right">
{% if subfield.get_pk and subfield.get_pk() %}
<input type="checkbox" name="del-{{ subfield.id }}" id="del-{{ subfield.id }}" />
<label for="del-{{ subfield.id }}" style="display: inline">{{ _gettext('Delete?') }}</label>
{% else %}
<a href="javascript:void(0)" value="{{ _gettext('Are you sure you want to delete this record?') }}" class="inline-remove-field"><i class="fa fa-times glyphicon glyphicon-remove"></i></a>
{% endif %}
</div>
</small>
</legend>
<div class='clearfix'></div>
{%- endif -%}
{{ render(subfield) }}
</div>
{% endfor %}
</div>
{# template for new inline form fields #}
<div class="inline-field-template hide">
{% filter forceescape %}
<div class="inline-field card card-body bg-light">
<legend>
<small>{{ _gettext('New') }} {{ field.label.text }}</small>
<div class="pull-right">
<a href="javascript:void(0)" value="{{ _gettext('Are you sure you want to delete this record?') }}" class="inline-remove-field"><span class="fa fa-times glyphicon glyphicon-remove"></span></a>
</div>
</legend>
<div class='clearfix'></div>
{{ render(template) }}
</div>
{% endfilter %}
</div>
<a id="{{ field.id }}-button" href="javascript:void(0)" class="btn btn-primary" role="button" onclick="faForm.addInlineField(this, '{{ field.id }}');">{{ _gettext('Add') }} {{ field.label.text }}</a>
</div>
{% endmacro %}

View file

@ -1,106 +0,0 @@
{% macro filter_options(btn_class='dropdown-toggle') %}
<a class="nav-link {{ btn_class }}" data-toggle="dropdown" href="javascript:void(0)">{{ _gettext('Add Filter') }}<b class="caret"></b></a>
<div class="dropdown-menu field-filters">
{% for k in filter_groups %}
<a href="javascript:void(0)" class="dropdown-item filter" onclick="return false;">{{ k }}</a>
{% endfor %}
</div>
{% endmacro %}
{% macro export_options(btn_class='dropdown-toggle') %}
{% if admin_view.export_types|length > 1 %}
<li class="dropdown">
<a class="nav-link {{ btn_class }}" data-toggle="dropdown" href="javascript:void(0)" role="button"
aria-haspopup="true" aria-expanded="false">{{ _gettext('Export') }}<b class="caret"></b></a>
<div class="dropdown-menu">
{% for export_type in admin_view.export_types %}
<a class="dropdown-item"
href="{{ get_url('.export', export_type=export_type, **request.args) }}"
title="{{ _gettext('Export') }}">{{ _gettext('Export') + ' ' + export_type|upper }}</a>
{% endfor %}
</div>
</li>
{% else %}
<li>
<a class="nav-link" href="{{ get_url('.export', export_type=admin_view.export_types[0], **request.args) }}"
title="{{ _gettext('Export') }}">{{ _gettext('Export') }}</a>
</li>
{% endif %}
{% endmacro %}
{% macro filter_form() %}
<form id="filter_form" method="GET" action="{{ return_url }}">
{% if sort_column is not none %}
<input type="hidden" name="sort" value="{{ sort_column }}">
{% endif %}
{% if sort_desc %}
<input type="hidden" name="desc" value="{{ sort_desc }}">
{% endif %}
{% if search %}
<input type="hidden" name="search" value="{{ search }}">
{% endif %}
{% if page_size != default_page_size %}
<input type="hidden" name="page_size" value="{{ page_size }}">
{% endif %}
<div class="pull-right">
<button type="submit" class="btn btn-primary" style="display: none">{{ _gettext('Apply') }}</button>
{% if active_filters %}
<a href="{{ clear_search_url }}" class="btn btn-secondary">{{ _gettext('Reset Filters') }}</a>
{% endif %}
</div>
<table class="filters"></table>
</form>
<div class="clearfix"></div>
{% endmacro %}
{% macro search_form(input_class="col-auto") %}
<form method="GET" action="{{ return_url }}" class="form-inline my-2 my-lg-0" role="search">
{% for flt_name, flt_value in filter_args.items() %}
<input type="hidden" name="{{ flt_name }}" value="{{ flt_value }}">
{% endfor %}
{% if page_size != default_page_size %}
<input type="hidden" name="page_size" value="{{ page_size }}">
{% endif %}
{% for arg_name, arg_value in extra_args.items() %}
<input type="hidden" name="{{ arg_name }}" value="{{ arg_value }}">
{% endfor %}
{% if sort_column is not none %}
<input type="hidden" name="sort" value="{{ sort_column }}">
{% endif %}
{% if sort_desc %}
<input type="hidden" name="desc" value="{{ sort_desc }}">
{% endif %}
{% if search %}
<div class="form-inline input-group">
<input class="form-control {{ input_class }}" size="30" type="text" name="search" value="{{ search }}"
placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
<div class="input-group-append">
<span class="input-group-text">
<a href="{{ clear_search_url }}" class="align-middle">
<span class="fa fa-times glyphicon glyphicon-remove"></span>
</a>
</span>
</div>
<button class="btn btn-secondary my-2 my-sm-0 ml-2" type="submit">{{ _gettext('Search') }}</button>
</div>
{% else %}
<div class="form-inline">
<input class="form-control {{ input_class }}" size="30" type="text" name="search" value=""
placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
<button class="btn btn-secondary my-2 my-sm-0 ml-2" type="submit">{{ _gettext('Search') }}</button>
</div>
{% endif %}
</form>
{% endmacro %}
{% macro page_size_form(generator, btn_class='nav-link dropdown-toggle') %}
<a class="{{ btn_class }}" data-toggle="dropdown" href="javascript:void(0)">
{{ page_size }} {{ _gettext('items') }}<b class="caret"></b>
</a>
<div class="dropdown-menu">
<a class="dropdown-item{% if page_size == 20 %} active{% endif %}" href="{{ generator(20) }}">20 {{ _gettext('items') }}</a>
<a class="dropdown-item{% if page_size == 50 %} active{% endif %}" href="{{ generator(50) }}">50 {{ _gettext('items') }}</a>
<a class="dropdown-item{% if page_size == 100 %} active{% endif %}" href="{{ generator(100) }}">100 {{ _gettext('items') }}</a>
</div>
{% endmacro %}

View file

@ -1,198 +0,0 @@
{% extends 'admin/master.html' %}
{% import 'admin/lib.html' as lib with context %}
{% import 'admin/static.html' as admin_static with context%}
{% import 'admin/model/layout.html' as model_layout with context %}
{% import 'admin/actions.html' as actionlib with context %}
{% import 'admin/model/row_actions.html' as row_actions with context %}
{% block head %}
{{ super() }}
{{ lib.form_css() }}
{% endblock %}
{% block body %}
{% block model_menu_bar %}
<ul class="nav nav-tabs">
<li class="nav-item">
<a href="javascript:void(0)" class="nav-link active">{{ _gettext('List') }}{% if count %} ({{ count }}){% endif %}</a>
</li>
{% if admin_view.can_create %}
<li class="nav-item">
{%- if admin_view.create_modal -%}
{{ lib.add_modal_button(url=get_url('.create_view', url=return_url, modal=True), btn_class='nav-link', title=_gettext('Create New Record'), content=_gettext('Create')) }}
{% else %}
<a href="{{ get_url('.create_view', url=return_url) }}" title="{{ _gettext('Create New Record') }}" class="nav-link">{{ _gettext('Create') }}</a>
{%- endif -%}
</li>
{% endif %}
{% if admin_view.can_export %}
{{ model_layout.export_options() }}
{% endif %}
{% block model_menu_bar_before_filters %}{% endblock %}
{% if filters %}
<li class="nav-item dropdown">
{{ model_layout.filter_options() }}
</li>
{% endif %}
{% if can_set_page_size %}
<li class="nav-item dropdown">
{{ model_layout.page_size_form(page_size_url) }}
</li>
{% endif %}
{% if actions %}
<li class="nav-item dropdown">
{{ actionlib.dropdown(actions) }}
</li>
{% endif %}
{% if search_supported %}
<li class="nav-item ml-2">
{{ model_layout.search_form() }}
</li>
{% endif %}
{% block model_menu_bar_after_filters %}{% endblock %}
</ul>
{% endblock %}
{% if filters %}
{{ model_layout.filter_form() }}
<div class="clearfix"></div>
{% endif %}
{% block model_list_table %}
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover model-list">
<thead>
<tr>
{% block list_header scoped %}
{% if actions %}
<th class="list-checkbox-column">
<input type="checkbox" name="rowtoggle" class="action-rowtoggle" title="{{ _gettext('Select all records') }}" />
</th>
{% endif %}
{% block list_row_actions_header %}
{% if admin_view.column_display_actions %}
<th class="">&nbsp;</th>
{% endif %}
{% endblock %}
{% for c, name in list_columns %}
{% set column = loop.index0 %}
<th class="column-header col-{{c}}">
{% if admin_view.is_sortable(c) %}
{% if sort_column == column %}
<a href="{{ sort_url(column, True) }}" title="{{ _gettext('Sort by %(name)s', name=name) }}">
{{ name }}
{% if sort_desc %}
<span class="fa fa-chevron-up glyphicon glyphicon-chevron-up"></span>
{% else %}
<span class="fa fa-chevron-down glyphicon glyphicon-chevron-down"></span>
{% endif %}
</a>
{% else %}
<a href="{{ sort_url(column) }}" title="{{ _gettext('Sort by %(name)s', name=name) }}">{{ name }}</a>
{% endif %}
{% else %}
{{ name }}
{% endif %}
{% if admin_view.column_descriptions.get(c) %}
<a class="fa fa-question-circle glyphicon glyphicon-question-sign"
title="{{ admin_view.column_descriptions[c] }}"
href="javascript:void(0)" data-role="tooltip"
></a>
{% endif %}
</th>
{% endfor %}
{% endblock %}
</tr>
</thead>
{% for row in data %}
<tr>
{% block list_row scoped %}
{% if actions %}
<td>
<input type="checkbox" name="rowid" class="action-checkbox" value="{{ get_pk_value(row) }}" title="{{ _gettext('Select record') }}" />
</td>
{% endif %}
{% block list_row_actions_column scoped %}
{% if admin_view.column_display_actions %}
<td class="list-buttons-column">
{% block list_row_actions scoped %}
{% for action in list_row_actions %}
{{ action.render_ctx(get_pk_value(row), row) }}
{% endfor %}
{% endblock %}
</td>
{%- endif -%}
{% endblock %}
{% for c, name in list_columns %}
<td class="col-{{c}}">
{% if admin_view.is_editable(c) %}
{% set form = list_forms[get_pk_value(row)] %}
{% if form.csrf_token %}
{{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=form.csrf_token._value()) }}
{% elif csrf_token %}
{{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=csrf_token()) }}
{% else %}
{{ form[c](pk=get_pk_value(row), display_value=get_value(row, c)) }}
{% endif %}
{% else %}
{{ get_value(row, c) }}
{% endif %}
</td>
{% endfor %}
{% endblock %}
</tr>
{% else %}
<tr>
<td colspan="999">
{% block empty_list_message %}
<div class="text-center">
{{ admin_view.get_empty_list_message() }}
</div>
{% endblock %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% block list_pager %}
{% if num_pages is not none %}
{{ lib.pager(page, num_pages, pager_url) }}
{% else %}
{{ lib.simple_pager(page, data|length == page_size, pager_url) }}
{% endif %}
{% endblock %}
{% endblock %}
{% block actions %}
{{ actionlib.form(actions, get_url('.action_view')) }}
{% endblock %}
{%- if admin_view.edit_modal or admin_view.create_modal or admin_view.details_modal -%}
{{ lib.add_modal_window() }}
{%- endif -%}
{% endblock %}
{% block tail %}
{{ super() }}
{% if filter_groups %}
<div id="filter-groups-data" style="display:none;">{{ filter_groups|tojson|safe }}</div>
<div id="active-filters-data" style="display:none;">{{ active_filters|tojson|safe }}</div>
{% endif %}
{{ lib.form_js() }}
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
<script src="{{ admin_static.url(filename='admin/js/bs4_filters.js', v='1.0.0') }}"></script>
{{ actionlib.script(_gettext('Please select at least one record.'),
actions,
actions_confirmation) }}
{% endblock %}

View file

@ -1,36 +0,0 @@
{% import 'admin/static.html' as admin_static with context%}
{% import 'admin/lib.html' as lib with context %}
{# store the jinja2 context for form_rules rendering logic #}
{% set render_ctx = h.resolve_ctx() %}
{% block body %}
<div class="modal-header">
{% block header_text %}<h5 class="modal-title">{{ _gettext('Create New Record') }}</h5>{% endblock %}
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
{% call lib.form_tag(action=url_for('.create_view', url=return_url)) %}
<div class="modal-body">
{{ lib.render_form_fields(form, form_opts=form_opts) }}
</div>
<div class="modal-footer">
{{ lib.render_form_buttons(return_url, extra=None, is_modal=True) }}
</div>
{% endcall %}
{# "save and add" button is removed from modal (it won't function properly) #}
{# % block create_form %}
{{ lib.render_form(form, return_url, extra=None, form_opts=form_opts,
action=url_for('.create_view', url=return_url),
is_modal=True) }}
{% endblock % #}
{% endblock %}
{% block tail %}
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
{% endblock %}

View file

@ -1,40 +0,0 @@
{% import 'admin/static.html' as admin_static with context%}
{% import 'admin/lib.html' as lib with context %}
{% block body %}
<div class="modal-header">
{% block header_text %}
<h3>{{ _gettext('View Record') + ' #' + request.args.get('id') }}</h3>
{% endblock %}
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
{% block details_search %}
<div class="form-inline fa_filter_container col-lg-6">
<label for="fa_filter">{{ _gettext('Filter') }}</label>
<input id="fa_filter" type="text" class="ml-3 form-control">
</div>
{% endblock %}
{% block details_table %}
<table class="table table-hover table-bordered searchable">
{% for c, name in details_columns %}
<tr>
<td>
<b>{{ name }}</b>
</td>
<td>
{{ get_value(model, c) }}
</td>
</tr>
{% endfor %}
</table>
{% endblock %}
</div>
{% endblock %}
{% block tail %}
<script src="{{ admin_static.url(filename='admin/js/details_filter.js', v='1.0.0') }}"></script>
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
{% endblock %}

View file

@ -1,31 +0,0 @@
{% import 'admin/static.html' as admin_static with context%}
{% import 'admin/lib.html' as lib with context %}
{# store the jinja2 context for form_rules rendering logic #}
{% set render_ctx = h.resolve_ctx() %}
{% block body %}
<div class="modal-header">
{% block header_text %}
<h5 class="modal-title">{{ _gettext('Edit Record') + ' #' + request.args.get('id') }}</h5>
{% endblock %}
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
{% call lib.form_tag(action=url_for('.edit_view', id=request.args.get('id'), url=return_url)) %}
<div class="modal-body">
{{ lib.render_form_fields(form, form_opts=form_opts) }}
</div>
<div class="modal-footer">
{{ lib.render_form_buttons(return_url, extra=None, is_modal=True) }}
</div>
{% endcall %}
{% endblock %}
{% block tail %}
<script src="{{ admin_static.url(filename='admin/js/bs4_modal.js', v='1.0.0') }}"></script>
{% endblock %}

View file

@ -1,38 +0,0 @@
{% import 'admin/lib.html' as lib with context %}
{% macro link(action, url, icon_class=None) %}
<a class="icon" href="{{ url }}" title="{{ action.title or '' }}">
<span class="{{ icon_class or action.icon_class }}"></span>
</a>
{% endmacro %}
{% macro view_row(action, row_id, row) %}
{{ link(action, get_url('.details_view', id=row_id, url=return_url), 'fa fa-eye glyphicon glyphicon-eye-open') }}
{% endmacro %}
{% macro view_row_popup(action, row_id, row) %}
{{ lib.add_modal_button(url=get_url('.details_view', id=row_id, url=return_url, modal=True), title=action.title, content='<span class="fa fa-eye glyphicon glyphicon-eye-open"></span>') }}
{% endmacro %}
{% macro edit_row(action, row_id, row) %}
{{ link(action, get_url('.edit_view', id=row_id, url=return_url), 'fa fa-pencil glyphicon glyphicon-pencil') }}
{% endmacro %}
{% macro edit_row_popup(action, row_id, row) %}
{{ lib.add_modal_button(url=get_url('.edit_view', id=row_id, url=return_url, modal=True), title=action.title, content='<span class="fa fa-pencil glyphicon glyphicon-pencil"></span>') }}
{% endmacro %}
{% macro delete_row(action, row_id, row) %}
<form class="icon" method="POST" action="{{ get_url('.delete_view') }}">
{{ delete_form.id(value=get_pk_value(row)) }}
{{ delete_form.url(value=return_url) }}
{% if delete_form.csrf_token %}
{{ delete_form.csrf_token }}
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return faHelpers.safeConfirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="{{ _gettext('Delete record') }}">
<span class="fa fa-trash glyphicon glyphicon-trash"></span>
</button>
</form>
{% endmacro %}

View file

@ -1,27 +0,0 @@
{% extends 'admin/master.html' %}
{% import 'admin/lib.html' as lib with context %}
{% import 'admin/static.html' as admin_static with context%}
{% block head %}
{{ super() }}
<link href="{{ admin_static.url(filename='admin/css/bootstrap4/rediscli.css', v='1.0.0') }}" rel="stylesheet">
{% endblock %}
{% block body %}
<div class="console">
<div class="console-container">
</div>
<div class="console-line mb-4">
<form action="#">
<input type="text"></input>
</form>
</div>
</div>
{% endblock %}
{% block tail %}
{{ super() }}
<div id="execute-view-data" style="display:none;">{{ admin_view.get_url('.execute_view')|tojson|safe }}</div>
<script src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script>
{% endblock %}

View file

@ -1,32 +0,0 @@
{% macro render(item, depth=0) %}
{% set type = type_name(item) %}
{% if type == 'tuple' or type == 'list' %}
{% if not item %}
Empty {{ type }}.
{% else %}
{% for n in item %}
{{ loop.index }}) {{ render(n, depth + 1) }}<br/>
{% endfor %}
{% endif %}
{% elif type == 'bool' %}
{% if depth == 0 and item %}
OK
{% else %}
<span class="type-bool">{{ item }}</span>
{% endif %}
{% elif type == 'str' or type == 'unicode' %}
"{{ item }}"
{% elif type == 'bytes' %}
"{{ item.decode('utf-8') }}"
{% elif type == 'TextWrapper' %}
<pre>{{ item }}</pre>
{% elif type == 'dict' %}
{% for k, v in item.items() %}
{{ loop.index }}) {{ k }} - {{ render(v, depth + 1) }}<br/>
{% endfor %}
{% else %}
{{ item }}
{% endif %}
{% endmacro %}
{{ render(result) }}

View file

@ -1,3 +0,0 @@
{% macro url() -%}
{{ get_url('{admin_endpoint}.static'.format(admin_endpoint=admin_view.admin.endpoint), *varargs, **kwargs) }}
{%- endmacro %}

View file

@ -7,9 +7,14 @@
<hr>
<form method="POST">
{{ form.csrf_token }}
{{ form.assistantMarksSubmit }}
<hr>
{{ form.finalPartMarksSubmit }}
{% for field in form %}
{% if field.widget.input_type == "submit" %}
{{ field(class="btn btn-primary btn-block") }}
<br>
{% else %}
{{ field() }}
{% endif %}
{% endfor %}
</form>
{% endblock body %}

View file

@ -6,17 +6,36 @@
<hr>
<h2>Assistant's marks analysis</h2>
<p>
This page shows an analysis of all marks of all assistants with an active user.
<br>
The marks are from all semesters, not only the active semester.
The histograms on this page (except the last two histograms) show the oral and protocol marks of each active assistant <em>individually</em>.
</p>
<p>
The last two histograms show the oral and protocol marks of all active assistants <em>together</em>.
</p>
<p>
An active assistant is an assistant with an active user. The marks are from all semesters, not only from the active semester.
</p>
<h4>Export</h4>
<p>
You can export this analysis by printing this page to a PDF file. The shortcut for printing the page is normally <code>Ctrl + p</code>. Select "Save as (PDF) file" afterwards instead of a printer!
</p>
<br>
<hr>
{% for histInd in histIndices %}
<img src="data:image/png;base64,{{ oralMarkHists[histInd]}}">
<img src="data:image/png;base64,{{ protocolMarkHists[histInd]}}">
{% for hist_ind in hist_indices %}
<div class="row text-center">
<div class="col-sm">
<img class="img-fluid" src="data:image/png;base64,{{ oral_mark_hists[hist_ind]}}">
</div>
<div class="col-sm">
<img class="img-fluid" src="data:image/png;base64,{{ protocol_mark_hists[hist_ind]}}">
</div>
</div>
<hr>
{% endfor %}
{% endblock body %}

View file

@ -6,10 +6,32 @@
<hr>
{% for activeSemesterFinalPartMarksHist in activeSemesterFinalPartMarksHists %}
<img src="data:image/png;base64,{{ activeSemesterFinalPartMarksHist }}">
<hr>
{% endfor %}
<h2>Final part marks analysis</h2>
<img src="data:image/png;base64,{{ meanFinalPartMarksPlot }}">
<p>
The histograms on this page show the final experiment marks of each part in the active semester.
</p>
<p>
The plot at the end of the page shows the course of the mean value of the final experiment marks in all parts of each semester over all semesters.
</p>
<h4>Export</h4>
<p>
You can export this analysis by printing this page to a PDF file. The shortcut for printing the page is normally <code>Ctrl + p</code>. Select "Save as (PDF) file" afterwards instead of a printer!
</p>
<br>
<hr>
<div class="text-center">
{% for active_semester_final_part_marks_hist in active_semester_final_part_marks_hists %}
<img class="img-fluid" src="data:image/png;base64,{{ active_semester_final_part_marks_hist }}">
<hr>
{% endfor %}
<img class="img-fluid" src="data:image/png;base64,{{ mean_final_part_mark_plot }}">
</div>
<br>
{% endblock body %}

View file

@ -9,14 +9,6 @@
<div class="d-inline-flex">
<table class="table table-bordered">
<tbody>
<tr>
<th scope="row">
Experiment
</th>
<td>
{{ experiment_label }}
</td>
</tr>
<tr>
<th scope="row">
Group number
@ -31,6 +23,47 @@
<br>
<div class="d-inline-flex">
<table class="table table-bordered">
<tbody>
<tr>
<th scope="row">
Experiment
</th>
<td>
{{ semester_experiment.experiment.str() }}
</td>
</tr>
<tr>
<th scope="row">
Oral mark weighting
</th>
<td>
{{ semester_experiment.oral_weighting }}
</td>
</tr>
<tr>
<th scope="row">
Protocol mark weighting
</th>
<td>
{{ semester_experiment.protocol_weighting }}
</td>
</tr>
<tr>
<th scope="row">
Final experiment mark weighting
</th>
<td>
{{ semester_experiment.final_weighting }}
</td>
</tr>
</tbody>
</table>
</div>
<br>
<form method="POST">
{{ form.csrf_token }}
<div class="d-inline-flex">
@ -50,7 +83,7 @@
{% for appointment_field in appointment_fields %}
<tr>
<td>
{{ appointment_field }}
{{ appointment_field(class="form-control") }}
</td>
<td>
{{ appointment_field.description }}
@ -95,10 +128,10 @@
{{ student }}
</th>
<td>
{{ oral_experiment_mark }}
{{ oral_experiment_mark(class="form-control", style="width: auto;") }}
</td>
<td>
{{ protocol_experiment_mark }}
{{ protocol_experiment_mark(class="form-control", style="width: auto;") }}
</td>
<td>
{% if final_experiment_mark is none %}
@ -126,13 +159,13 @@
<div class="form-group form-row">
<label for={{ form.note.id }} class="col-form-label">{{ form.note.label }}³</label>
<div class="col">
{{ form.note }}
{{ form.note(class="form-control") }}
</div>
</div>
<br>
{{ form.submit }}
{{ form.submit(class="btn btn-primary btn-block") }}
</form>
<hr>

View file

@ -7,8 +7,7 @@
<hr>
<p>
Number of <strong>missing</strong> final experiment marks:
{{ number_of_missing_final_experiment_marks }} / {{ number_of_all_experiment_marks }}
Number of <strong>missing</strong> final experiment marks: {{ number_of_missing_final_experiment_marks }}
</p>
{{ super() }}

View file

@ -13,7 +13,7 @@
<h3>Home</h3>
<p>
This is your home page. Here, you currently only find the number of
This is your home page. Here, you find the number of
<strong>missing</strong> final experiment marks for the experiments that you
are assigned to. At the latest at the end of the semester, this number should
be 0. A number higher than 0 means that some oral and/or protocol marks are
@ -21,7 +21,7 @@
and protocol marks of an experiment are set.
</p>
<h3 id="_group_experiment">Group Experiment</h3>
<h3>Group Experiment</h3>
<p>
This is the most important menu item. Here, you see all pairs of experiments
and groups that you are responsible for. If a value in the column
@ -48,38 +48,25 @@
Oral and protocol marks are between 0 and 15!
</div>
<p>
After editing appointment date(s) or experiment marks, click on the
<em>Save</em> button to save the changes.
After editing appointment date(s) or experiment marks, click on
<em>Save</em> to save the changes.
</p>
<p>
The changes are lost if you don't click on <em>Save</em>. Therefore, if you
want to discard the changes, just click on the back button of your browser.
</p>
<h3>User</h3>
<p>
Here, you find a table with only one row which is you as a user. Make sure
that the fields <em>Phone Number</em>, <em>Mobile Phone Number</em>,
<em>Building</em> and <em>Room</em> are filled and up to date, especially if
you are a new assistant. To edit these fields, click on the pen icon at the
left of the single entry in the table. After editing, click on <em>Save</em>.
</p>
<p>
You can generate a new random password by clicking on the pen icon, checking
the corresponding checkbox and then clicking on
<em>Save</em>. You will be then logged out. Your new password is displayed
above the login fields. Make sure that you save the password in a safe place.
Using a free open source password manager like
<a href="https://bitwarden.com/" target="_blank" rel="noopener">Bitwarden</a>
or
<a href="https://keepassxc.org/" target="_blank" rel="noopener">KeepassXC</a>
is recommended.
</p>
<h3>Docs</h3>
<p>This is a link which leads you to this page.</p>
<h3>Active semester</h3>
<h3>User settings</h3>
<p>
In the user settings, You can change your active semester, user information
and password. Don't forget to click on <em>Save</em> after changing any
option.
</p>
<h4>Active semester</h4>
<p>
An active semester is the semester you are working in. All shown experiment
marks and appointments are in your active semester. By default as a new
@ -90,3 +77,25 @@
your active semester in the user settings. You should only work in an old
semester if there are still experiment marks to be set in the old semester.
</p>
<h4>User information</h4>
<p>
Make sure that the fields <em>Phone Number</em>, <em>Mobile Phone Number</em>,
<em>Building</em> and <em>Room</em> are filled and up to date, especially if
you are a new assistant.
</p>
<h4>Password</h4>
<p>
You can generate a new random password by checking the corresponding checkbox
and then clicking on <em>Save</em>. You will be then logged out. Your new
password is displayed above the login fields. Make sure that you save the
password in a safe place. Using a free open source password manager like
<a href="https://bitwarden.com/" target="_blank" rel="noopener">Bitwarden</a>
or
<a href="https://keepassxc.org/" target="_blank" rel="noopener">KeepassXC</a>
is recommended.
</p>
<p>
If you forget your password, you have to write an admin to reset it.
</p>

View file

@ -1,18 +1,26 @@
{% macro information(current_user, active_semester_str, role) %}
User:
<a
href="{{ url_for('main.index') }}{{ role }}/user/edit/?id={{ current_user.id }}"
>{{ current_user }}</a>
| Active semester: {{ active_semester_str() }}
{% if (role == "admin") and (current_user.has_role("assistant")) %}
|
<a
href="{{ url_for('main.index') }}assistant"
>Assistant space</a>.
{% elif (role == "assistant") and (current_user.has_role("admin")) %}
|
<a
href="{{ url_for('main.index') }}admin"
>Admin space</a>.
{% endif %}
<div class="row" style="text-align: center;">
<div class="col">
User: {{ current_user }}
</div>
<div class="col">
Active semester: {{ active_semester_str() }}
<span class="fa fa-question-circle" title="The active semester can be changed in user settings"></span>
</div>
{% if (role == "admin") and (current_user.has_role("assistant")) %}
<div class="col">
<a
href="{{ url_for('main.index') }}assistant"
>Assistant space</a>
</div>
{% elif (role == "assistant") and (current_user.has_role("admin")) %}
<div class="col">
<a
href="{{ url_for('main.index') }}admin"
>Admin space</a>
</div>
{% endif %}
</div>
{% endmacro %}

View file

@ -0,0 +1,103 @@
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors %}
{% block title %}AdvLabDB - Login{% endblock title %}
{% block body_attribs %}
style="
background-image: radial-gradient(#35393b, #181a1b);
"
{% endblock body_attribs %}
{% block body %}
<style>
.fs-error-msg {
color: #ff6b6b;
}
</style>
<font color="white">
<div style="
display: flex;
justify-content: center;
align-items: center;
height: 95vh;
">
<div style="
text-align: center;
">
<h1>AdvLabDB</h1>
<p>Database for labs</p>
<h2>Login</h2>
{% include "security/_messages.html" %}
<br>
<form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
{{ login_user_form.hidden_tag() }}
{{ render_form_errors(login_user_form) }}
<div style="
display: flex;
justify-content: center;
">
<table>
<tr>
<td>
Email
</td>
<td>
{{ login_user_form.email() }}
</td>
</tr>
<tr>
<td>
Password
</td>
<td>
{{ login_user_form.password() }}
</td>
</tr>
</table>
</div>
{% if login_user_form.email.errors %}
<ul>
{% for error in login_user_form.email.errors %}
<li class="fs-error-msg">{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
{% if login_user_form.password.errors %}
<ul>
{% for error in login_user_form.password.errors %}
<li class="fs-error-msg">{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<br>
Remember me {{ login_user_form.remember() }}
{% if login_user_form.remember.errors %}
<ul>
{% for error in login_user_form.remember.errors %}
<li class="fs-error-msg">{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
{{ render_field_errors(login_user_form.csrf_token) }}
<br>
{{ render_field(login_user_form.submit) }}
</form>
</div>
</div>
</font>
{% endblock body %}

View file

@ -1,50 +0,0 @@
from pathlib import Path
from shutil import copytree, rmtree
import click
from flask_admin import __file__ as flask_admin_path
def _copy_admin_templates():
src = Path(flask_admin_path).parent / "templates/bootstrap4/admin"
if not src.is_dir():
click.echo(click.style(f"Templates could not be found at {src}", fg="red"))
return
dist = Path("advlabdb/templates/admin")
if dist.is_dir():
if not click.confirm(
click.style(f"The directory {dist} already exists! Do you want to overwrite it?", fg="yellow")
):
return
rmtree(dist)
click.echo(click.style("Old templates deleted!", fg="yellow"))
copytree(src, dist)
click.echo(click.style(f"Copied {src} -> {dist}", fg="green"))
click.echo(
click.style(
f"""
_________
| WARNING
| -------
| You might have to edit the file {dist}/base.html
| by adding nav in the following way:
| This line:\t<ul class="navbar-nav mr-auto">
| Becomes:\t<ul class="nav navbar-nav mr-auto">
|
| This will prevent the navigation bar from expanding
| such that some elements can not be seen.
| Refer to this pull request:
| https://github.com/flask-admin/flask-admin/pull/2233
|
| If the above pull request is merged and flask-admin
| is on a new release after the merge,
| then this step is not needed.
_________
""",
fg="yellow",
)
)

View file

@ -14,12 +14,12 @@ def _reset_admin_password(manage):
with app.app_context():
with db.session.begin():
admins = db.session.execute(select(Admin).join(User).where(User.active == True)).scalars().all()
admins = db.session.scalars(select(Admin).join(User).where(User.active == True)).all()
activate_user = False
if len(admins) == 0:
click.echo("There is no admin with an active user. The user of the chosen admin will be activated.")
admins = db.session.execute(select(Admin)).scalars().all()
admins = db.session.scalars(select(Admin)).all()
activate_user = True
num_admins = len(admins)

View file

@ -4,7 +4,6 @@ import subprocess # nosec 404
import click
from cli.maintain.copy_admin_templates.main import _copy_admin_templates
from cli.maintain.reset_admin_password.main import _reset_admin_password
from cli.setup.generate_secrets.main import _generate_secrets
from cli.setup.init_db.main import _init_db
@ -70,14 +69,6 @@ def reset_admin_password():
_reset_admin_password(Manage)
@maintain.command(
short_help="Copy admin templates",
help="Copy the templates from the Flask-Admin package. This is only needed if the templates should be updated to a new version after a new release of Flask-Admin.",
)
def copy_admin_templates():
_copy_admin_templates()
@cli.group(
short_help="Test commands.",
help="Commands used to test AdvLabDB.",

229
poetry.lock generated
View file

@ -40,6 +40,24 @@ category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "contourpy"
version = "1.0.5"
description = "Python library for calculating contours of 2D quadrilateral grids"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
numpy = ">=1.16"
[package.extras]
bokeh = ["bokeh", "selenium"]
docs = ["docutils (<0.18)", "sphinx", "sphinx-rtd-theme"]
test = ["Pillow", "flake8", "isort", "matplotlib", "pytest"]
test-minimal = ["pytest"]
test-no-codebase = ["Pillow", "matplotlib", "pytest"]
[[package]]
name = "cycler"
version = "0.11.0"
@ -66,8 +84,8 @@ wmi = ["wmi (>=1.5.1,<2.0.0)"]
[[package]]
name = "email-validator"
version = "1.2.1"
description = "A robust email syntax and deliverability validation library."
version = "1.3.0"
description = "A robust email address syntax and deliverability validation library."
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
@ -149,7 +167,7 @@ Flask = "*"
[[package]]
name = "Flask-Security-Too"
version = "5.0.1"
version = "5.0.2"
description = "Simple security for Flask apps."
category = "main"
optional = false
@ -202,7 +220,7 @@ email = ["email-validator"]
[[package]]
name = "fonttools"
version = "4.37.1"
version = "4.37.3"
description = "Tools to manipulate font files"
category = "main"
optional = false
@ -252,7 +270,7 @@ tornado = ["tornado (>=0.2)"]
[[package]]
name = "idna"
version = "3.3"
version = "3.4"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
@ -290,7 +308,7 @@ python-versions = ">=3.7"
[[package]]
name = "Mako"
version = "1.2.2"
version = "1.2.3"
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
category = "main"
optional = false
@ -314,22 +332,23 @@ python-versions = ">=3.7"
[[package]]
name = "matplotlib"
version = "3.5.3"
version = "3.6.0"
description = "Python plotting package"
category = "main"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
[package.dependencies]
contourpy = ">=1.0.1"
cycler = ">=0.10"
fonttools = ">=4.22.0"
kiwisolver = ">=1.0.1"
numpy = ">=1.17"
numpy = ">=1.19"
packaging = ">=20.0"
pillow = ">=6.2.0"
pyparsing = ">=2.2.1"
python-dateutil = ">=2.7"
setuptools_scm = ">=4,<7"
setuptools_scm = ">=7"
[[package]]
name = "numpy"
@ -413,16 +432,17 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (
[[package]]
name = "setuptools-scm"
version = "6.4.2"
version = "7.0.5"
description = "the blessed package to manage your versions by scm tags"
category = "main"
optional = false
python-versions = ">=3.6"
python-versions = ">=3.7"
[package.dependencies]
packaging = ">=20.0"
setuptools = "*"
tomli = ">=1.0.0"
typing-extensions = "*"
[package.extras]
test = ["pytest (>=6.2)", "virtualenv (>20)"]
@ -476,6 +496,14 @@ category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "typing-extensions"
version = "4.3.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "Werkzeug"
version = "2.2.2"
@ -507,7 +535,7 @@ email = ["email-validator"]
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "7727fb4275266074afb431bd624d0e94b3896c220f736108b4b593a9207edf53"
content-hash = "068b146f1e4f736e280ec42004d834ad249ab053a439796600d423f52d55b3d3"
[metadata.files]
alembic = [
@ -526,6 +554,77 @@ colorama = [
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
]
contourpy = [
{file = "contourpy-1.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:87121b9428ac568fb84fae4af5e7852fc34f02eadc4e3e91f6c8989327692186"},
{file = "contourpy-1.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fb782982c42cee667b892a0b0c52a9f6c7ecf1da5c5f4345845f04eaa862f93"},
{file = "contourpy-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:689d7d2a840619915d0abd1ecc6e399fee202f8ad315acda2807f4ca420d0802"},
{file = "contourpy-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d88814befbd1433152c5f6dd536905149ba028d795a22555b149ae0a36024d9e"},
{file = "contourpy-1.0.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df65f4b2b4e74977f0336bef12a88051ab24e6a16873cd9249f34d67cb3e345d"},
{file = "contourpy-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6b4c0c723664f65c2a47c8cb6ebbf660b0b2e2d936adf2e8503d4e93359465"},
{file = "contourpy-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bcc98d397c3dea45d5b262029564b29cb8e945f2607a38bee6163694c0a8b4ef"},
{file = "contourpy-1.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2bf5c846c257578b03d498b20f54f53551616a507d8e5463511c58bb58e9a9cf"},
{file = "contourpy-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdacddb18d55ffec42d1907079cdc04ec4fa8a990cdf5b9d9fe67d281fc0d12e"},
{file = "contourpy-1.0.5-cp310-cp310-win32.whl", hash = "sha256:434942fa2f9019b9ae525fb752dc523800c49a1a28fbd6d9240b0fa959573dcc"},
{file = "contourpy-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:3b3082ade8849130203d461b98c2a061b382c46074b43b4edd5cefd81af92b8a"},
{file = "contourpy-1.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:057114f698ffb9e54657e8fda6802e2f5c8fad609845cf6afaf31590ef6a33c0"},
{file = "contourpy-1.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:218722a29c5c26677d37c44f5f8a372daf6f07870aad793a97d47eb6ad6b3290"},
{file = "contourpy-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6c02e22cf09996194bcb3a4784099975cf527d5c29caf759abadf29ebdb2fe27"},
{file = "contourpy-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0d5ee865b5fd16bf62d72122aadcc90aab296c30c1adb0a32b4b66bd843163e"},
{file = "contourpy-1.0.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45822b0a2a452327ab4f95efe368d234d5294bbf89a99968be27c7938a21108"},
{file = "contourpy-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dca5be83a6dfaf933a46e3bc2b9f2685e5ec61b22f6a38ad740aac9c16e9a0ff"},
{file = "contourpy-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3c3f2f6b898a40207843ae01970e57e33d22a26b22f23c6a5e07b4716751085f"},
{file = "contourpy-1.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c2b4eab7c12f9cb460509bc34a3b086f9802f0dba27c89a63df4123819ad64af"},
{file = "contourpy-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09ed9b63f4df8a7591b7a4a26c1ad066dcaafda1f846250fdcb534074a411692"},
{file = "contourpy-1.0.5-cp311-cp311-win32.whl", hash = "sha256:f670686d99c867d0f24b28ce8c6f02429c6eef5e2674aab287850d0ee2d20437"},
{file = "contourpy-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:c51568e94f7f232296de30002f2a50f77a7bd346673da3e4f2aaf9d2b833f2e5"},
{file = "contourpy-1.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7c9e99aac7b430f6a9f15eebf058c742097cea3369f23a2bfc5e64d374b67e3a"},
{file = "contourpy-1.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3210d93ad2af742b6a96cf39792f7181822edbb8fe11c3ef29d1583fe637a8d8"},
{file = "contourpy-1.0.5-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:128bd7acf569f8443ad5b2227f30ac909e4f5399ed221727eeacf0c6476187e6"},
{file = "contourpy-1.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:813c2944e940ef8dccea71305bacc942d4b193a021140874b3e58933ec44f5b6"},
{file = "contourpy-1.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a74afd8d560eaafe0d9e3e1db8c06081282a05ca4de00ee416195085a79d7d3d"},
{file = "contourpy-1.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d0ad9a85f208473b1f3613c45756c7aa6fcc288266a8c7b873f896aaf741b6b"},
{file = "contourpy-1.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:60f37acd4e4227c5a29f737d9a85ca3145c529a8dd4bf70af7f0637c61b49222"},
{file = "contourpy-1.0.5-cp37-cp37m-win32.whl", hash = "sha256:b50e481a4317a8efcfffcfddcd4c9b36eacba440440e70cbe0256aeb6fd6abae"},
{file = "contourpy-1.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:0395ae71164bfeb2dedd136e03c71a2718a5aa9873a46f518f4133be0d63e1d2"},
{file = "contourpy-1.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3ca40d7844b391d90b864c6a6d1bb6b88b09035fb4d866d64d43c4d26fb0ab64"},
{file = "contourpy-1.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3109fa601d2a448cec4643abd3a31f972bf05b7c2f2e83df9d3429878f8c10ae"},
{file = "contourpy-1.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:06c4d1dde5ee4f909a8a95ba1eb04040c6c26946b4f3b5beaf10d45f14e940ee"},
{file = "contourpy-1.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f54dcc9bb9390fd0636301ead134d46d5229fe86da0db4d974c0fda349f560e"},
{file = "contourpy-1.0.5-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46b8e24813e2fb5a3e598c1f8b9ae403e1438cb846a80cc2b33cddf19dddd7f2"},
{file = "contourpy-1.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:061e1f066c419ffe25b615a1df031b4832ea1d7f2676937e69e8e00e24512005"},
{file = "contourpy-1.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:19ea64fa0cf389d2ebc10974616acfa1fdecbd73d1fd9c72215b782f3c40f561"},
{file = "contourpy-1.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:dfe924e5a63861c82332a12adeeab955dc8c8009ddbbd80cc2fcca049ff89a49"},
{file = "contourpy-1.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bed3a2a823a041e8d249b1a7ec132933e1505299329b5cfe1b2b5ec689ec7675"},
{file = "contourpy-1.0.5-cp38-cp38-win32.whl", hash = "sha256:0389349875424aa8c5e61f757e894687916bc4e9616cc6afcbd8051aa2428952"},
{file = "contourpy-1.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:2b5e334330d82866923015b455260173cb3b9e3b4e297052d758abd262031289"},
{file = "contourpy-1.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:def9a01b73c9e27d70ea03b381fb3e7aadfac1f398dbd63751313c3a46747ef5"},
{file = "contourpy-1.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:59c827e536bb5e3ef58e06da0faba61fd89a14f30b68bcfeca41f43ca83a1942"},
{file = "contourpy-1.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f05d311c937da03b0cd26ac3e14cb991f6ff8fc94f98b3df9713537817539795"},
{file = "contourpy-1.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:970a4be7ec84ccda7c27cb4ae74930bbbd477bc8d849ed55ea798084dd5fca8c"},
{file = "contourpy-1.0.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f7672148f8fca48e4efc16aba24a7455b40c22d4f8abe42475dec6a12b0bb9a"},
{file = "contourpy-1.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eba62b7c21a33e72dd8adab2b92dd5610d8527f0b2ac28a8e0770e71b21a13f9"},
{file = "contourpy-1.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:dd084459ecdb224e617e4ab3f1d5ebe4d1c48facb41f24952b76aa6ba9712bb0"},
{file = "contourpy-1.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c5158616ab39d34b76c50f40c81552ee180598f7825dc7a66fd187d29958820f"},
{file = "contourpy-1.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f856652f9b533c6cd2b9ad6836a7fc0e43917d7ff15be46c5baf1350f8cdc5d9"},
{file = "contourpy-1.0.5-cp39-cp39-win32.whl", hash = "sha256:f1cc623fd6855b25da52b3275e0c9e51711b86a9dccc75f8c9ab4432fd8e42c7"},
{file = "contourpy-1.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:e67dcaa34dcd908fcccbf49194211d847c731b6ebaac661c1c889f1bf6af1e44"},
{file = "contourpy-1.0.5-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bfd634cb9685161b2a51f73a7fc4736fd0d67a56632d52319317afaa27f08243"},
{file = "contourpy-1.0.5-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79908b9d02b1d6c1c71ff3b7ad127f3f82e14a8e091ab44b3c7e34b649fea733"},
{file = "contourpy-1.0.5-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4963cf08f4320d98ae72ec7694291b8ab85cb7da3b0cd824bc32701bc992edf"},
{file = "contourpy-1.0.5-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cfc067ddde78b76dcbc9684d82688b7d3c5158fa2254a085f9bcb9586c1e2d8"},
{file = "contourpy-1.0.5-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:9939796abcadb2810a63dfb26ff8ca4595fe7dd70a3ceae7f607a2639b714307"},
{file = "contourpy-1.0.5-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d8150579bf30cdf896906baf256aa200cd50dbe6e565c17d6fd3d678e21ff5de"},
{file = "contourpy-1.0.5-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed9c91bf4ce614efed5388c3f989a7cfe08728ab871d995a486ea74ff88993db"},
{file = "contourpy-1.0.5-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b46a04588ceb7cf132568e0e564a854627ef87a1ed3bf536234540a79ced44b0"},
{file = "contourpy-1.0.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b85553699862c09937a7a5ea14ee6229087971a7d51ae97d5f4b407f571a2c17"},
{file = "contourpy-1.0.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:99a8071e351b50827ad976b92ed91845fb614ac67a3c41109b24f3d8bd3afada"},
{file = "contourpy-1.0.5-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fb0458d74726937ead9e2effc91144aea5a58ecee9754242f8539a782bed685a"},
{file = "contourpy-1.0.5-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f89f0608a5aa8142ed0e53957916623791a88c7f5e5f07ae530c328beeb888f"},
{file = "contourpy-1.0.5-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce763369e646e59e4ca2c09735cd1bdd3048d909ad5f2bc116e83166a9352f3c"},
{file = "contourpy-1.0.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c16fa267740d67883899e054cccb4279e002f3f4872873b752c1ba15045ff49"},
{file = "contourpy-1.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a30e95274f5c0e007ccc759ec258aa5708c534ec058f153ee25ac700a2f1438b"},
{file = "contourpy-1.0.5.tar.gz", hash = "sha256:896631cd40222aef3697e4e51177d14c3709fda49d30983269d584f034acc8a4"},
]
cycler = [
{file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"},
{file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"},
@ -535,8 +634,8 @@ dnspython = [
{file = "dnspython-2.2.1.tar.gz", hash = "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e"},
]
email-validator = [
{file = "email_validator-1.2.1-py2.py3-none-any.whl", hash = "sha256:c8589e691cf73eb99eed8d10ce0e9cbb05a0886ba920c8bcb7c82873f4c5789c"},
{file = "email_validator-1.2.1.tar.gz", hash = "sha256:6757aea012d40516357c0ac2b1a4c31219ab2f899d26831334c5d069e8b6c3d8"},
{file = "email_validator-1.3.0-py2.py3-none-any.whl", hash = "sha256:816073f2a7cffef786b29928f58ec16cdac42710a53bb18aa94317e3e145ec5c"},
{file = "email_validator-1.3.0.tar.gz", hash = "sha256:553a66f8be2ec2dea641ae1d3f29017ab89e9d603d4a25cdaac39eefa283d769"},
]
Flask = [
{file = "Flask-2.2.2-py3-none-any.whl", hash = "sha256:b9c46cc36662a7949f34b52d8ec7bb59c0d74ba08ba6cb9ce9adc1d8676d9526"},
@ -557,8 +656,8 @@ Flask-Principal = [
{file = "Flask-Principal-0.4.0.tar.gz", hash = "sha256:f5d6134b5caebfdbb86f32d56d18ee44b080876a27269560a96ea35f75c99453"},
]
Flask-Security-Too = [
{file = "Flask-Security-Too-5.0.1.tar.gz", hash = "sha256:436e3ba05984f010e27741055c69bc12cc45566946e4d805990a2628c3656594"},
{file = "Flask_Security_Too-5.0.1-py2.py3-none-any.whl", hash = "sha256:2197fa7fceff6c485aa9774ccc79f653e2f66db25e831b892a002261ede5fc3a"},
{file = "Flask-Security-Too-5.0.2.tar.gz", hash = "sha256:36fee0da5d1b3d211caf274553b7753478c208997c624abb84ebba4261de65c2"},
{file = "Flask_Security_Too-5.0.2-py2.py3-none-any.whl", hash = "sha256:5f81e220c63f8f319bcd04327267328fd4b58ca05aa6f3ffc458756dfa78d579"},
]
Flask-SQLAlchemy = [
{file = "Flask-SQLAlchemy-2.5.1.tar.gz", hash = "sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912"},
@ -569,8 +668,8 @@ Flask-WTF = [
{file = "Flask_WTF-1.0.1-py3-none-any.whl", hash = "sha256:9d733658c80be551ce7d5bc13c7a7ac0d80df509be1e23827c847d9520f4359a"},
]
fonttools = [
{file = "fonttools-4.37.1-py3-none-any.whl", hash = "sha256:fff6b752e326c15756c819fe2fe7ceab69f96a1dbcfe8911d0941cdb49905007"},
{file = "fonttools-4.37.1.zip", hash = "sha256:4606e1a88ee1f6699d182fea9511bd9a8a915d913eab4584e5226da1180fcce7"},
{file = "fonttools-4.37.3-py3-none-any.whl", hash = "sha256:a5bc5f5d48faa4085310b8ebd4c5d33bf27c6636c5f10a7de792510af2745a81"},
{file = "fonttools-4.37.3.zip", hash = "sha256:f32ef6ec966cf0e7d2aa88601fed2e3a8f2851c26b5db2c80ccc8f82bee4eedc"},
]
greenlet = [
{file = "greenlet-1.1.3-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:8c287ae7ac921dfde88b1c125bd9590b7ec3c900c2d3db5197f1286e144e712b"},
@ -633,8 +732,8 @@ gunicorn = [
{file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"},
]
idna = [
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
{file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
]
itsdangerous = [
{file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"},
@ -715,8 +814,8 @@ kiwisolver = [
{file = "kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955"},
]
Mako = [
{file = "Mako-1.2.2-py3-none-any.whl", hash = "sha256:8efcb8004681b5f71d09c983ad5a9e6f5c40601a6ec469148753292abc0da534"},
{file = "Mako-1.2.2.tar.gz", hash = "sha256:3724869b363ba630a272a5f89f68c070352137b8fd1757650017b7e06fda163f"},
{file = "Mako-1.2.3-py3-none-any.whl", hash = "sha256:c413a086e38cd885088d5e165305ee8eed04e8b3f8f62df343480da0a385735f"},
{file = "Mako-1.2.3.tar.gz", hash = "sha256:7fde96466fcfeedb0eed94f187f20b23d85e4cb41444be0e542e2c8c65c396cd"},
]
MarkupSafe = [
{file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"},
@ -761,41 +860,47 @@ MarkupSafe = [
{file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"},
]
matplotlib = [
{file = "matplotlib-3.5.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a206a1b762b39398efea838f528b3a6d60cdb26fe9d58b48265787e29cd1d693"},
{file = "matplotlib-3.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cd45a6f3e93a780185f70f05cf2a383daed13c3489233faad83e81720f7ede24"},
{file = "matplotlib-3.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d62880e1f60e5a30a2a8484432bcb3a5056969dc97258d7326ad465feb7ae069"},
{file = "matplotlib-3.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ab29589cef03bc88acfa3a1490359000c18186fc30374d8aa77d33cc4a51a4a"},
{file = "matplotlib-3.5.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2886cc009f40e2984c083687251821f305d811d38e3df8ded414265e4583f0c5"},
{file = "matplotlib-3.5.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c995f7d9568f18b5db131ab124c64e51b6820a92d10246d4f2b3f3a66698a15b"},
{file = "matplotlib-3.5.3-cp310-cp310-win32.whl", hash = "sha256:6bb93a0492d68461bd458eba878f52fdc8ac7bdb6c4acdfe43dba684787838c2"},
{file = "matplotlib-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:2e6d184ebe291b9e8f7e78bbab7987d269c38ea3e062eace1fe7d898042ef804"},
{file = "matplotlib-3.5.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6ea6aef5c4338e58d8d376068e28f80a24f54e69f09479d1c90b7172bad9f25b"},
{file = "matplotlib-3.5.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:839d47b8ead7ad9669aaacdbc03f29656dc21f0d41a6fea2d473d856c39c8b1c"},
{file = "matplotlib-3.5.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3b4fa56159dc3c7f9250df88f653f085068bcd32dcd38e479bba58909254af7f"},
{file = "matplotlib-3.5.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:94ff86af56a3869a4ae26a9637a849effd7643858a1a04dd5ee50e9ab75069a7"},
{file = "matplotlib-3.5.3-cp37-cp37m-win32.whl", hash = "sha256:35a8ad4dddebd51f94c5d24bec689ec0ec66173bf614374a1244c6241c1595e0"},
{file = "matplotlib-3.5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:43e9d3fa077bf0cc95ded13d331d2156f9973dce17c6f0c8b49ccd57af94dbd9"},
{file = "matplotlib-3.5.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:22227c976ad4dc8c5a5057540421f0d8708c6560744ad2ad638d48e2984e1dbc"},
{file = "matplotlib-3.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf618a825deb6205f015df6dfe6167a5d9b351203b03fab82043ae1d30f16511"},
{file = "matplotlib-3.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9befa5954cdbc085e37d974ff6053da269474177921dd61facdad8023c4aeb51"},
{file = "matplotlib-3.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3840c280ebc87a48488a46f760ea1c0c0c83fcf7abbe2e6baf99d033fd35fd8"},
{file = "matplotlib-3.5.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dacddf5bfcec60e3f26ec5c0ae3d0274853a258b6c3fc5ef2f06a8eb23e042be"},
{file = "matplotlib-3.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b428076a55fb1c084c76cb93e68006f27d247169f056412607c5c88828d08f88"},
{file = "matplotlib-3.5.3-cp38-cp38-win32.whl", hash = "sha256:874df7505ba820e0400e7091199decf3ff1fde0583652120c50cd60d5820ca9a"},
{file = "matplotlib-3.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:b28de401d928890187c589036857a270a032961411934bdac4cf12dde3d43094"},
{file = "matplotlib-3.5.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3211ba82b9f1518d346f6309df137b50c3dc4421b4ed4815d1d7eadc617f45a1"},
{file = "matplotlib-3.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6fe807e8a22620b4cd95cfbc795ba310dc80151d43b037257250faf0bfcd82bc"},
{file = "matplotlib-3.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5c096363b206a3caf43773abebdbb5a23ea13faef71d701b21a9c27fdcef72f4"},
{file = "matplotlib-3.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcdfcb0f976e1bac6721d7d457c17be23cf7501f977b6a38f9d38a3762841f7"},
{file = "matplotlib-3.5.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e64ac9be9da6bfff0a732e62116484b93b02a0b4d4b19934fb4f8e7ad26ad6a"},
{file = "matplotlib-3.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:73dd93dc35c85dece610cca8358003bf0760d7986f70b223e2306b4ea6d1406b"},
{file = "matplotlib-3.5.3-cp39-cp39-win32.whl", hash = "sha256:879c7e5fce4939c6aa04581dfe08d57eb6102a71f2e202e3314d5fbc072fd5a0"},
{file = "matplotlib-3.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:ab8d26f07fe64f6f6736d635cce7bfd7f625320490ed5bfc347f2cdb4fae0e56"},
{file = "matplotlib-3.5.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:99482b83ebf4eb6d5fc6813d7aacdefdd480f0d9c0b52dcf9f1cc3b2c4b3361a"},
{file = "matplotlib-3.5.3-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f814504e459c68118bf2246a530ed953ebd18213dc20e3da524174d84ed010b2"},
{file = "matplotlib-3.5.3-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57f1b4e69f438a99bb64d7f2c340db1b096b41ebaa515cf61ea72624279220ce"},
{file = "matplotlib-3.5.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:d2484b350bf3d32cae43f85dcfc89b3ed7bd2bcd781ef351f93eb6fb2cc483f9"},
{file = "matplotlib-3.5.3.tar.gz", hash = "sha256:339cac48b80ddbc8bfd05daae0a3a73414651a8596904c2a881cfd1edb65f26c"},
{file = "matplotlib-3.6.0-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:6b98e098549d3aea2bfb93f38f0b2ecadcb423fa1504bbff902c01efdd833fd8"},
{file = "matplotlib-3.6.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:798559837156b8e2e2df97cffca748c5c1432af6ec5004c2932e475d813f1743"},
{file = "matplotlib-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e572c67958f7d55eae77f5f64dc7bd31968cc9f24c233926833efe63c60545f2"},
{file = "matplotlib-3.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ec2edf7f74829eae287aa53d64d83ad5d43ee51d29fb1d88e689d8b36028312"},
{file = "matplotlib-3.6.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51092d13499be72e47c15c3a1ae0209edaca6be42b65ffbbefbe0c85f6153c6f"},
{file = "matplotlib-3.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9295ca10a140c21e40d2ee43ef423213dc20767f6cea6b87c36973564bc51095"},
{file = "matplotlib-3.6.0-cp310-cp310-win32.whl", hash = "sha256:1a4835c177821f3729be27ae9be7b8ae209fe75e83db7d9b2bfd319a998f0a42"},
{file = "matplotlib-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:2b60d4abcb6a405ca7d909c80791b00637d22c62aa3bb0ffff7e589f763867f5"},
{file = "matplotlib-3.6.0-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:66a0db13f77aa7806dba29273874cf862450c61c2e5158245d17ee85d983fe8e"},
{file = "matplotlib-3.6.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:1739935d293d0348d7bf662e8cd0edb9c2aa8f20ccd646db755ce0f3456d24e4"},
{file = "matplotlib-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1559213b803959a2b8309122585b5226d1c2fb66c933b1a2094cf1e99cb4fb90"},
{file = "matplotlib-3.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5bd3b3ff191f81509d9a1afd62e1e3cda7a7889c35b5b6359a1241fe1511015"},
{file = "matplotlib-3.6.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1954d71cdf15c19e7f3bf2235a4fe1600ba42f34d472c9495bcf54d75a43e4e"},
{file = "matplotlib-3.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d840712f4b4c7d2a119f993d7e43ca9bcaa73aeaa24c322fa2bdf4f689a3ee09"},
{file = "matplotlib-3.6.0-cp311-cp311-win32.whl", hash = "sha256:89e1978c3fbe4e3d4c6ad7db7e6f982607cb2546f982ccbe42708392437b1972"},
{file = "matplotlib-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:9711ef291e184b5a73c9d3af3f2d5cfe25d571c8dd95aa498415f74ac7e221a8"},
{file = "matplotlib-3.6.0-cp38-cp38-macosx_10_12_universal2.whl", hash = "sha256:fbbceb0a0dfe9213f6314510665a32ef25fe29b50657567cd00115fbfcb3b20d"},
{file = "matplotlib-3.6.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:62319d57dab5ad3e3494dd97a214e22079d3f72a0c8a2fd001829c2c6abbf8d1"},
{file = "matplotlib-3.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:140316427a7c384e3dd37efb3a73cd67e14b0b237a6d277def91227f43cdcec2"},
{file = "matplotlib-3.6.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ccea337fb9a44866c5300c594b13d4d87e827ebc3c353bff15d298bac976b654"},
{file = "matplotlib-3.6.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:16a899b958dd76606b571bc7eaa38f09160c27dfb262e493584644cfd4a77f0f"},
{file = "matplotlib-3.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd73a16a759865831be5a8fb6546f2a908c8d7d7f55c75f94ee7c2ca13cc95de"},
{file = "matplotlib-3.6.0-cp38-cp38-win32.whl", hash = "sha256:2ed779a896b70c8012fe301fb91ee37e713e1dda1eb8f37de04cdbf506706983"},
{file = "matplotlib-3.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:eca6f59cd0729edaeaa7032d582dffce518a420d4961ef3e8c93dce86be352c3"},
{file = "matplotlib-3.6.0-cp39-cp39-macosx_10_12_universal2.whl", hash = "sha256:408bbf968c15e9e38df9f25a588e372e28a43240cf5884c9bc6039a5021b7d5b"},
{file = "matplotlib-3.6.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:7127e2b94571318531caf098dc9e8f60f5aba1704600f0b2483bf151d535674a"},
{file = "matplotlib-3.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f0d5b9b14ccc7f539143ac9eb1c6b57d26d69ca52d30c3d719a7bc4123579e44"},
{file = "matplotlib-3.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa19508d8445f5648cd1ffe4fc6d4f7daf8b876f804e9a453df6c3708f6200b"},
{file = "matplotlib-3.6.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ae1b9b555212c1e242666af80e7ed796705869581e2d749971db4e682ccc1f3"},
{file = "matplotlib-3.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0958fc3fdc59c1b716ee1a5d14e73d03d541d873241a37c5c3a86f7ef6017923"},
{file = "matplotlib-3.6.0-cp39-cp39-win32.whl", hash = "sha256:efe9e8037b989b14bb1887089ae763385431cc06fe488406413079cfd2a3a089"},
{file = "matplotlib-3.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b0320f882214f6ffde5992081520b57b55450510bdaa020e96aacff9b7ae10e6"},
{file = "matplotlib-3.6.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:11c1987b803cc2b26725659cfe817478f0a9597878e5c4bf374cfe4e12cbbd79"},
{file = "matplotlib-3.6.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:802feae98addb9f21707649a7f229c90a59fad34511881f20b906a5e8e6ea475"},
{file = "matplotlib-3.6.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efd2e12f8964f8fb4ba1984df71d85d02ef0531e687e59f78ec8fc07271a3857"},
{file = "matplotlib-3.6.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4eba6972b796d97c8fcc5266b6dc42ef27c2dce4421b846cded0f3af851b81c9"},
{file = "matplotlib-3.6.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:df26a09d955b3ab9b6bc18658b9403ed839096c97d7abe8806194e228a485a3c"},
{file = "matplotlib-3.6.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e01382c06ac3710155a0ca923047c5abe03c676d08f03e146c6a240d0a910713"},
{file = "matplotlib-3.6.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4699bb671dbc4afdb544eb893e4deb8a34e294b7734733f65b4fd2787ba5fbc6"},
{file = "matplotlib-3.6.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:657fb7712185f82211170ac4debae0800ed4f5992b8f7ebba2a9eabaf133a857"},
{file = "matplotlib-3.6.0.tar.gz", hash = "sha256:c5108ebe67da60a9204497d8d403316228deb52b550388190c53a57394d41531"},
]
numpy = [
{file = "numpy-1.23.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c9f707b5bb73bf277d812ded9896f9512a43edff72712f31667d0a8c2f8e71ee"},
@ -908,8 +1013,8 @@ setuptools = [
{file = "setuptools-65.3.0.tar.gz", hash = "sha256:7732871f4f7fa58fb6bdcaeadb0161b2bd046c85905dbaa066bdcbcc81953b57"},
]
setuptools-scm = [
{file = "setuptools_scm-6.4.2-py3-none-any.whl", hash = "sha256:acea13255093849de7ccb11af9e1fb8bde7067783450cee9ef7a93139bddf6d4"},
{file = "setuptools_scm-6.4.2.tar.gz", hash = "sha256:6833ac65c6ed9711a4d5d2266f8024cfa07c533a0e55f4c12f6eff280a5a9e30"},
{file = "setuptools_scm-7.0.5-py3-none-any.whl", hash = "sha256:7930f720905e03ccd1e1d821db521bff7ec2ac9cf0ceb6552dd73d24a45d3b02"},
{file = "setuptools_scm-7.0.5.tar.gz", hash = "sha256:031e13af771d6f892b941adb6ea04545bbf91ebc5ce68c78aaf3fff6e1fb4844"},
]
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
@ -962,6 +1067,10 @@ tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
typing-extensions = [
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
{file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
]
Werkzeug = [
{file = "Werkzeug-2.2.2-py3-none-any.whl", hash = "sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5"},
{file = "Werkzeug-2.2.2.tar.gz", hash = "sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f"},

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "advlabdb"
version = "0.6.0"
version = "0.7.0"
description = "Database with a web interface for labs."
authors = ["Mo Bitar <mo8it@proton.me>"]
readme = "README.adoc"
@ -9,17 +9,17 @@ readme = "README.adoc"
# TODO: Use ^ instead of >=
python = "^3.10"
click = ">=8.1.3"
email-validator = ">=1.2.1"
email-validator = ">=1.3.0"
flask = ">=2.2.2"
flask-admin = ">=1.6.0"
flask-login = ">=0.6.2"
flask-migrate = ">=3.1.0"
flask-security-Too = ">=5.0.1"
flask-security-Too = ">=5.0.2"
flask-sqlalchemy = ">=2.5.1"
flask-wtf = ">=1.0.1"
gunicorn = ">=20.1.0"
markupsafe = ">=2.1.1"
matplotlib = ">=3.5.3"
matplotlib = ">=3.6.0"
numpy = ">=1.23.3"
[tool.poetry.dev-dependencies]

View file

@ -10,15 +10,85 @@ click==8.1.3 ; python_version >= "3.10" and python_version < "4.0" \
colorama==0.4.5 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows" \
--hash=sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da \
--hash=sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4
contourpy==1.0.5 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:0389349875424aa8c5e61f757e894687916bc4e9616cc6afcbd8051aa2428952 \
--hash=sha256:0395ae71164bfeb2dedd136e03c71a2718a5aa9873a46f518f4133be0d63e1d2 \
--hash=sha256:057114f698ffb9e54657e8fda6802e2f5c8fad609845cf6afaf31590ef6a33c0 \
--hash=sha256:061e1f066c419ffe25b615a1df031b4832ea1d7f2676937e69e8e00e24512005 \
--hash=sha256:06c4d1dde5ee4f909a8a95ba1eb04040c6c26946b4f3b5beaf10d45f14e940ee \
--hash=sha256:09ed9b63f4df8a7591b7a4a26c1ad066dcaafda1f846250fdcb534074a411692 \
--hash=sha256:0f7672148f8fca48e4efc16aba24a7455b40c22d4f8abe42475dec6a12b0bb9a \
--hash=sha256:0f89f0608a5aa8142ed0e53957916623791a88c7f5e5f07ae530c328beeb888f \
--hash=sha256:128bd7acf569f8443ad5b2227f30ac909e4f5399ed221727eeacf0c6476187e6 \
--hash=sha256:19ea64fa0cf389d2ebc10974616acfa1fdecbd73d1fd9c72215b782f3c40f561 \
--hash=sha256:1fb782982c42cee667b892a0b0c52a9f6c7ecf1da5c5f4345845f04eaa862f93 \
--hash=sha256:218722a29c5c26677d37c44f5f8a372daf6f07870aad793a97d47eb6ad6b3290 \
--hash=sha256:2b5e334330d82866923015b455260173cb3b9e3b4e297052d758abd262031289 \
--hash=sha256:2bf5c846c257578b03d498b20f54f53551616a507d8e5463511c58bb58e9a9cf \
--hash=sha256:2d0ad9a85f208473b1f3613c45756c7aa6fcc288266a8c7b873f896aaf741b6b \
--hash=sha256:2f54dcc9bb9390fd0636301ead134d46d5229fe86da0db4d974c0fda349f560e \
--hash=sha256:3109fa601d2a448cec4643abd3a31f972bf05b7c2f2e83df9d3429878f8c10ae \
--hash=sha256:3210d93ad2af742b6a96cf39792f7181822edbb8fe11c3ef29d1583fe637a8d8 \
--hash=sha256:3b3082ade8849130203d461b98c2a061b382c46074b43b4edd5cefd81af92b8a \
--hash=sha256:3c3f2f6b898a40207843ae01970e57e33d22a26b22f23c6a5e07b4716751085f \
--hash=sha256:3ca40d7844b391d90b864c6a6d1bb6b88b09035fb4d866d64d43c4d26fb0ab64 \
--hash=sha256:3cfc067ddde78b76dcbc9684d82688b7d3c5158fa2254a085f9bcb9586c1e2d8 \
--hash=sha256:434942fa2f9019b9ae525fb752dc523800c49a1a28fbd6d9240b0fa959573dcc \
--hash=sha256:46b8e24813e2fb5a3e598c1f8b9ae403e1438cb846a80cc2b33cddf19dddd7f2 \
--hash=sha256:59c827e536bb5e3ef58e06da0faba61fd89a14f30b68bcfeca41f43ca83a1942 \
--hash=sha256:60f37acd4e4227c5a29f737d9a85ca3145c529a8dd4bf70af7f0637c61b49222 \
--hash=sha256:689d7d2a840619915d0abd1ecc6e399fee202f8ad315acda2807f4ca420d0802 \
--hash=sha256:6c02e22cf09996194bcb3a4784099975cf527d5c29caf759abadf29ebdb2fe27 \
--hash=sha256:79908b9d02b1d6c1c71ff3b7ad127f3f82e14a8e091ab44b3c7e34b649fea733 \
--hash=sha256:7c9e99aac7b430f6a9f15eebf058c742097cea3369f23a2bfc5e64d374b67e3a \
--hash=sha256:813c2944e940ef8dccea71305bacc942d4b193a021140874b3e58933ec44f5b6 \
--hash=sha256:87121b9428ac568fb84fae4af5e7852fc34f02eadc4e3e91f6c8989327692186 \
--hash=sha256:896631cd40222aef3697e4e51177d14c3709fda49d30983269d584f034acc8a4 \
--hash=sha256:970a4be7ec84ccda7c27cb4ae74930bbbd477bc8d849ed55ea798084dd5fca8c \
--hash=sha256:9939796abcadb2810a63dfb26ff8ca4595fe7dd70a3ceae7f607a2639b714307 \
--hash=sha256:99a8071e351b50827ad976b92ed91845fb614ac67a3c41109b24f3d8bd3afada \
--hash=sha256:9c16fa267740d67883899e054cccb4279e002f3f4872873b752c1ba15045ff49 \
--hash=sha256:a30e95274f5c0e007ccc759ec258aa5708c534ec058f153ee25ac700a2f1438b \
--hash=sha256:a74afd8d560eaafe0d9e3e1db8c06081282a05ca4de00ee416195085a79d7d3d \
--hash=sha256:b46a04588ceb7cf132568e0e564a854627ef87a1ed3bf536234540a79ced44b0 \
--hash=sha256:b4963cf08f4320d98ae72ec7694291b8ab85cb7da3b0cd824bc32701bc992edf \
--hash=sha256:b50e481a4317a8efcfffcfddcd4c9b36eacba440440e70cbe0256aeb6fd6abae \
--hash=sha256:b85553699862c09937a7a5ea14ee6229087971a7d51ae97d5f4b407f571a2c17 \
--hash=sha256:bcc98d397c3dea45d5b262029564b29cb8e945f2607a38bee6163694c0a8b4ef \
--hash=sha256:bed3a2a823a041e8d249b1a7ec132933e1505299329b5cfe1b2b5ec689ec7675 \
--hash=sha256:bf6b4c0c723664f65c2a47c8cb6ebbf660b0b2e2d936adf2e8503d4e93359465 \
--hash=sha256:bfd634cb9685161b2a51f73a7fc4736fd0d67a56632d52319317afaa27f08243 \
--hash=sha256:c0d5ee865b5fd16bf62d72122aadcc90aab296c30c1adb0a32b4b66bd843163e \
--hash=sha256:c2b4eab7c12f9cb460509bc34a3b086f9802f0dba27c89a63df4123819ad64af \
--hash=sha256:c51568e94f7f232296de30002f2a50f77a7bd346673da3e4f2aaf9d2b833f2e5 \
--hash=sha256:c5158616ab39d34b76c50f40c81552ee180598f7825dc7a66fd187d29958820f \
--hash=sha256:cdacddb18d55ffec42d1907079cdc04ec4fa8a990cdf5b9d9fe67d281fc0d12e \
--hash=sha256:ce763369e646e59e4ca2c09735cd1bdd3048d909ad5f2bc116e83166a9352f3c \
--hash=sha256:d45822b0a2a452327ab4f95efe368d234d5294bbf89a99968be27c7938a21108 \
--hash=sha256:d8150579bf30cdf896906baf256aa200cd50dbe6e565c17d6fd3d678e21ff5de \
--hash=sha256:d88814befbd1433152c5f6dd536905149ba028d795a22555b149ae0a36024d9e \
--hash=sha256:dca5be83a6dfaf933a46e3bc2b9f2685e5ec61b22f6a38ad740aac9c16e9a0ff \
--hash=sha256:dd084459ecdb224e617e4ab3f1d5ebe4d1c48facb41f24952b76aa6ba9712bb0 \
--hash=sha256:def9a01b73c9e27d70ea03b381fb3e7aadfac1f398dbd63751313c3a46747ef5 \
--hash=sha256:df65f4b2b4e74977f0336bef12a88051ab24e6a16873cd9249f34d67cb3e345d \
--hash=sha256:dfe924e5a63861c82332a12adeeab955dc8c8009ddbbd80cc2fcca049ff89a49 \
--hash=sha256:e67dcaa34dcd908fcccbf49194211d847c731b6ebaac661c1c889f1bf6af1e44 \
--hash=sha256:eba62b7c21a33e72dd8adab2b92dd5610d8527f0b2ac28a8e0770e71b21a13f9 \
--hash=sha256:ed9c91bf4ce614efed5388c3f989a7cfe08728ab871d995a486ea74ff88993db \
--hash=sha256:f05d311c937da03b0cd26ac3e14cb991f6ff8fc94f98b3df9713537817539795 \
--hash=sha256:f1cc623fd6855b25da52b3275e0c9e51711b86a9dccc75f8c9ab4432fd8e42c7 \
--hash=sha256:f670686d99c867d0f24b28ce8c6f02429c6eef5e2674aab287850d0ee2d20437 \
--hash=sha256:f856652f9b533c6cd2b9ad6836a7fc0e43917d7ff15be46c5baf1350f8cdc5d9 \
--hash=sha256:fb0458d74726937ead9e2effc91144aea5a58ecee9754242f8539a782bed685a
cycler==0.11.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3 \
--hash=sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f
dnspython==2.2.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e \
--hash=sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f
email-validator==1.2.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:6757aea012d40516357c0ac2b1a4c31219ab2f899d26831334c5d069e8b6c3d8 \
--hash=sha256:c8589e691cf73eb99eed8d10ce0e9cbb05a0886ba920c8bcb7c82873f4c5789c
email-validator==1.3.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:553a66f8be2ec2dea641ae1d3f29017ab89e9d603d4a25cdaac39eefa283d769 \
--hash=sha256:816073f2a7cffef786b29928f58ec16cdac42710a53bb18aa94317e3e145ec5c
flask-admin==1.6.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:424ffc79b7b0dfff051555686ea12e86e48dffacac14beaa319fb4502ac40988
flask-login==0.6.2 ; python_version >= "3.10" and python_version < "4.0" \
@ -29,9 +99,9 @@ flask-migrate==3.1.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:a6498706241aba6be7a251078de9cf166d74307bca41a4ca3e403c9d39e2f897
flask-principal==0.4.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:f5d6134b5caebfdbb86f32d56d18ee44b080876a27269560a96ea35f75c99453
flask-security-too==5.0.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:2197fa7fceff6c485aa9774ccc79f653e2f66db25e831b892a002261ede5fc3a \
--hash=sha256:436e3ba05984f010e27741055c69bc12cc45566946e4d805990a2628c3656594
flask-security-too==5.0.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:36fee0da5d1b3d211caf274553b7753478c208997c624abb84ebba4261de65c2 \
--hash=sha256:5f81e220c63f8f319bcd04327267328fd4b58ca05aa6f3ffc458756dfa78d579
flask-sqlalchemy==2.5.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912 \
--hash=sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390
@ -41,9 +111,9 @@ flask-wtf==1.0.1 ; python_version >= "3.10" and python_version < "4.0" \
flask==2.2.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b \
--hash=sha256:b9c46cc36662a7949f34b52d8ec7bb59c0d74ba08ba6cb9ce9adc1d8676d9526
fonttools==4.37.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:4606e1a88ee1f6699d182fea9511bd9a8a915d913eab4584e5226da1180fcce7 \
--hash=sha256:fff6b752e326c15756c819fe2fe7ceab69f96a1dbcfe8911d0941cdb49905007
fonttools==4.37.3 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:a5bc5f5d48faa4085310b8ebd4c5d33bf27c6636c5f10a7de792510af2745a81 \
--hash=sha256:f32ef6ec966cf0e7d2aa88601fed2e3a8f2851c26b5db2c80ccc8f82bee4eedc
greenlet==1.1.3 ; python_version >= "3.10" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and python_version < "4.0" \
--hash=sha256:0118817c9341ef2b0f75f5af79ac377e4da6ff637e5ee4ac91802c0e379dadb4 \
--hash=sha256:048d2bed76c2aa6de7af500ae0ea51dd2267aec0e0f2a436981159053d0bc7cc \
@ -102,9 +172,9 @@ greenlet==1.1.3 ; python_version >= "3.10" and (platform_machine == "aarch64" or
gunicorn==20.1.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e \
--hash=sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8
idna==3.3 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff \
--hash=sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d
idna==3.4 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4 \
--hash=sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2
itsdangerous==2.1.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44 \
--hash=sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a
@ -180,9 +250,9 @@ kiwisolver==1.4.4 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:f6cb459eea32a4e2cf18ba5fcece2dbdf496384413bc1bae15583f19e567f3b2 \
--hash=sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09 \
--hash=sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c
mako==1.2.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:3724869b363ba630a272a5f89f68c070352137b8fd1757650017b7e06fda163f \
--hash=sha256:8efcb8004681b5f71d09c983ad5a9e6f5c40601a6ec469148753292abc0da534
mako==1.2.3 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:7fde96466fcfeedb0eed94f187f20b23d85e4cb41444be0e542e2c8c65c396cd \
--hash=sha256:c413a086e38cd885088d5e165305ee8eed04e8b3f8f62df343480da0a385735f
markupsafe==2.1.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003 \
--hash=sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88 \
@ -224,42 +294,48 @@ markupsafe==2.1.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933 \
--hash=sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a \
--hash=sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7
matplotlib==3.5.3 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:0bcdfcb0f976e1bac6721d7d457c17be23cf7501f977b6a38f9d38a3762841f7 \
--hash=sha256:1e64ac9be9da6bfff0a732e62116484b93b02a0b4d4b19934fb4f8e7ad26ad6a \
--hash=sha256:22227c976ad4dc8c5a5057540421f0d8708c6560744ad2ad638d48e2984e1dbc \
--hash=sha256:2886cc009f40e2984c083687251821f305d811d38e3df8ded414265e4583f0c5 \
--hash=sha256:2e6d184ebe291b9e8f7e78bbab7987d269c38ea3e062eace1fe7d898042ef804 \
--hash=sha256:3211ba82b9f1518d346f6309df137b50c3dc4421b4ed4815d1d7eadc617f45a1 \
--hash=sha256:339cac48b80ddbc8bfd05daae0a3a73414651a8596904c2a881cfd1edb65f26c \
--hash=sha256:35a8ad4dddebd51f94c5d24bec689ec0ec66173bf614374a1244c6241c1595e0 \
--hash=sha256:3b4fa56159dc3c7f9250df88f653f085068bcd32dcd38e479bba58909254af7f \
--hash=sha256:43e9d3fa077bf0cc95ded13d331d2156f9973dce17c6f0c8b49ccd57af94dbd9 \
--hash=sha256:57f1b4e69f438a99bb64d7f2c340db1b096b41ebaa515cf61ea72624279220ce \
--hash=sha256:5c096363b206a3caf43773abebdbb5a23ea13faef71d701b21a9c27fdcef72f4 \
--hash=sha256:6bb93a0492d68461bd458eba878f52fdc8ac7bdb6c4acdfe43dba684787838c2 \
--hash=sha256:6ea6aef5c4338e58d8d376068e28f80a24f54e69f09479d1c90b7172bad9f25b \
--hash=sha256:6fe807e8a22620b4cd95cfbc795ba310dc80151d43b037257250faf0bfcd82bc \
--hash=sha256:73dd93dc35c85dece610cca8358003bf0760d7986f70b223e2306b4ea6d1406b \
--hash=sha256:839d47b8ead7ad9669aaacdbc03f29656dc21f0d41a6fea2d473d856c39c8b1c \
--hash=sha256:874df7505ba820e0400e7091199decf3ff1fde0583652120c50cd60d5820ca9a \
--hash=sha256:879c7e5fce4939c6aa04581dfe08d57eb6102a71f2e202e3314d5fbc072fd5a0 \
--hash=sha256:94ff86af56a3869a4ae26a9637a849effd7643858a1a04dd5ee50e9ab75069a7 \
--hash=sha256:99482b83ebf4eb6d5fc6813d7aacdefdd480f0d9c0b52dcf9f1cc3b2c4b3361a \
--hash=sha256:9ab29589cef03bc88acfa3a1490359000c18186fc30374d8aa77d33cc4a51a4a \
--hash=sha256:9befa5954cdbc085e37d974ff6053da269474177921dd61facdad8023c4aeb51 \
--hash=sha256:a206a1b762b39398efea838f528b3a6d60cdb26fe9d58b48265787e29cd1d693 \
--hash=sha256:ab8d26f07fe64f6f6736d635cce7bfd7f625320490ed5bfc347f2cdb4fae0e56 \
--hash=sha256:b28de401d928890187c589036857a270a032961411934bdac4cf12dde3d43094 \
--hash=sha256:b428076a55fb1c084c76cb93e68006f27d247169f056412607c5c88828d08f88 \
--hash=sha256:bf618a825deb6205f015df6dfe6167a5d9b351203b03fab82043ae1d30f16511 \
--hash=sha256:c995f7d9568f18b5db131ab124c64e51b6820a92d10246d4f2b3f3a66698a15b \
--hash=sha256:cd45a6f3e93a780185f70f05cf2a383daed13c3489233faad83e81720f7ede24 \
--hash=sha256:d2484b350bf3d32cae43f85dcfc89b3ed7bd2bcd781ef351f93eb6fb2cc483f9 \
--hash=sha256:d62880e1f60e5a30a2a8484432bcb3a5056969dc97258d7326ad465feb7ae069 \
--hash=sha256:dacddf5bfcec60e3f26ec5c0ae3d0274853a258b6c3fc5ef2f06a8eb23e042be \
--hash=sha256:f3840c280ebc87a48488a46f760ea1c0c0c83fcf7abbe2e6baf99d033fd35fd8 \
--hash=sha256:f814504e459c68118bf2246a530ed953ebd18213dc20e3da524174d84ed010b2
matplotlib==3.6.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:0958fc3fdc59c1b716ee1a5d14e73d03d541d873241a37c5c3a86f7ef6017923 \
--hash=sha256:0ae1b9b555212c1e242666af80e7ed796705869581e2d749971db4e682ccc1f3 \
--hash=sha256:11c1987b803cc2b26725659cfe817478f0a9597878e5c4bf374cfe4e12cbbd79 \
--hash=sha256:140316427a7c384e3dd37efb3a73cd67e14b0b237a6d277def91227f43cdcec2 \
--hash=sha256:1559213b803959a2b8309122585b5226d1c2fb66c933b1a2094cf1e99cb4fb90 \
--hash=sha256:16a899b958dd76606b571bc7eaa38f09160c27dfb262e493584644cfd4a77f0f \
--hash=sha256:1739935d293d0348d7bf662e8cd0edb9c2aa8f20ccd646db755ce0f3456d24e4 \
--hash=sha256:1a4835c177821f3729be27ae9be7b8ae209fe75e83db7d9b2bfd319a998f0a42 \
--hash=sha256:2b60d4abcb6a405ca7d909c80791b00637d22c62aa3bb0ffff7e589f763867f5 \
--hash=sha256:2ed779a896b70c8012fe301fb91ee37e713e1dda1eb8f37de04cdbf506706983 \
--hash=sha256:3ec2edf7f74829eae287aa53d64d83ad5d43ee51d29fb1d88e689d8b36028312 \
--hash=sha256:408bbf968c15e9e38df9f25a588e372e28a43240cf5884c9bc6039a5021b7d5b \
--hash=sha256:4699bb671dbc4afdb544eb893e4deb8a34e294b7734733f65b4fd2787ba5fbc6 \
--hash=sha256:4eba6972b796d97c8fcc5266b6dc42ef27c2dce4421b846cded0f3af851b81c9 \
--hash=sha256:51092d13499be72e47c15c3a1ae0209edaca6be42b65ffbbefbe0c85f6153c6f \
--hash=sha256:62319d57dab5ad3e3494dd97a214e22079d3f72a0c8a2fd001829c2c6abbf8d1 \
--hash=sha256:657fb7712185f82211170ac4debae0800ed4f5992b8f7ebba2a9eabaf133a857 \
--hash=sha256:66a0db13f77aa7806dba29273874cf862450c61c2e5158245d17ee85d983fe8e \
--hash=sha256:6b98e098549d3aea2bfb93f38f0b2ecadcb423fa1504bbff902c01efdd833fd8 \
--hash=sha256:7127e2b94571318531caf098dc9e8f60f5aba1704600f0b2483bf151d535674a \
--hash=sha256:798559837156b8e2e2df97cffca748c5c1432af6ec5004c2932e475d813f1743 \
--hash=sha256:802feae98addb9f21707649a7f229c90a59fad34511881f20b906a5e8e6ea475 \
--hash=sha256:89e1978c3fbe4e3d4c6ad7db7e6f982607cb2546f982ccbe42708392437b1972 \
--hash=sha256:9295ca10a140c21e40d2ee43ef423213dc20767f6cea6b87c36973564bc51095 \
--hash=sha256:9711ef291e184b5a73c9d3af3f2d5cfe25d571c8dd95aa498415f74ac7e221a8 \
--hash=sha256:b0320f882214f6ffde5992081520b57b55450510bdaa020e96aacff9b7ae10e6 \
--hash=sha256:b5bd3b3ff191f81509d9a1afd62e1e3cda7a7889c35b5b6359a1241fe1511015 \
--hash=sha256:baa19508d8445f5648cd1ffe4fc6d4f7daf8b876f804e9a453df6c3708f6200b \
--hash=sha256:c5108ebe67da60a9204497d8d403316228deb52b550388190c53a57394d41531 \
--hash=sha256:ccea337fb9a44866c5300c594b13d4d87e827ebc3c353bff15d298bac976b654 \
--hash=sha256:cd73a16a759865831be5a8fb6546f2a908c8d7d7f55c75f94ee7c2ca13cc95de \
--hash=sha256:d840712f4b4c7d2a119f993d7e43ca9bcaa73aeaa24c322fa2bdf4f689a3ee09 \
--hash=sha256:df26a09d955b3ab9b6bc18658b9403ed839096c97d7abe8806194e228a485a3c \
--hash=sha256:e01382c06ac3710155a0ca923047c5abe03c676d08f03e146c6a240d0a910713 \
--hash=sha256:e572c67958f7d55eae77f5f64dc7bd31968cc9f24c233926833efe63c60545f2 \
--hash=sha256:eca6f59cd0729edaeaa7032d582dffce518a420d4961ef3e8c93dce86be352c3 \
--hash=sha256:efd2e12f8964f8fb4ba1984df71d85d02ef0531e687e59f78ec8fc07271a3857 \
--hash=sha256:efe9e8037b989b14bb1887089ae763385431cc06fe488406413079cfd2a3a089 \
--hash=sha256:f0d5b9b14ccc7f539143ac9eb1c6b57d26d69ca52d30c3d719a7bc4123579e44 \
--hash=sha256:f1954d71cdf15c19e7f3bf2235a4fe1600ba42f34d472c9495bcf54d75a43e4e \
--hash=sha256:fbbceb0a0dfe9213f6314510665a32ef25fe29b50657567cd00115fbfcb3b20d
numpy==1.23.3 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:004f0efcb2fe1c0bd6ae1fcfc69cc8b6bf2407e0f18be308612007a0762b4089 \
--hash=sha256:09f6b7bdffe57fc61d869a22f506049825d707b288039d30f26a0d0d8ea05164 \
@ -360,9 +436,9 @@ pyparsing==3.0.9 ; python_version >= "3.10" and python_version < "4.0" \
python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \
--hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9
setuptools-scm==6.4.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:6833ac65c6ed9711a4d5d2266f8024cfa07c533a0e55f4c12f6eff280a5a9e30 \
--hash=sha256:acea13255093849de7ccb11af9e1fb8bde7067783450cee9ef7a93139bddf6d4
setuptools-scm==7.0.5 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:031e13af771d6f892b941adb6ea04545bbf91ebc5ce68c78aaf3fff6e1fb4844 \
--hash=sha256:7930f720905e03ccd1e1d821db521bff7ec2ac9cf0ceb6552dd73d24a45d3b02
setuptools==65.3.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:2e24e0bec025f035a2e72cdd1961119f557d78ad331bb00ff82efb2ab8da8e82 \
--hash=sha256:7732871f4f7fa58fb6bdcaeadb0161b2bd046c85905dbaa066bdcbcc81953b57
@ -414,6 +490,9 @@ sqlalchemy==1.4.41 ; python_version >= "3.10" and python_version < "4.0" \
tomli==2.0.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \
--hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f
typing-extensions==4.3.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02 \
--hash=sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6
werkzeug==2.2.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f \
--hash=sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5