Added: Postgres Support

This commit is contained in:
DerGrumpf 2025-03-08 15:47:02 +01:00
parent 2d25d5105a
commit 4b82b11072
38 changed files with 451 additions and 149153 deletions

3
.gitignore vendored
View File

@ -1,5 +1,6 @@
# ---> Custom # ---> Custom
state pickles/*
assets/documents/*
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files

View File

@ -9,7 +9,7 @@ Nova,Eib,Female,MeWi4,30%,Medienwissenschaften,31,15,15,34,20,20,21,27,19,21
Lena,Fricke,Female,MeWi4,30%,Medienwissenschaften,13,14,15,21,15,17,17,18,19,11 Lena,Fricke,Female,MeWi4,30%,Medienwissenschaften,13,14,15,21,15,17,17,18,19,11
Nele,Grundke,Female,MeWi6,30%,Medienwissenschaften,23.5,13,16,28,20,17,21,18,22,11 Nele,Grundke,Female,MeWi6,30%,Medienwissenschaften,23.5,13,16,28,20,17,21,18,22,11
Anna,Grünewald,Female,MeWi3,30%,Medienwissenschaften,12,14,16,29,16,15,19,9,16,12 Anna,Grünewald,Female,MeWi3,30%,Medienwissenschaften,12,14,16,29,16,15,19,9,16,12
Yannik,Haupt,Male,NoGroup,30%,Unknown,18,6,14,21,13,2,9,0,0,0 Yannik,Haupt,Male,NoGroup,30%,Student,18,6,14,21,13,2,9,0,0,0
Janna,Heiny,Female,MeWi1,30%,Technologie Orientiertes Managment,30,15,18,33,18,20,22,25,24,30 Janna,Heiny,Female,MeWi1,30%,Technologie Orientiertes Managment,30,15,18,33,18,20,22,25,24,30
Milena,Krieger,Female,MeWi1,30%,Technologie Orientiertes Managment,30,15,18,33,20,20,21.5,26,20,22 Milena,Krieger,Female,MeWi1,30%,Technologie Orientiertes Managment,30,15,18,33,20,20,21.5,26,20,22
Julia,Limbach,Female,MeWi6,30%,Medienwissenschaften,27.5,12,18,29,11,19,17.5,26,24,28 Julia,Limbach,Female,MeWi6,30%,Medienwissenschaften,27.5,12,18,29,11,19,17.5,26,24,28
@ -32,7 +32,5 @@ Inga-Brit,Turschner,Female,MeWi2,30%,Medienwissenschaften,25.5,14,18,34,20,16,19
Alea,Unger,Female,MeWi5,30%,Medienwissenschaften,30,12,18,31,20,20,21,22,15,21.5 Alea,Unger,Female,MeWi5,30%,Medienwissenschaften,30,12,18,31,20,20,21,22,15,21.5
Marie,Wallbaum,Female,MeWi5,30%,Medienwissenschaften,28.5,14,18,34,17,20,19,24,12,22 Marie,Wallbaum,Female,MeWi5,30%,Medienwissenschaften,28.5,14,18,34,17,20,19,24,12,22
Katharina,Walz,Female,MeWi4,30%,Medienwissenschaften,31,15,18,31,19,19,17,24,17,14.5 Katharina,Walz,Female,MeWi4,30%,Medienwissenschaften,31,15,18,31,19,19,17,24,17,14.5
Xiaowei,Wang,Male,NoGroup,30%,Unknown,30.5,14,18,26,19,17,0,0,0,0 Xiaowei,Wang,Male,NoGroup,30%,Student,30.5,14,18,26,19,17,0,0,0,0
Lilly-Lu,Warnken,Female,DiKum,30%,Medienwissenschaften,30,15,18,30,14,17,19,14,16,24 Lilly-Lu,Warnken,Female,DiKum,30%,Medienwissenschaften,30,15,18,30,14,17,19,14,16,24
,,,,,,,,,,,,,,,

1 First Name Last Name Sex Group Grader Study Tutorial 1 Tutorial 2 Extended Applications Numpy & MatPlotLib SciPy Monte Carlo Pandas & Seaborn Folium Statistical Test Methods Data Analysis
9 Lena Fricke Female MeWi4 30% Medienwissenschaften 13 14 15 21 15 17 17 18 19 11
10 Nele Grundke Female MeWi6 30% Medienwissenschaften 23.5 13 16 28 20 17 21 18 22 11
11 Anna Grünewald Female MeWi3 30% Medienwissenschaften 12 14 16 29 16 15 19 9 16 12
12 Yannik Haupt Male NoGroup 30% Unknown Student 18 6 14 21 13 2 9 0 0 0
13 Janna Heiny Female MeWi1 30% Technologie Orientiertes Managment 30 15 18 33 18 20 22 25 24 30
14 Milena Krieger Female MeWi1 30% Technologie Orientiertes Managment 30 15 18 33 20 20 21.5 26 20 22
15 Julia Limbach Female MeWi6 30% Medienwissenschaften 27.5 12 18 29 11 19 17.5 26 24 28
32 Alea Unger Female MeWi5 30% Medienwissenschaften 30 12 18 31 20 20 21 22 15 21.5
33 Marie Wallbaum Female MeWi5 30% Medienwissenschaften 28.5 14 18 34 17 20 19 24 12 22
34 Katharina Walz Female MeWi4 30% Medienwissenschaften 31 15 18 31 19 19 17 24 17 14.5
35 Xiaowei Wang Male NoGroup 30% Unknown Student 30.5 14 18 26 19 17 0 0 0 0
36 Lilly-Lu Warnken Female DiKum 30% Medienwissenschaften 30 15 18 30 14 17 19 14 16 24

Binary file not shown.

View File

@ -31,12 +31,14 @@ groups = {
} }
print(df) print(df)
init_db('WiSe_24_25.db') #init_db('WiSe_24_25.db')
init_postgres('postgresql://admin:admin@127.0.0.1/learnlytics')
db.drop_tables(tables)
db.create_tables(tables)
# Create Class # Create Class
clas = Class.create(name='WiSe 24/25') clas = Class.create(name='WiSe 24/25')
#print(clas.id)
Study.create(name='Student')
# Create Courses # Create Courses
for k, v in courses.items(): for k, v in courses.items():

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,8 @@
from .utils import ( from .utils import (
tables, tables,
table_labels, table_labels,
init_db, init_local,
init_postgres,
save_as_json, save_as_json,
create_from_json create_from_json
) )

View File

@ -9,17 +9,18 @@ Online Viewer: https://dbdiagram.io
from peewee import * from peewee import *
from playhouse.db_url import connect
from datetime import datetime from datetime import datetime
db = DatabaseProxy() db = DatabaseProxy()
# WIP: Add Switch Function # WIP: Add Switch Function
if True: #if False:
database = SqliteDatabase(None, autoconnect=False) # database = SqliteDatabase(None, autoconnect=False)
else: #else:
database = PostgresqlDatabase(None, autoconnect=False) # database = connect('postgresql://admin:admin@127.0.0.1:5432/learnlytics')
db.initialize(database) #db.initialize(database)
class BaseModel(Model): class BaseModel(Model):
''' '''
@ -94,4 +95,3 @@ class Submission(BaseModel):
points = FloatField() points = FloatField()
created_at = DateTimeField(default=datetime.now) created_at = DateTimeField(default=datetime.now)

View File

@ -12,6 +12,7 @@ import sys, inspect, json
from datetime import datetime, date from datetime import datetime, date
from pathlib import Path from pathlib import Path
from playhouse.shortcuts import model_to_dict, dict_to_model from playhouse.shortcuts import model_to_dict, dict_to_model
from playhouse.db_url import connect
from .model import * from .model import *
class DateTimeEncoder(json.JSONEncoder): class DateTimeEncoder(json.JSONEncoder):
@ -59,7 +60,7 @@ def get_module_cls(module: str) -> tuple[tuple[str, object]]:
tables = tuple(table[1] for table in get_module_cls('dbmodel.model') if not table[1] is BaseModel) tables = tuple(table[1] for table in get_module_cls('dbmodel.model') if not table[1] is BaseModel)
table_labels = tuple(table[0] for table in get_module_cls('dbmodel.model') if not table[1] is BaseModel) table_labels = tuple(table[0] for table in get_module_cls('dbmodel.model') if not table[1] is BaseModel)
def init_db(name: Path | str) -> None: def init_local(name: Path | str) -> None:
''' '''
(Creates,) Connects and Initializes the db descriptor from a given *.db file (Creates,) Connects and Initializes the db descriptor from a given *.db file
''' '''
@ -71,14 +72,23 @@ def init_db(name: Path | str) -> None:
if type(name) is Path: if type(name) is Path:
name = str(name) name = str(name)
# Switch Database; Initialize if needed database = SqliteDatabase(None, autoconnect=False)
if not db.is_closed(): db.initialize(database)
db.close()
db.init(name) db.init(name)
db.connect() db.connect()
db.create_tables(tables) # Ensure tables exist db.create_tables(tables) # Ensure tables exist
def init_postgres(url: str) -> None:
assert isinstance(url, str), "Provided url isnt a String"
database = connect(url)
db.initialize(database)
db.connect()
db.create_tables(tables)
def save_as_json(filename: str, path: Path | str = Path('.')) -> Path: def save_as_json(filename: str, path: Path | str = Path('.')) -> Path:
''' '''
Saves the current loaded Database as <filename>.json to given <path> Saves the current loaded Database as <filename>.json to given <path>

View File

@ -7,9 +7,14 @@ class AnalyzerState:
return cls._instance return cls._instance
def __init__(self): def __init__(self):
self.class_id = 1 self.class_id = -1
self.group_id = 1 self.group_id = -1
self.student_id = 1 self.student_id = -1
def reset(self):
self.class_id = -1
self.group_id = -1
self.student_id = -1
def __str__(self): def __str__(self):
return f''' return f'''

View File

@ -167,7 +167,6 @@ def add_lecture(class_id: int) -> bool:
@immapp.static(inited=False) @immapp.static(inited=False)
def group_list(class_id: int) -> None: def group_list(class_id: int) -> None:
imgui_md.render_unindented('# Groups') imgui_md.render_unindented('# Groups')
if class_id < 1: if class_id < 1:
return return
@ -340,7 +339,7 @@ def add_student(class_id: int) -> bool:
statics.prename = str() statics.prename = str()
statics.surname = str() statics.surname = str()
statics.genders = ["Male", "Female"] statics.genders = ["Male", "Female", "Diverse"]
statics.gender_select = 0 statics.gender_select = 0
statics.studys = list(Study.select()) statics.studys = list(Study.select())
@ -367,14 +366,22 @@ def add_student(class_id: int) -> bool:
_, statics.prename = imgui.input_text("First Name", statics.prename) _, statics.prename = imgui.input_text("First Name", statics.prename)
_, statics.surname = imgui.input_text("Last Name", statics.surname) _, statics.surname = imgui.input_text("Last Name", statics.surname)
_, statics.gender_select = imgui.combo("Gender", statics.gender_select, statics.genders) imgui.text("Gender")
for n, gender in enumerate(statics.genders):
changed = imgui.radio_button(gender, statics.gender_select == n)
if changed:
statics.gender_select = n
_, statics.study_select = imgui.combo("Study Progamm", statics.study_select, statics.study_labels) _, statics.study_select = imgui.combo("Study Progamm", statics.study_select, statics.study_labels)
_, statics.group_select = imgui.combo("Group", statics.group_select, statics.group_labels) _, statics.group_select = imgui.combo("Group", statics.group_select, statics.group_labels)
_, statics.grader_select = imgui.combo("Grader", statics.grader_select, statics.graders) imgui.text("Grader")
for n, grader in enumerate(statics.graders):
changed = imgui.radio_button(grader, statics.grader_select == n)
if changed:
statics.grader_select = n
if imgui.button("Add"): if imgui.button("Add"):
s = Student.create( s = Student.create(
@ -408,6 +415,49 @@ def add_student(class_id: int) -> bool:
return False return False
@immapp.static(inited=False)
def study_list() -> None:
statics = study_list
if not statics.inited:
statics.studys = Study.select().order_by(Study.id)
statics.new_study = str()
statics.inited = True
imgui_md.render_unindented("# Study Programms")
if imgui.button("Add Study"):
Study.create(name=statics.new_study)
statics.inited = False
imgui.same_line()
_, statics.new_study = imgui.input_text("##study", statics.new_study)
if imgui.begin_table("Studys", 2, imgui.TableFlags_.sizing_fixed_fit.value):
for study in statics.studys:
if study.name == 'Student':
continue
imgui.table_next_row()
imgui.table_next_column()
if imgui.button(f"X##{study.id}"):
students = Student.select().where(Student.study_id == study.id)
nostudy = Study.select().where(Study.name == "Student").get()
for student in students:
student.study_id = nostudy.id
with db.atomic():
Student.bulk_update(students, fields=[Student.study_id], batch_size=50)
study.delete_instance()
statics.inited = False
imgui.end_table()
return
imgui.same_line()
imgui.text(study.name)
#imgui.table_next_column()
#imgui.text(f"{group.project}")
imgui.end_table()
def class_graph() -> None: def class_graph() -> None:
if db.is_closed(): if db.is_closed():
@ -440,4 +490,8 @@ def class_graph() -> None:
if imgui.begin_child("Ranking", ImVec2(w1/3, h1), imgui.ChildFlags_.borders.value): if imgui.begin_child("Ranking", ImVec2(w1/3, h1), imgui.ChildFlags_.borders.value):
ranking(state.class_id) ranking(state.class_id)
imgui.end_child() imgui.end_child()
imgui.same_line()
if imgui.begin_child("Studys", ImVec2(w1/3, h1), imgui.ChildFlags_.borders.value):
study_list()
imgui.end_child()
imgui.end_child() imgui.end_child()

View File

@ -52,6 +52,19 @@ def load_properties() -> DocumentProperties:
except FileNotFoundError: except FileNotFoundError:
return None return None
file = Path('./pickles/document_properties.pkl')
try:
file.resolve(strict=True)
except FileNotFoundError:
dump_properties(DocumentProperties(
logo = Path("./assets/learnlytics.svg"),
save_dir = Path.home(),
file_name = "document",
author = "Learnlytics"
))
@immapp.static(inited=False) @immapp.static(inited=False)
def document_properties(class_id: int) -> None: def document_properties(class_id: int) -> None:
# TO DO: Store Properties persistent # TO DO: Store Properties persistent
@ -137,14 +150,21 @@ def document_properties(class_id: int) -> None:
return statics.properties return statics.properties
@immapp.static(inited=False) @immapp.static(inited=False)
def filters() -> list[Study]: def filters(class_id: int) -> list[Study]:
statics = filters statics = filters
if not statics.inited: if not statics.inited:
statics.class_id = class_id
statics.studys = list(Study.select()) statics.studys = list(Study.select())
statics.checked = [True] * len(statics.studys) statics.checked = [True] * len(statics.studys)
statics.inited = True statics.inited = True
if statics.class_id != class_id:
statics.inited = False
if Study.select().count() != len(statics.studys):
statics.inited = False
imgui_md.render_unindented("### Study Filters") imgui_md.render_unindented("### Study Filters")
imgui.text("") imgui.text("")
@ -219,7 +239,7 @@ def create_document(class_id: int) -> None:
if imgui.begin_child("Filters", ImVec2(w*0.54, h), imgui.ChildFlags_.borders.value): if imgui.begin_child("Filters", ImVec2(w*0.54, h), imgui.ChildFlags_.borders.value):
w1, h1 = imgui.get_content_region_avail() w1, h1 = imgui.get_content_region_avail()
if imgui.begin_child("Study Filters", ImVec2(w1, h1*0.2)): if imgui.begin_child("Study Filters", ImVec2(w1, h1*0.2)):
study_filters = filters() study_filters = filters(statics.class_id)
imgui.end_child() imgui.end_child()
if imgui.begin_child("Order By", ImVec2(w1, h1*0.2)): if imgui.begin_child("Order By", ImVec2(w1, h1*0.2)):
@ -230,17 +250,17 @@ def create_document(class_id: int) -> None:
properties = document_properties(statics.class_id) properties = document_properties(statics.class_id)
if imgui.button("Create"): if imgui.button("Create"):
pdf, css = get_pdf(statics.class_id) pdf, css = get_pdf(statics.class_id)
header = get_pdf_header(state.class_id, [study.name for study in study_filters], properties.author, properties.logo) header = get_pdf_header_creator(state.class_id, [study.name for study in study_filters], properties.author, properties.logo)
match statics.order_by: match statics.order_by:
case "Group": case "Group":
sections = create_group_pdf(study_filters) sections = create_group_pdf(statics.class_id, study_filters)
case "Study Program": case "Study Program":
sections = create_study_pdf(study_filters) sections = create_study_pdf(statics.class_id, study_filters)
case 'Ranking': case 'Ranking':
sections = create_ranking_pdf(study_filters) sections = create_ranking_pdf(statics.class_id, study_filters)
pdf.add_section(header, user_css=css) pdf.add_section(header, user_css=css)
for section in sections: for section in sections:

View File

@ -2,18 +2,21 @@ from imgui_bundle import (
imgui, imgui,
imgui_md, imgui_md,
immapp, immapp,
im_file_dialog,
ImVec2, ImVec2,
ImVec4 ImVec4
) )
import numpy as np import numpy as np
from peewee import fn from peewee import fn
from pathlib import Path
from dbmodel import * from dbmodel import *
from grader import get_gradings, get_grader from grader import get_gradings, get_grader
from pdf import *
from .analyzer_state import AnalyzerState from .analyzer_state import AnalyzerState
from .plotter import plot_bar_line_percentage from .plotter import plot_bar_line_percentage
from .document_creator import load_properties
state = AnalyzerState() state = AnalyzerState()
PROGRESS_BAR_COLOR = ImVec4(190, 190, 40, 255)/255 PROGRESS_BAR_COLOR = ImVec4(190, 190, 40, 255)/255
@ -43,7 +46,15 @@ def header(student_id: int, reset: bool) -> None:
imgui_md.render_unindented(f"# {statics.student.prename} {statics.student.surname} - {statics.has_passed}") imgui_md.render_unindented(f"# {statics.student.prename} {statics.student.surname} - {statics.has_passed}")
imgui_md.render_unindented(f"Degree Program: **{statics.study.name}**") imgui_md.render_unindented(f"Degree Program: **{statics.study.name}**")
imgui.same_line()
if imgui.button("Create Summary"):
properties = load_properties()
pdf, css = get_pdf(statics.student.class_id)
sections = get_pdf_student(statics.student.id, properties.author, properties.logo)
for section in sections:
pdf.add_section(section, user_css = css)
save = properties.save_dir / (properties.file_name + '.pdf')
pdf.save(save)
@immapp.static(inited=False) @immapp.static(inited=False)
def plot(student_id: int, reset: bool) -> None: def plot(student_id: int, reset: bool) -> None:
@ -96,7 +107,9 @@ def dialog(student_id: int) -> None:
statics.prename = statics.student.prename statics.prename = statics.student.prename
statics.surname = statics.student.surname statics.surname = statics.student.surname
statics.sex = 0 if statics.student.sex == "Male" else 1
statics.genders = ["Male", "Female", "Diverse"]
statics.sex = statics.genders.index(statics.student.sex)
statics.studys = list(Study.select()) statics.studys = list(Study.select())
s = Study.get_by_id(statics.student.study_id) s = Study.get_by_id(statics.student.study_id)
@ -131,7 +144,7 @@ def dialog(student_id: int) -> None:
imgui.table_next_column() imgui.table_next_column()
imgui.text("Sex") imgui.text("Sex")
imgui.table_next_column() imgui.table_next_column()
_, statics.sex = imgui.combo("##Sex", statics.sex, ["Male", "Female"]) _, statics.sex = imgui.combo("##Sex", statics.sex, statics.genders)
imgui.table_next_row() imgui.table_next_row()
imgui.table_next_column() imgui.table_next_column()
@ -156,7 +169,7 @@ def dialog(student_id: int) -> None:
if imgui.button("Save"): if imgui.button("Save"):
statics.student.prename = statics.prename statics.student.prename = statics.prename
statics.student.surname = statics.surname statics.student.surname = statics.surname
statics.student.sex = "Male" if statics.sex == 0 else "Female" statics.student.sex = statics.genders[statics.sex]
statics.student.study_id = statics.studys[statics.study].id statics.student.study_id = statics.studys[statics.study].id
statics.student.group_id = statics.groups[statics.group].id statics.student.group_id = statics.groups[statics.group].id
statics.student.grader = get_grader(statics.graders[statics.grader]).alt_name statics.student.grader = get_grader(statics.graders[statics.grader]).alt_name

View File

@ -18,15 +18,18 @@ def class_selector() -> int:
if not statics.inited: if not statics.inited:
statics.selector = 0 statics.selector = 0
statics.inited = True statics.inited = True
labels = (c.name for c in Class.select())
_, statics.selector = imgui.combo("##Classes", statics.selector, list(labels))
current = Class.select()[statics.selector].id
if imgui.button("New Class"): if imgui.button("New Class"):
imgui.open_popup("NewC") imgui.open_popup("NewC")
new_class() new_class()
classes = Class.select()
if classes.count() < 1:
return -1
labels = (c.name for c in classes)
_, statics.selector = imgui.combo("##Classes", statics.selector, list(labels))
current = classes[statics.selector].id
imgui.same_line() imgui.same_line()
@ -56,6 +59,7 @@ def new_class() -> None:
if imgui.button("Add"): if imgui.button("Add"):
clas = Class.create(name=statics.name) clas = Class.create(name=statics.name)
Study.get_or_create(name='Student')
Group.create( Group.create(
name="NoGroup", name="NoGroup",
project="NoProject", project="NoProject",

View File

@ -4,8 +4,6 @@ from typing import List
from .database import * from .database import *
from .editor import editor
def database_docking_splits() -> List[hello_imgui.DockingSplit]: def database_docking_splits() -> List[hello_imgui.DockingSplit]:
""" """
Defines the docking layout for the database editor. Defines the docking layout for the database editor.
@ -54,17 +52,7 @@ def set_database_editor_layout() -> List[hello_imgui.DockableWindow]:
log.dock_space_name = "CommandSpace2" log.dock_space_name = "CommandSpace2"
log.gui_function = hello_imgui.log_gui log.gui_function = hello_imgui.log_gui
table_view = hello_imgui.DockableWindow() return [file_dialog, log]
table_view.label = "Table"
table_view.dock_space_name = "MainDockSpace"
table_view.gui_function = lambda: table()
eeditor = hello_imgui.DockableWindow()
eeditor.label = "Editor"
eeditor.dock_space_name = "CommandSpace"
eeditor.gui_function = lambda: editor()
return [file_dialog, log, table_view, eeditor]
def database_editor_layout() -> hello_imgui.DockingParams: def database_editor_layout() -> hello_imgui.DockingParams:
""" """

View File

@ -9,6 +9,7 @@ to set up the database editing environment.
# Custom # Custom
from dbmodel import * from dbmodel import *
from gui import * from gui import *
from gui.analyzer.analyzer_state import AnalyzerState
from grader import get_gradings from grader import get_gradings
# External # External
@ -57,7 +58,7 @@ def file_info(path: Path) -> None:
} }
# Create ImGui table to display file information # Create ImGui table to display file information
if imgui.begin_table("File Info", 2): if imgui.begin_table("File Info", 2, imgui.TableFlags_.sizing_fixed_fit.value):
imgui.table_setup_column(" ", 0) imgui.table_setup_column(" ", 0)
imgui.table_setup_column(" ") imgui.table_setup_column(" ")
@ -73,207 +74,90 @@ def file_info(path: Path) -> None:
imgui.end_table() imgui.end_table()
@immapp.static(inited=False, res=False)
def connect_postgres() -> None:
pass
@immapp.static(inited=False)
def select_file() -> None: def select_file() -> None:
""" statics = select_file
Handles the selection and loading of a database file (JSON or SQLite).
It initializes necessary state, allows users to open a file dialog,
and processes the selected file by either loading or converting it.
"""
statics = select_file # Access static variables within the function
# Initialize static variables on the first function call
if not statics.inited: if not statics.inited:
statics.res = None # Stores the selected file result try:
statics.current = None # Stores the currently loaded database file path with open("./pickles/database_location.txt", "r") as f:
statics.file = Path(f.read())
# Retrieve the last used database file from persistent storage except FileNotFoundError:
with shelve.open("state") as state: statics.file = None
statics.current = Path(state.get("DB") or "")
statics.inited = True statics.inited = True
imgui_md.render_unindented('# Database Manager')
# Render UI title and display file information if imgui.button("Create DB"):
imgui_md.render("# Database Manager") im_file_dialog.FileDialog.instance().save(
file_info(statics.current) "CreateDatabase", "Create Database", "Database File (*.db){.db}", str(Path.home())
)
# Button to open the file selection dialog # Handle the file dialog result
if imgui.button("Open File"): if im_file_dialog.FileDialog.instance().is_done("CreateDatabase"):
if im_file_dialog.FileDialog.instance().has_result():
file = im_file_dialog.FileDialog.instance().get_result()
init_db(str(file.path()))
with open("./pickles/database_location.txt", "w") as f:
f.write(str(file.path()))
statics.file = Path(str(file))
im_file_dialog.FileDialog.instance().close()
imgui.same_line()
if imgui.button("Load DB File"):
im_file_dialog.FileDialog.instance().open( im_file_dialog.FileDialog.instance().open(
"SelectDatabase", "Open Database", "Database File (*.json;*.db){.json,.db}" "SelectDatabase", "Open Database", "Database File (*.db){.db}", False, str(Path.home())
) )
# Handle the file dialog result # Handle the file dialog result
if im_file_dialog.FileDialog.instance().is_done("SelectDatabase"): if im_file_dialog.FileDialog.instance().is_done("SelectDatabase"):
if im_file_dialog.FileDialog.instance().has_result(): if im_file_dialog.FileDialog.instance().has_result():
statics.res = im_file_dialog.FileDialog.instance().get_result() file = im_file_dialog.FileDialog.instance().get_result()
init_db(str(file.path()))
with open("./pickles/database_location.txt", "w") as f:
f.write(str(file.path()))
statics.file = Path(str(file))
AnalyzerState().reset()
im_file_dialog.FileDialog.instance().close() im_file_dialog.FileDialog.instance().close()
# Process the selected file if available
if statics.res:
filename = statics.res.filename()
info = Path(statics.res.path())
imgui.separator() # UI separator for clarity
file_info(info) # Display information about the selected file
file = None
# Load the selected database file
if imgui.button("Load"):
# Ensure any currently open database is closed before loading a new one
if not db.is_closed():
db.close()
# Handle JSON files by converting them to SQLite databases
if statics.res.extension() == '.json':
file = filename.removesuffix('.json') + '.db' # Convert JSON filename to SQLite filename
init_db(file)
load_from_json(str(info)) # Convert and load JSON data into the database
# Handle SQLite database files directly
if statics.res.extension() == '.db':
file = str(statics.res.path())
init_db(file)
# Save the selected database path to persistent storage
with shelve.open("state") as state:
state["DB"] = file
# Update application state and reset selection result
statics.res = None
@immapp.static(inited=False) if imgui.button("Export DB"):
def editor() -> None: im_file_dialog.FileDialog.instance().save(
""" "ExportJSON", "Export JSON", "JSON Export (*.json){.json}", str(Path.home())
Class Editor UI Component.
This function initializes and renders the class selection interface within the database editor.
It maintains a static state to keep track of the selected class and fetches available classes.
"""
statics = class_editor
if db.is_closed():
imgui.text("DB")
return
statics.classes = Class.select()
if not statics.inited:
statics.selected = 0
statics.value = statics.classes[statics.selected].name if statics.classes else str()
statics.inited = True
# Fetch available classes from the database
# Render the UI for class selection
imgui_md.render("# Edit Classes")
changed, statics.selected = imgui.combo("##Classes", statics.selected, [c.name for c in statics.classes])
if changed:
statics.value = statics.classes[statics.selected].name
_, statics.value = imgui.input_text("##input_class", statics.value)
if imgui.button("Update"):
clas = statics.classes[statics.selected]
clas.name, old_name = statics.value, clas.name
clas.save()
imgui.same_line()
if imgui.button("New"):
Class.create(name=statics.value)
imgui.same_line()
if imgui.button("Delete"):
clas = statics.classes[statics.selected]
clas.delete_instance()
statics.selected -= 1
statics.value = statics.classes[statics.selected].name
def create_editor_popup(table, action: str, selectors: dict) -> None:
table_flags = (
imgui.TableFlags_.row_bg.value
) )
cols = 2
rows = len(table._meta.fields)
if imgui.begin_table("Editor Grid", cols, table_flags):
# Setup Header
for header in ["Attribute", "Value"]:
imgui.table_setup_column(header, imgui.TableColumnFlags_.width_stretch.value, 1/len(header))
imgui.table_headers_row()
id = 0 # Handle the file dialog result
for k, v in table._meta.fields.items(): if im_file_dialog.FileDialog.instance().is_done("ExportJSON"):
# Don't show Fields if im_file_dialog.FileDialog.instance().has_result():
match type(v): file = im_file_dialog.FileDialog.instance().get_result()
case peewee.AutoField: filename = str(file).removesuffix('.json') + '.db'
continue save_as_json(filename, Path(str(file.path())))
case peewee.DateTimeField: im_file_dialog.FileDialog.instance().close()
continue
imgui.table_next_row()
imgui.table_set_column_index(0)
label = str(k).removesuffix('_id')
imgui.text(label.title())
imgui.table_set_column_index(1)
# Generate Input for Type
match type(v):
case peewee.IntegerField:
if id not in selectors:
selectors[id] = int()
_, selectors[id] = imgui.input_int(f"##{id}", selectors[id], 1)
case peewee.CharField:
if id not in selectors:
if k == 'grader':
selectors[id] = int()
else:
selectors[id] = str()
if k == 'grader':
graders = [g.alt_name for g in get_gradings()]
_, selectors[id] = imgui.combo(f"##{id}", selectors[id], graders)
else:
_, selectors[id] = imgui.input_text(f"##{id}", selectors[id])
case peewee.FloatField:
if id not in selectors:
selectors[id] = float()
_, selectors[id] = imgui.input_float(f"##{id}", selectors[id])
case peewee.ForeignKeyField:
if id not in selectors:
selectors[id] = int()
labels = list()
match k:
case 'class_id':
labels = [clas.name for clas in Class.select()]
case 'lecture_id':
labels = [lecture.title for lecture in Lecture.select()]
if not labels:
imgui.text("No Element for this Attribute")
else:
_, selectors[id] = imgui.combo(f"##{id}", selectors[id], labels)
case _:
imgui.text(f"Unknown Field {k}")
id += 1
imgui.end_table()
if imgui.button(action):
match action:
case "Create":
print("Create")
case "Update":
print("Update")
case "Delete":
print("Delete")
case _:
print("Unknown Case")
# Clear & Close Popup
selectors.clear()
imgui.close_current_popup()
imgui.same_line()
if imgui.button("Load JSON"):
im_file_dialog.FileDialog.instance().open(
"SelectJSON", "Open JSON Export", "JSON Export (*.json){.json}", False, str(Path.home())
)
if im_file_dialog.FileDialog.instance().is_done("SelectJSON"):
if im_file_dialog.FileDialog.instance().has_result():
file = im_file_dialog.FileDialog.instance().get_result()
filename = str(file).removesuffix('.json') + '.db'
create_from_json(filename, Path(str(file.path())))
with open("./pickles/database_location.txt", "w") as f:
f.write(str(file.path()))
statics.file = Path(filename)
AnalyzerState().reset()
im_file_dialog.FileDialog.instance().close()
if imgui.button("Connect Postgres (WIP)"):
connect_postgres()
imgui.separator()
if statics.file:
file_info(statics.file)

View File

@ -1,141 +0,0 @@
from imgui_bundle import (
imgui,
immapp,
imgui_md,
hello_imgui
)
from grader import get_gradings, get_grader
from dbmodel import *
@immapp.static(inited=False)
def editor() -> None:
"""
Database Editor UI Function.
Calls the class editor function to render its UI component.
"""
if db.is_closed():
imgui.text("Please open a Database")
return
statics = editor
if not statics.inited:
statics.selected = 0
statics.actions = ["Create", "Update", "Delete"]
statics.action = str()
statics.inited = True
statics.selectors = dict()
imgui.text("Select what you want to Edit:")
changed, statics.selected = imgui.combo('##DBSelector', statics.selected, table_labels)
for action in statics.actions:
if imgui.button(action):
imgui.open_popup(table_labels[statics.selected])
statics.action = action
imgui.same_line()
if imgui.begin_popup_modal(table_labels[statics.selected])[0]:
table = tables[statics.selected]
imgui_md.render(f"# {statics.action} {table_labels[statics.selected]}")
if imgui.begin_table("Editor Grid", 2, imgui.TableFlags_.row_bg.value):
# Setup Header
for header in ["Attribute", "Value"]:
imgui.table_setup_column(header, imgui.TableColumnFlags_.width_stretch.value, 1/len(header))
imgui.table_headers_row()
student_editor(statics.action)
imgui.end_table()
imgui.end_popup()
@immapp.static(inited=False)
def student_editor(action: str) -> dict:
'''
'''
statics = student_editor
if not statics.inited:
statics.classes = tuple(Class.select())
statics.residences = tuple(Residence.select())
statics.classes_labels = tuple(clas.name for clas in statics.classes)
statics.graders = tuple(grader.alt_name for grader in get_gradings())
statics.buffer = {
'prename': "",
'surname': "",
'sex': 0,
'class_id': 0,
'group_id': 0,
'grader': 0,
'residence': 0,
}
statics.inited = True
imgui.table_next_row()
imgui.table_set_column_index(0)
imgui.text("First Name")
imgui.table_set_column_index(1)
_, statics.buffer['prename'] = imgui.input_text("##prename", statics.buffer['prename'])
imgui.table_next_row()
imgui.table_set_column_index(0)
imgui.text("Last Name")
imgui.table_set_column_index(1)
_, statics.buffer['surname'] = imgui.input_text("##surname", statics.buffer['surname'])
imgui.table_next_row()
imgui.table_set_column_index(0)
imgui.text("Gender")
imgui.table_set_column_index(1)
_, statics.buffer['sex'] = imgui.combo("##sex", statics.buffer['sex'], ['Male', 'Female'])
imgui.table_next_row()
imgui.table_set_column_index(0)
imgui.text("Class")
imgui.table_set_column_index(1)
_, statics.buffer['class_id'] = imgui.combo("##class_id", statics.buffer['class_id'], statics.classes_labels)
imgui.table_next_row()
imgui.table_set_column_index(0)
imgui.text("Project Group")
imgui.table_set_column_index(1)
groups = tuple(Group.select().where(Group.class_id == statics.classes[statics.buffer['class_id']].id))
_, statics.buffer['group_id'] = imgui.combo("##group_id", statics.buffer['group_id'], tuple(group.name for group in groups))
imgui.table_next_row()
imgui.table_set_column_index(0)
imgui.text("Grader")
imgui.table_set_column_index(1)
_, statics.buffer['grader'] = imgui.combo("##grader", statics.buffer['grader'], statics.graders)
imgui.table_next_row()
imgui.table_set_column_index(0)
imgui.text("Residence")
imgui.table_set_column_index(1)
_, statics.buffer['residence'] = imgui.combo("##residence", statics.buffer['residence'], [str(residence.id) for residence in statics.residences])
if imgui.button(action):
match action:
case "Create":
Student.create(
prename = statics.buffer['prename'],
surname = statics.buffer['surname'],
sex = 'Female' if statics.buffer['sex'] else 'Male',
class_id = statics.classes[statics.buffer['class_id']].id,
group_id = groups[statics.buffer['group_id']].id,
residence = statics.residences[statics.buffer['residence']]
)
case "Update":
pass
case "Delete":
pass
statics.inited = False
imgui.close_current_popup()

View File

@ -1,5 +1,3 @@
import shelve
from imgui_bundle import ( from imgui_bundle import (
hello_imgui, hello_imgui,
immapp immapp
@ -12,18 +10,22 @@ from gui import (
status_bar status_bar
) )
from dbmodel import init_db from dbmodel import init_postgres
from pathlib import Path
def main() -> None: def main() -> None:
"""Main function to initialize and run the application.""" """Main function to initialize and run the application."""
# Load Database # Load Database
try: try:
with shelve.open("state") as state: with open("./pickles/database_location.txt", "r") as f:
v = state.get("DB") # Retrieve stored database connection info file = f.read()
init_db(v) except FileNotFoundError:
except Exception as e: file = str(Path.home() / "learnlytics.db")
print(e) with open("./pickles/database_location.txt", "w") as f:
f.write(file)
init_postgres('postgres://admin:admin@127.0.0.1:5432/learnlytics')
# Set Window Parameters # Set Window Parameters
runner_params = hello_imgui.RunnerParams() runner_params = hello_imgui.RunnerParams()

View File

@ -1,4 +1,4 @@
from .utils import get_pdf, get_pdf_header from .utils import get_pdf, get_pdf_header_creator, get_pdf_student
from .ranking_pdf import create_ranking_pdf from .ranking_pdf import create_ranking_pdf
from .study_pdf import create_study_pdf from .study_pdf import create_study_pdf
from .group_pdf import create_group_pdf from .group_pdf import create_group_pdf

View File

@ -70,6 +70,11 @@ hr {
border: 3px solid #be1e3c; border: 3px solid #be1e3c;
} }
strong {
font-size: 14px;
font-family: 'NexusSansPro-Bold';
}
/* Table */ /* Table */
table { table {
margin: 0 auto; margin: 0 auto;

View File

@ -1,27 +1,34 @@
from dbmodel import * from dbmodel import *
from grader import get_grader from grader import get_grader
from .utils import error
from markdown_pdf import Section from markdown_pdf import Section
from functools import reduce from functools import reduce
import operator import operator
def create_group_pdf(filter: list[Study]) -> list[Section]: def create_group_pdf(class_id: int, filter: list[Study]) -> list[Section]:
if not filter:
return error()
sections = list() sections = list()
text = '' text = ''
filter = [(Student.study_id == study.id) for study in filter] filter = [(Student.study_id == study.id) for study in filter]
expr = reduce(operator.or_, filter) expr = reduce(operator.or_, filter)
students = list(Student.select().where(expr)) students = list(Student.select().where(expr).where(Student.class_id == class_id))
group_ids = set([student.group_id for student in students]) group_ids = set([student.group_id for student in students])
group_filter = [(Group.id == id) for id in group_ids] group_filter = [(Group.id == id) for id in group_ids]
if not group_filter:
return [Section('# No Student found')]
group_expr = reduce(operator.or_, group_filter) group_expr = reduce(operator.or_, group_filter)
data = { data = {
group: [student for student in Student.select().where(Student.group_id == group.id) if student.study in filter] group: [student for student in Student.select().where(Student.group_id == group.id).where(expr)]
for group in Group.select().where(group_expr) for group in Group.select().where(group_expr)
} }
max_points = [lecture.points for lecture in Lecture.select().where(Lecture.class_id == next(iter(data.keys())).class_id)] max_points = [lecture.points for lecture in Lecture.select().where(Lecture.class_id == next(iter(data.keys())).class_id)]
n = 0 n = 0

View File

@ -6,7 +6,7 @@ from itertools import compress
import operator import operator
from grader import get_grader from grader import get_grader
from collections import Counter from collections import Counter
from .utils import error
def create_header(number_of_students: int) -> str: def create_header(number_of_students: int) -> str:
text = '## Ranking\n' text = '## Ranking\n'
@ -16,12 +16,15 @@ def create_header(number_of_students: int) -> str:
return text return text
def create_ranking_pdf(filter: list[int]) -> list[Section]: def create_ranking_pdf(class_id: int, filter: list[Study]) -> list[Section]:
filter = [(Student.study_id == id) for id in filter] if not filter:
return error()
filter = [(Student.study_id == study.id) for study in filter]
expr = reduce(operator.or_, filter) expr = reduce(operator.or_, filter)
students = [ students = [
(student, Submission.select(fn.SUM(Submission.points)).where(Submission.student_id == student.id).scalar()) (student, Submission.select(fn.SUM(Submission.points)).where(Submission.student_id == student.id).scalar())
for student in Student.select().where(expr) for student in Student.select().where(expr).where(Student.class_id == class_id)
] ]
students.sort(key = lambda tup: tup[1], reverse=True) students.sort(key = lambda tup: tup[1], reverse=True)

View File

@ -1,14 +1,17 @@
from dbmodel import * from dbmodel import *
from grader import get_grader from grader import get_grader
from markdown_pdf import Section from markdown_pdf import Section
from .utils import error
def create_study_pdf(class_id: int, filter: list[Study]) -> list[Section]:
if not filter:
return error()
def create_study_pdf(filter: list[int]) -> list[Section]:
sections = list() sections = list()
text = '' text = ''
data = { data = {
study: list(Student.select().where(Student.study_id == study.id)) study: list(Student.select().where(Student.study_id == study.id).where(Student.class_id == class_id))
for study in filter for study in filter
} }

View File

@ -1,8 +1,13 @@
from dbmodel import Class from dbmodel import *
from markdown_pdf import MarkdownPdf, Section from markdown_pdf import MarkdownPdf, Section
from datetime import datetime from datetime import datetime
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from grader import get_grader
def error() -> list[Section]:
return [Section('# No Filter selected')]
def get_pdf(class_id: int) -> tuple[MarkdownPdf, str]: def get_pdf(class_id: int) -> tuple[MarkdownPdf, str]:
clas = Class.get_by_id(class_id) clas = Class.get_by_id(class_id)
@ -21,7 +26,73 @@ def get_pdf(class_id: int) -> tuple[MarkdownPdf, str]:
return (pdf, css) return (pdf, css)
def get_pdf_header(class_id: int, filter: Sequence[str], author: str, logo_file: Path = None) -> Section:
def get_metadata(author: str) -> str:
text = f'Author: {author}\n\n'
date = datetime.now()
text += f'Created: {date.strftime("%A %d.%m.%Y %H:%M")}\n\n'
text += 'This document is system-generated and may not require manual signatures.\n'
return text
def get_pdf_student(student_id: int, author: str, logo_file: Path = None) -> list[Section]:
if student_id < 1:
return Section('# No Student selected')
sections = list()
student = Student.get_by_id(student_id)
study = Study.get_by_id(student.study_id)
subs = list(Submission.select().where(Submission.student_id == student.id))
grader = get_grader(student.grader)
points = [sub.points for sub in subs]
maxs = [sub.lecture_id.points for sub in subs]
name = f'{student.prename} {student.surname}'
passed = f'{name} has passed the course successfully.' if grader.has_passed_course(points, maxs) else f'{name} failed the course.'
text = ''
# Append Logo
if logo_file:
text += f'![logo]({logo_file})\n'
text += f'# {student.prename} {student.surname} - {student.class_id.name}\n'
text += f'#### {study.name}\n\n'
text += f'Gender: **{student.sex}**\n\n'
s = sum(points)
text += f'Score: **{int(s) if s.is_integer() else s}/{sum(maxs)}**\n\n'
text += f'Percent: **{s/sum(maxs):.1%}**\n\n'
text += f'Grade: {grader.get_final_grade(points, maxs)}\n\n'
text += passed + '\n\n'
text += '---\n\n'
text += get_metadata(author)
sections.append(Section(text))
text = '# Lectures\n'
text += '|Lecture|Submission|Passed?|\n'
text += '|-|-|-|\n'
n = 0
for sub in subs:
text += f'|{sub.lecture_id.title}|{int(sub.points) if sub.points.is_integer() else sub.points}/{sub.lecture_id.points}|{grader.get_grade(sub.points, sub.lecture_id.points)}|\n'
n += 1
if n == 30:
text += '---\n\n'
sections.append(Section(text))
n = 0
text = '# Lectures\n'
text += '|Lecture|Submission|Passed?|\n'
text += '|-|-|-|\n'
if text and n != 0:
text += '---\n\n'
sections.append(Section(text))
return sections
def get_pdf_header_creator(class_id: int, filter: Sequence[str], author: str, logo_file: Path = None) -> Section:
text = '' text = ''
# Append Logo # Append Logo
if logo_file: if logo_file:
@ -37,11 +108,5 @@ def get_pdf_header(class_id: int, filter: Sequence[str], author: str, logo_file:
text += '---\n\n' text += '---\n\n'
# Metadata # Metadata
text += f'Author: {author}\n\n' text += get_metadata(author)
date = datetime.now()
text += f'Created: {date.strftime("%A %d.%m.%Y %H:%M")}\n\n'
text += 'This document is system-generated and may not require manual signatures.'
return Section(text) return Section(text)

Binary file not shown.

View File

@ -1 +1 @@
/storage/programming/Learnlytics/assets/documents/wise-23-24-fri-07-03-2025-01-15a.pdf /home/phil/wise-24-25-sat-08-03-2025-01-53.pdf

79
poetry.lock generated
View File

@ -710,6 +710,83 @@ files = [
greenlet = ">=3.1.1,<4.0.0" greenlet = ">=3.1.1,<4.0.0"
pyee = ">=12,<13" pyee = ">=12,<13"
[[package]]
name = "psycopg2-binary"
version = "2.9.10"
description = "psycopg2 - Python-PostgreSQL Database Adapter"
optional = false
python-versions = ">=3.8"
files = [
{file = "psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-win32.whl", hash = "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b"},
{file = "psycopg2_binary-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392"},
{file = "psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64"},
{file = "psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:056470c3dc57904bbf63d6f534988bafc4e970ffd50f6271fc4ee7daad9498a5"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aa0e31fa4bb82578f3a6c74a73c273367727de397a7a0f07bd83cbea696baa"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8de718c0e1c4b982a54b41779667242bc630b2197948405b7bd8ce16bcecac92"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5c370b1e4975df846b0277b4deba86419ca77dbc25047f535b0bb03d1a544d44"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:ffe8ed017e4ed70f68b7b371d84b7d4a790368db9203dfc2d222febd3a9c8863"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8aecc5e80c63f7459a1a2ab2c64df952051df196294d9f739933a9f6687e86b3"},
{file = "psycopg2_binary-2.9.10-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b"},
{file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc"},
{file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697"},
{file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481"},
{file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648"},
{file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d"},
{file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30"},
{file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c"},
{file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287"},
{file = "psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8"},
{file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"},
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "2.22" version = "2.22"
@ -1178,4 +1255,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "0f368a19ff46c53164f16b88dee00580f42b9559212a4fa7fa71b9aad0721824" content-hash = "799a180a55fe1eac994203a47c03b390b1abbd444d0fd7d5d92045b310947319"

View File

@ -29,6 +29,7 @@ markdown-pdf = "^1.3.3"
linkify-it-py = "^2.0.3" linkify-it-py = "^2.0.3"
python-slugify = "^8.0.4" python-slugify = "^8.0.4"
cairosvg = "^2.7.1" cairosvg = "^2.7.1"
psycopg2-binary = "^2.9.10"
[build-system] [build-system]

View File

@ -0,0 +1,29 @@
services:
postgres:
container_name: learnlytics-pg
image: postgres
hostname: localhost
ports:
- "5432:5432"
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: admin
POSTGRES_DB: learnlytics
volumes:
- postgres-data:/var/lib/postgresql/data
restart: unless-stopped
pgadmin:
container_name: learnlytics-pgadmin
image: dpage/pgadmin4
depends_on:
- postgres
ports:
- "80:80"
environment:
PGADMIN_DEFAULT_EMAIL: admin@learnlytics.de
PGADMIN_DEFAULT_PASSWORD: admin
restart: unless-stopped
volumes:
postgres-data: