commit 50920532bf75ea3017ceac4c030fa55cfa785624
Author: DerGrumpf
Date: Sat Mar 1 01:50:06 2025 +0100
Initial
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/assets/Presentations/DiKum.pdf b/assets/Presentations/DiKum.pdf
new file mode 100644
index 0000000..1b41a22
Binary files /dev/null and b/assets/Presentations/DiKum.pdf differ
diff --git a/assets/Presentations/MeWi_1.pdf b/assets/Presentations/MeWi_1.pdf
new file mode 100644
index 0000000..59ee5fb
Binary files /dev/null and b/assets/Presentations/MeWi_1.pdf differ
diff --git a/assets/Presentations/MeWi_2.pdf b/assets/Presentations/MeWi_2.pdf
new file mode 100644
index 0000000..28b3bd6
Binary files /dev/null and b/assets/Presentations/MeWi_2.pdf differ
diff --git a/assets/Presentations/MeWi_3.pdf b/assets/Presentations/MeWi_3.pdf
new file mode 100644
index 0000000..044b11d
Binary files /dev/null and b/assets/Presentations/MeWi_3.pdf differ
diff --git a/assets/Presentations/MeWi_4.pdf b/assets/Presentations/MeWi_4.pdf
new file mode 100644
index 0000000..515e701
Binary files /dev/null and b/assets/Presentations/MeWi_4.pdf differ
diff --git a/assets/Presentations/MeWi_5.pdf b/assets/Presentations/MeWi_5.pdf
new file mode 100644
index 0000000..5252d6f
Binary files /dev/null and b/assets/Presentations/MeWi_5.pdf differ
diff --git a/assets/Presentations/MeWi_6.pdf b/assets/Presentations/MeWi_6.pdf
new file mode 100644
index 0000000..6b957d0
Binary files /dev/null and b/assets/Presentations/MeWi_6.pdf differ
diff --git a/assets/Student_list.csv b/assets/Student_list.csv
new file mode 100644
index 0000000..f88d8db
--- /dev/null
+++ b/assets/Student_list.csv
@@ -0,0 +1,36 @@
+First Name,Last Name,Sex,Group,Grader,Tutorial 1,Tutorial 2,Extended Applications,Numpy & MatPlotLib,SciPy,Monte Carlo,Pandas & Seaborn,Folium,Statistical Test Methods,Data Analysis
+Abdalaziz,Abunjaila,Male,DiKum,30%,30.5,15,18,28,17,17,17,22,0,18
+Marleen,Adolphi,Female,MeWi6,30%,29.5,15,18,32,19,20,17,24,23,0
+Sarina,Apel,Female,MeWi1,30%,28.5,15,18,32,20,20,21,24,20,23
+Skofiare,Berisha,Female,DiKum,30%,29.5,13,18,34,20,17,20,26,16,0
+Aurela,Brahimi,Female,MeWi2,30%,17.5,15,15.5,26,16,17,19,16,0,0
+Cam Thu,Do,Female,MeWi3,30%,31,15,18,34,19,20,21.5,22,12,0
+Nova,Eib,Female,MeWi4,30%,31,15,15,34,20,20,21,27,19,21
+Lena,Fricke,Female,MeWi4,30%,0,0,0,0,0,0,0,0,0,0
+Nele,Grundke,Female,MeWi6,30%,23.5,13,16,28,20,17,21,18,22,11
+Anna,Grünewald,Female,MeWi3,30%,12,14,16,29,16,15,19,9,0,0
+Yannik,Haupt,Male,NoGroup,30%,18,6,14,21,13,2,9,0,0,0
+Janna,Heiny,Female,MeWi1,30%,30,15,18,33,18,20,22,25,24,30
+Milena,Krieger,Female,MeWi1,30%,30,15,18,33,20,20,21.5,26,20,22
+Julia,Limbach,Female,MeWi6,30%,27.5,12,18,29,11,19,17.5,26,24,28
+Viktoria,Litza,Female,MeWi5,30%,21.5,15,18,27,13,20,22,21,21,30
+Leonie,Manthey,Female,MeWi1,30%,28.5,14,18,29,20,10,18,23,16,28
+Izabel,Mike,Female,MeWi2,30%,29.5,15,15,35,11,15,19,21,21,27
+Lea,Noglik,Female,MeWi5,30%,22.5,15,17,34,13,10,20,21,19,6
+Donika,Nuhiu,Female,MeWi5,30%,31,13.5,18,35,14,10,17,18,19,8
+Julia,Renner,Female,MeWi4,30%,27.5,10,14,32,20,17,11,20,24,14
+Fabian,Rothberger,Male,MeWi3,30%,30.5,15,18,34,17,17,19,22,18,30
+Natascha,Rott,Female,MeWi1,30%,29.5,12,18,32,19,20,21,26,23,26
+Isabel,Rudolf,Female,MeWi4,30%,27.5,9,17,34,16,19,19,21,16,14
+Melina,Sablotny,Female,MeWi6,30%,31,15,18,33,20,20,21,19,11,28
+Alea,Schleier,Female,DiKum,30%,27,14,18,34,16,18,21.5,22,15,22
+Flemming,Schur,Male,MeWi3,30%,29.5,15,17,34,19,20,19,22,18,27
+Marie,Seeger,Female,DiKum,30%,27.5,15,18,32,14,9,17,22,9,25
+Lucy,Thiele,Female,MeWi6,30%,27.5,15,18,27,20,17,19,18,22,25
+Lara,Troschke,Female,MeWi2,30%,28.5,14,17,28,13,19,21,25,12,24
+Inga-Brit,Turschner,Female,MeWi2,30%,25.5,14,18,34,20,16,19,22,17,30
+Alea,Unger,Female,MeWi5,30%,30,12,18,31,20,20,21,22,15,21.5
+Marie,Wallbaum,Female,MeWi5,30%,28.5,14,18,34,17,20,19,24,12,22
+Katharina,Walz,Female,MeWi4,30%,31,15,18,31,19,19,17,24,17,14.5
+Xiaowei,Wang,Male,NoGroup,30%,30.5,14,18,26,19,17,0,0,0,0
+Lilly-Lu,Warnken,Female,DiKum,30%,30,15,18,30,14,17,19,14,16,24
diff --git a/assets/WiSe_24_25.db b/assets/WiSe_24_25.db
new file mode 100644
index 0000000..1560518
Binary files /dev/null and b/assets/WiSe_24_25.db differ
diff --git a/assets/convert.py b/assets/convert.py
new file mode 100644
index 0000000..f4ed9e5
--- /dev/null
+++ b/assets/convert.py
@@ -0,0 +1,67 @@
+import pandas as pd
+import pprint
+import sys
+sys.path.append('../learnlytics/')
+from dbmodel import *
+
+df = pd.read_csv("Student_list.csv")
+df = df.dropna()
+courses = {
+ 'Tutorial 1': 31,
+ 'Tutorial 2': 15,
+ 'Extended Applications': 18,
+ 'Numpy & MatPlotLib': 35,
+ 'SciPy': 20,
+ 'Monte Carlo': 20,
+ 'Pandas & Seaborn': 22,
+ 'Folium': 27,
+ 'Statistical Test Methods': 24,
+ 'Data Analysis': 30
+ }
+
+groups = {
+ "NoGroup": "No Project",
+ "MeWi1": "Covid-19",
+ "MeWi2": "Covid-19",
+ "MeWi3": "Discovery of Handwashing",
+ "MeWi4": "Uber Trips",
+ "MeWi5": "Extramarital Affairs",
+ "MeWi6": "Hochschulstatistik",
+ "DiKum": "Facebook Data"
+}
+
+print(df)
+init_db('WiSe_24_25.db')
+
+
+# Create Class
+clas = Class.create(name='WiSe 24/25')
+#print(clas.id)
+
+# Create Courses
+for k, v in courses.items():
+ Lecture.create(title=k, points=v, class_id=clas.id)
+ #print(l.title, l.points, l.class_id, l.id)
+
+for k, v in groups.items():
+ Group.create(name=k, project=v, has_passed=True, class_id=clas.id)
+
+for index, row in df.iterrows():
+ s = Student.create(
+ prename=row["First Name"],
+ surname=row["Last Name"],
+ sex=row["Sex"],
+ class_id=clas.id,
+ group_id=Group.select().where(Group.name == row["Group"]),
+ grader=row["Grader"],
+ )
+
+ for title, points in list(row.to_dict().items())[5:]:
+ Submission.create(
+ student_id=s.id,
+ lecture_id=Lecture.select().where(Lecture.title == title),
+ class_id=clas.id,
+ points=points
+ )
+
+
diff --git a/assets/covid_faelle_MeWi_2.html b/assets/covid_faelle_MeWi_2.html
new file mode 100644
index 0000000..c370e2f
--- /dev/null
+++ b/assets/covid_faelle_MeWi_2.html
@@ -0,0 +1,235 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/learnlytics/dbmodel/__init__.py b/learnlytics/dbmodel/__init__.py
new file mode 100644
index 0000000..228f949
--- /dev/null
+++ b/learnlytics/dbmodel/__init__.py
@@ -0,0 +1,10 @@
+from .utils import (
+ tables,
+ table_labels,
+ init_db,
+ save_as_json,
+ create_from_json
+)
+
+from .model import *
+
diff --git a/learnlytics/dbmodel/__pycache__/__init__.cpython-312.pyc b/learnlytics/dbmodel/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..f7715d0
Binary files /dev/null and b/learnlytics/dbmodel/__pycache__/__init__.cpython-312.pyc differ
diff --git a/learnlytics/dbmodel/__pycache__/model.cpython-312.pyc b/learnlytics/dbmodel/__pycache__/model.cpython-312.pyc
new file mode 100644
index 0000000..6c2d830
Binary files /dev/null and b/learnlytics/dbmodel/__pycache__/model.cpython-312.pyc differ
diff --git a/learnlytics/dbmodel/__pycache__/utils.cpython-312.pyc b/learnlytics/dbmodel/__pycache__/utils.cpython-312.pyc
new file mode 100644
index 0000000..d564837
Binary files /dev/null and b/learnlytics/dbmodel/__pycache__/utils.cpython-312.pyc differ
diff --git a/learnlytics/dbmodel/model.dbml b/learnlytics/dbmodel/model.dbml
new file mode 100644
index 0000000..20f9fa9
--- /dev/null
+++ b/learnlytics/dbmodel/model.dbml
@@ -0,0 +1,47 @@
+// Use DBML to define your database structure
+// Docs: https://dbml.dbdiagram.io/docs
+
+Table Class {
+ id integer [primary key]
+ name varchar
+ created_at timestamp
+}
+
+Table Student {
+ id integer [primary key]
+ name varchar
+ sex varchar
+ class_id integer [ref: < Class.id]
+ group_id integer [ref: < Group.id]
+ grader varchar
+ created_at timestamp
+}
+
+Table Lecture {
+ id integer [primary key]
+ title varchar
+ points integer
+ class_id integer [ref: < Class.id]
+ created_at timestamp
+}
+
+Table Submission {
+ id integer [primary key]
+ student_id integer [ref: < Student.id]
+ lecture_id integer [ref: < Lecture.id]
+ class_id integer [ref: < Class.id]
+ points float
+ created_at timestamp
+}
+
+Table Group {
+ id integer [primary key]
+ name varchar
+ project varchar
+ has_passed boolean
+ presentation varchar
+ class_id integer [ref: < Class.id]
+ created_at timestamp
+}
+
+
diff --git a/learnlytics/dbmodel/model.py b/learnlytics/dbmodel/model.py
new file mode 100644
index 0000000..fcae001
--- /dev/null
+++ b/learnlytics/dbmodel/model.py
@@ -0,0 +1,88 @@
+'''
+peewee ORM Database Model definition
+Documentation: https://docs.peewee-orm.com
+
+please look up model.dbml for Documentation
+Online Viewer: https://dbdiagram.io
+
+'''
+
+
+from peewee import *
+from datetime import datetime
+
+db = DatabaseProxy()
+
+# WIP: Add Switch Function
+if True:
+ database = SqliteDatabase(None, autoconnect=False)
+else:
+ database = PostgresqlDatabase(None, autoconnect=False)
+
+db.initialize(database)
+
+class BaseModel(Model):
+ '''
+ Base Model (needed for peewee) defines the Class Meta
+ and bounds Global db obj to every Table
+ '''
+ class Meta:
+ database = db
+
+
+class Class(BaseModel):
+ '''
+ Baseline Order Base
+
+ Table for Storing a Class
+ '''
+ name = CharField()
+ created_at = DateTimeField(default=datetime.now)
+
+
+class Group(BaseModel):
+ '''
+ Table for Storing a project Group
+ '''
+ name = CharField()
+ project = CharField()
+ has_passed = BooleanField()
+ presentation = CharField(null = True)
+ class_id = ForeignKeyField(Class, backref='class')
+ created_at = DateTimeField(default=datetime.now)
+
+
+class Student(BaseModel):
+ '''
+ Table for Storing a Student and linking him to appropiate Tables
+ '''
+ prename = CharField()
+ surname = CharField()
+ sex = CharField()
+ class_id = ForeignKeyField(Class, backref='class')
+ group_id = ForeignKeyField(Group, backref='group')
+ grader = CharField()
+ created_at = DateTimeField(default=datetime.now)
+
+
+class Lecture(BaseModel):
+ '''
+ Table for defining a Lecture
+ '''
+ title = CharField()
+ points = IntegerField()
+ class_id = ForeignKeyField(Class, backref='class')
+ created_at = DateTimeField(default=datetime.now)
+
+
+class Submission(BaseModel):
+ '''
+ Table for defining a Submission from a Student for Lecture
+ '''
+ student_id = ForeignKeyField(Student, backref='student')
+ lecture_id = ForeignKeyField(Lecture, backref='lecture')
+ class_id = ForeignKeyField(Class, backref='class')
+ points = FloatField()
+ created_at = DateTimeField(default=datetime.now)
+
+
diff --git a/learnlytics/dbmodel/utils.py b/learnlytics/dbmodel/utils.py
new file mode 100644
index 0000000..612770f
--- /dev/null
+++ b/learnlytics/dbmodel/utils.py
@@ -0,0 +1,173 @@
+'''
+Module provids Utilities to Interface with Database Model
+
+Includes:
+ - DateTime De-/Encoder for JSON loads/dumps
+ - Database Initialize Helper
+ - Module Class Summarizer
+'''
+
+
+import sys, inspect, json
+from datetime import datetime, date
+from pathlib import Path
+from playhouse.shortcuts import model_to_dict, dict_to_model
+from .model import *
+
+class DateTimeEncoder(json.JSONEncoder):
+ '''
+ Helper Class converting datetime.datetime -> isoformated String
+ '''
+
+
+ def default(self, obj):
+ if isinstance(obj, (datetime, date)):
+ return obj.isoformat()
+
+# Predefined used timestamp keys
+KEYNAMES = [
+ "date", "timestamp", "last_updatet", "last_created", "last_editet", "created_at"
+]
+
+def DateTimeDecoder(obj: dict) -> dict:
+ '''
+ Helper Function converting isoformated String -> datetime.datetime
+ '''
+
+
+ for key, value in obj.items():
+ if key not in KEYNAMES:
+ continue
+ try:
+ obj[key] = datetime.fromisoformat(value)
+ except ValueError:
+ pass
+ return obj
+
+
+def get_module_cls(module: str) -> tuple[tuple[str, object]]:
+ '''
+ Given a module name function returns a list of Classes defined only in that Module
+ '''
+
+
+ assert type(module) is str, "Provided Module isn't a String"
+ members = inspect.getmembers(sys.modules[module], inspect.isclass)
+ return tuple(member for member in members if member[1].__module__ == module)
+
+# precalculated from model.py
+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)
+
+def init_db(name: Path | str) -> None:
+ '''
+ (Creates,) Connects and Initializes the db descriptor from a given *.db file
+ '''
+
+
+ assert isinstance(name, (Path, str)), "Provided Name isn't of type Path | str"
+
+ # convert to String
+ if type(name) is Path:
+ name = str(name)
+
+ # Switch Database; Initialize if needed
+ if not db.is_closed():
+ db.close()
+ db.init(name)
+ db.connect()
+ db.create_tables(tables) # Ensure tables exist
+
+
+def save_as_json(filename: str, path: Path | str = Path('.')) -> Path:
+ '''
+ Saves the current loaded Database as .json to given
+
+ JSON Format:
+ {
+ : [
+ {: , ...}
+ ],
+ ...
+ }
+ '''
+
+
+ assert type(filename) is str, "Provided Filename isn't a String"
+ assert isinstance(path, Path | str), "Provided Path isn't of type Path | str"
+
+ # Convert given path to Path
+ if type(path) is str:
+ path = Path(path)
+
+ filename = Path(filename)
+
+ # Set Correct Suffix
+ if filename.suffix != '.json':
+ filename = filename.with_suffix('.json')
+
+ file = path.resolve().absolute() / filename
+
+ # dump db
+ database = {
+ table.__name__: list(table.select().dicts())
+ for table in tables
+ }
+
+ # db -> json
+ with open(file, "w") as fp:
+ json.dump(database, fp, cls=DateTimeEncoder)
+
+ return file
+
+def create_from_json(dbname: str, file: Path | str) -> Path:
+ '''
+ Creates a new Database .db in from given
+
+ Valid JSON Format:
+ {
+ : [
+ {: , ...}
+ ],
+ ...
+ }
+ '''
+
+ assert type(dbname) is str, "Provided Database name isn't a String"
+ assert isinstance(file, (Path | str)), "Provided file descriptor isn't of type Path | str"
+
+ # Convert file to Path
+ if type(file) is str:
+ file = Path(file)
+ assert file.suffix == '.json', "File isn't a JSON"
+
+ # Set correct Suffix and connect
+ dbname = Path(dbname).resolve().absolute()
+ if dbname.suffix != '.db':
+ dbname = dbname.with_suffix('.db')
+ init_db(dbname)
+
+ # load from json
+ with open(file, "r") as fp:
+ data = json.load(fp, object_hook=DateTimeDecoder)
+
+ assert all([keys == table.__name__ for keys, table in zip(data.keys(), tables)]), f"{file.name} can't be convert to Database"
+
+ # Insert Data
+ for k, v in data.items():
+ if not v:
+ continue
+
+ table = next((table for table in tables if table.__name__ == k))
+ table.insert_many(v).execute()
+
+ return dbname
+
+if __name__ == "__main__":
+ init_db('/home/phil/programming/grapher/assets/WiSe_24_25.db')
+ f = save_as_json("file")
+ print(f)
+ print(create_from_json("test", f))
+
+
+
diff --git a/learnlytics/grader/__init__.py b/learnlytics/grader/__init__.py
new file mode 100644
index 0000000..a9b0dee
--- /dev/null
+++ b/learnlytics/grader/__init__.py
@@ -0,0 +1,9 @@
+from .valuation import (
+ get_gradings,
+ get_grader,
+ Std30PercentRule,
+ Std50PercentRule,
+ StdGermanGradingMiddleSchool,
+ StdGermanGradingHighSchool
+)
+
diff --git a/learnlytics/grader/__pycache__/__init__.cpython-312.pyc b/learnlytics/grader/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..d321b09
Binary files /dev/null and b/learnlytics/grader/__pycache__/__init__.cpython-312.pyc differ
diff --git a/learnlytics/grader/__pycache__/valuation.cpython-312.pyc b/learnlytics/grader/__pycache__/valuation.cpython-312.pyc
new file mode 100644
index 0000000..154ad03
Binary files /dev/null and b/learnlytics/grader/__pycache__/valuation.cpython-312.pyc differ
diff --git a/learnlytics/grader/valuation.py b/learnlytics/grader/valuation.py
new file mode 100644
index 0000000..ef387aa
--- /dev/null
+++ b/learnlytics/grader/valuation.py
@@ -0,0 +1,198 @@
+from collections.abc import Sequence, Iterable, Mapping
+from typing import Any
+import inspect
+from abc import ABC, abstractmethod
+import weakref
+
+from PIL import ImageColor
+from colour import Color
+PASSED: str = "#1AFE49"
+FAILED: str = "#FF124F"
+
+def hex_to_rgba(color: str) -> tuple:
+ return tuple([e/255 for e in ImageColor.getcolor(color, "RGBA")])
+
+def gradient(color1: str, color2: str, num: int) -> list[tuple]:
+ c1, c2 = Color(color1), Color(color2)
+ colors = list(c2.range_to(c1, num))
+ colors = [hex_to_rgba(str(c)) for c in colors]
+ return colors
+
+class BaseGrading(Mapping, ABC):
+ __instances: list[Mapping] = list()
+
+ def __init__(self, schema: dict[str, int | float], name=None, alt_name=None):
+ all_str = all(isinstance(k, str) for k in schema.keys())
+ assert all_str or all(isinstance(k, int) for k in schema.keys()), "Keys must be all of type (str, int)"
+ assert all(isinstance(v, float) for v in schema.values()), "All values must be floats in range(0,1)"
+ assert all(v <= 1 and v >= 0 for v in schema.values()), "All values must be floats in range(0,1)"
+ if all_str:
+ self.schema = dict()
+ for k, v in schema.items():
+ self.schema[k.title()] = v
+ else:
+ self.schema = schema
+
+ self.__class__.__instances.append(weakref.proxy(self))
+ self.name = name
+ self.alt_name = alt_name
+
+ def __getitem__(self, index):
+ if index >= len(self):
+ raise IndexError
+ return self.schema[index]
+
+ def __len__(self) -> int:
+ return len(self.schema)
+
+ def __contains__(self, item: int | str | float) -> bool:
+ if isinstance(item, (int, str)):
+ if isinstance(item, str):
+ item = item.title()
+ return item in self.schema
+ if isinstance(item, float):
+ return item <= 1 and item >= 0
+ return False
+
+ def __iter__(self) -> Iterable:
+ yield from self.schema
+
+ def __reversed__(self) -> Iterable:
+ yield from reversed(self.schema)
+
+ def __str__(self) -> str:
+ return self.name
+
+ def __repr__(self) -> str:
+ return f"<{self.name}: ({str(self.schema)})>"
+
+ def __eq__(self, other) -> bool:
+ if other == self:
+ return True
+ if isinstance(other, BaseEval):
+ return self.schema == other.schema
+ return NotImplemented
+
+ def __ne__(self, other) -> bool:
+ return not self.__eq__(other)
+
+ def get(self, key: int | str) -> float | None:
+ if isinstance(key, str):
+ key = key.title()
+ if key in self:
+ return self.schema[key]
+ return None
+
+ def keys(self) -> tuple:
+ return list(self.schema.keys())
+
+ def values(self) -> tuple:
+ return list(self.schema.values())
+
+ def items(self) -> list[tuple]:
+ return list(self.schema.items())
+
+ @abstractmethod
+ def has_passed(self, value: int | float, max: int | float) -> bool:
+ pass
+
+ @abstractmethod
+ def get_grade(self, value: int | float, max: int | float) -> str | int:
+ pass
+
+ @abstractmethod
+ def get_grade_color(self, value: int | float, max: int | float) -> tuple:
+ pass
+
+ @classmethod
+ def get_instances(cls):
+ yield from cls.__instances
+
+ @classmethod
+ def get_instance(cls, name: str):
+ for instance in cls.__instances:
+ if instance.alt_name == name:
+ return instance
+
+get_gradings = lambda: BaseGrading.get_instances()
+get_grader = lambda name: BaseGrading.get_instance(name)
+
+class StdPercentRule(BaseGrading):
+ def has_passed(self, value: int | float, max: int | float) -> bool:
+ return value >= max * self.schema["Passed"]
+
+ def get_grade(self, value: int | float, max: int | float) -> str:
+ return "Passed" if self.has_passed(value, max) else "Not Passed"
+
+ def get_grade_color(self, value: int | float, max: int | float) -> tuple:
+ if self.has_passed(value, max):
+ return hex_to_rgba(PASSED)
+ return hex_to_rgba(FAILED)
+
+class StdGermanGrading(BaseGrading):
+ def has_passed(self, value: int | float, max: int | float) -> bool:
+ return value/max >= 0.45
+
+ def search_grade(self, value: float) -> int:
+ if value <= 0:
+ return min(self.schema.keys())
+
+ searched = max(self.schema.keys())
+ found = False
+ while not found:
+ if self.schema[searched] <= value:
+ found = True
+ else:
+ searched -= 1
+ return searched
+
+ def get_grade(self, value: int | float, max: int | float) -> int:
+ return self.search_grade(value/max)
+
+ def get_grade_color(self, value: float, max: int | float) -> tuple:
+ grade = self.get_grade(value, max)
+ colors = gradient(PASSED, FAILED, len(self.schema))
+ return colors[grade]
+
+# Definitions
+Std30PercentRule = StdPercentRule({
+ "pAssed": 0.3,
+ "Failed": 0.0
+}, "Std30PercentRule", "30%")
+
+Std50PercentRule = StdPercentRule({
+ "Passed": 0.5,
+ "Failed": 0.0
+}, "Std50PercentRule", "50%")
+
+StdGermanGradingMiddleSchool = StdGermanGrading({
+ 1: 0.96,
+ 2: 0.80,
+ 3: 0.60,
+ 4: 0.45,
+ 5: 0.16,
+ 6: 0.00
+}, "StdGermanGradingMiddleSchool", "Mittelstufe")
+
+StdGermanGradingHighSchool = StdGermanGrading({
+ 15: 0.95,
+ 14: 0.90,
+ 13: 0.85,
+ 12: 0.80,
+ 11: 0.75,
+ 10: 0.70,
+ 9: 0.65,
+ 8: 0.60,
+ 7: 0.55,
+ 6: 0.50,
+ 5: 0.45,
+ 4: 0.40,
+ 3: 0.33,
+ 2: 0.27,
+ 1: 0.20,
+ 0: 0.00
+}, "StdGermanGradingHighSchool", "Oberstufe")
+
+
+#print(StdGermanGradingHighSchool.get_grade(0.0, 24))
+
diff --git a/learnlytics/gui/__init__.py b/learnlytics/gui/__init__.py
new file mode 100644
index 0000000..6737ef8
--- /dev/null
+++ b/learnlytics/gui/__init__.py
@@ -0,0 +1,3 @@
+from .analyzer import analyzer_layout
+from .database import database_editor_layout
+from .gui import menu_bar, status_bar
diff --git a/learnlytics/gui/__pycache__/__init__.cpython-312.pyc b/learnlytics/gui/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..63da1a4
Binary files /dev/null and b/learnlytics/gui/__pycache__/__init__.cpython-312.pyc differ
diff --git a/learnlytics/gui/__pycache__/gui.cpython-312.pyc b/learnlytics/gui/__pycache__/gui.cpython-312.pyc
new file mode 100644
index 0000000..c914127
Binary files /dev/null and b/learnlytics/gui/__pycache__/gui.cpython-312.pyc differ
diff --git a/learnlytics/gui/analyzer/__init__.py b/learnlytics/gui/analyzer/__init__.py
new file mode 100644
index 0000000..9c0dbff
--- /dev/null
+++ b/learnlytics/gui/analyzer/__init__.py
@@ -0,0 +1,65 @@
+from imgui_bundle import hello_imgui, imgui
+from typing import List
+
+from .student_list import student_list
+from .student_graph import student_graph
+from .group_graph import group_graph
+
+def analyzer_docking_splits() -> List[hello_imgui.DockingSplit]:
+ split_main_misc = hello_imgui.DockingSplit()
+ split_main_misc.initial_dock = "MainDockSpace"
+ split_main_misc.new_dock = "MiscSpace"
+ split_main_misc.direction = imgui.Dir.down
+ split_main_misc.ratio = 0.25
+
+ # Then, add a space to the left which occupies a column whose width is 25% of the app width
+ split_main_command = hello_imgui.DockingSplit()
+ split_main_command.initial_dock = "MainDockSpace"
+ split_main_command.new_dock = "CommandSpace"
+ split_main_command.direction = imgui.Dir.left
+ split_main_command.ratio = 0.2
+
+ # Then, add CommandSpace2 below MainDockSpace
+ split_main_command2 = hello_imgui.DockingSplit()
+ split_main_command2.initial_dock = "MainDockSpace"
+ split_main_command2.new_dock = "CommandSpace2"
+ split_main_command2.direction = imgui.Dir.down
+ split_main_command2.ratio = 0.25
+
+ splits = [split_main_misc, split_main_command, split_main_command2]
+ return splits
+
+def set_analyzer_layout() -> List[hello_imgui.DockableWindow]:
+ student_selector = hello_imgui.DockableWindow()
+ student_selector.label = "Class list"
+ student_selector.dock_space_name = "CommandSpace"
+ student_selector.gui_function = lambda: student_list()
+
+ student_info = hello_imgui.DockableWindow()
+ student_info.label = "Student Analyzer"
+ student_info.dock_space_name = "MainDockSpace"
+ student_info.gui_function = lambda: student_graph()
+
+ group_info = hello_imgui.DockableWindow()
+ group_info.label = "Group Analyzer"
+ group_info.dock_space_name = "MainDockSpace"
+ group_info.gui_function = lambda: group_graph()
+
+ #student_ranking = hello_imgui.DockableWindow()
+ #student_ranking.label = "Ranking"
+ #student_ranking.dock_space_name = "MainDockSpace"
+ #student_ranking.gui_function = lambda: ranking()
+
+ return [
+ student_selector,
+ student_info,
+ group_info
+ ]
+
+def analyzer_layout() -> hello_imgui.DockingParams:
+ docking_params = hello_imgui.DockingParams()
+ docking_params.layout_name = "Analyzer"
+ docking_params.docking_splits = analyzer_docking_splits()
+ docking_params.dockable_windows = set_analyzer_layout()
+ return docking_params
+
diff --git a/learnlytics/gui/analyzer/__pycache__/__init__.cpython-312.pyc b/learnlytics/gui/analyzer/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..cb580a0
Binary files /dev/null and b/learnlytics/gui/analyzer/__pycache__/__init__.cpython-312.pyc differ
diff --git a/learnlytics/gui/analyzer/__pycache__/analyzer.cpython-312.pyc b/learnlytics/gui/analyzer/__pycache__/analyzer.cpython-312.pyc
new file mode 100644
index 0000000..e5cc00d
Binary files /dev/null and b/learnlytics/gui/analyzer/__pycache__/analyzer.cpython-312.pyc differ
diff --git a/learnlytics/gui/analyzer/__pycache__/analyzer_state.cpython-312.pyc b/learnlytics/gui/analyzer/__pycache__/analyzer_state.cpython-312.pyc
new file mode 100644
index 0000000..5162582
Binary files /dev/null and b/learnlytics/gui/analyzer/__pycache__/analyzer_state.cpython-312.pyc differ
diff --git a/learnlytics/gui/analyzer/__pycache__/group_graph.cpython-312.pyc b/learnlytics/gui/analyzer/__pycache__/group_graph.cpython-312.pyc
new file mode 100644
index 0000000..b8f442e
Binary files /dev/null and b/learnlytics/gui/analyzer/__pycache__/group_graph.cpython-312.pyc differ
diff --git a/learnlytics/gui/analyzer/__pycache__/plot_bar_line.cpython-312.pyc b/learnlytics/gui/analyzer/__pycache__/plot_bar_line.cpython-312.pyc
new file mode 100644
index 0000000..a042e2f
Binary files /dev/null and b/learnlytics/gui/analyzer/__pycache__/plot_bar_line.cpython-312.pyc differ
diff --git a/learnlytics/gui/analyzer/__pycache__/plotter.cpython-312.pyc b/learnlytics/gui/analyzer/__pycache__/plotter.cpython-312.pyc
new file mode 100644
index 0000000..090ace9
Binary files /dev/null and b/learnlytics/gui/analyzer/__pycache__/plotter.cpython-312.pyc differ
diff --git a/learnlytics/gui/analyzer/__pycache__/student_graph.cpython-312.pyc b/learnlytics/gui/analyzer/__pycache__/student_graph.cpython-312.pyc
new file mode 100644
index 0000000..7013b86
Binary files /dev/null and b/learnlytics/gui/analyzer/__pycache__/student_graph.cpython-312.pyc differ
diff --git a/learnlytics/gui/analyzer/__pycache__/student_list.cpython-312.pyc b/learnlytics/gui/analyzer/__pycache__/student_list.cpython-312.pyc
new file mode 100644
index 0000000..72b197c
Binary files /dev/null and b/learnlytics/gui/analyzer/__pycache__/student_list.cpython-312.pyc differ
diff --git a/learnlytics/gui/analyzer/analyzer.py b/learnlytics/gui/analyzer/analyzer.py
new file mode 100644
index 0000000..d7850c5
--- /dev/null
+++ b/learnlytics/gui/analyzer/analyzer.py
@@ -0,0 +1,319 @@
+# Custom
+from dbmodel import *
+from grader.valuation import *
+
+# External
+from imgui_bundle import (
+ imgui,
+ immapp,
+ hello_imgui,
+ imgui_md,
+ implot,
+ implot3d,
+ immvision,
+ ImVec2,
+ ImVec4,
+ im_file_dialog
+)
+from PIL import ImageColor
+import numpy as np
+from numpy.typing import NDArray
+
+# Built In
+from typing import List, Any
+
+@immapp.static(inited=False)
+def select_class(app_state: AppState) -> None:
+ statics = select_class
+
+ if not app_state.current_class_id:
+ imgui.text("No class found in Database")
+ return
+
+ if not statics.inited:
+ statics.select = 0
+ statics.classes = Class.select()
+ statics.labels = [c.name for c in statics.classes]
+ statics.inited = True
+
+ changed, statics.select = imgui.combo("Classes", statics.select, statics.labels)
+ if changed:
+ app_state.current_class_id = statics.classes[statics.select].id
+
+@immapp.static(inited=False)
+def select_student(app_state: AppState) -> None:
+ statics = select_student
+
+ if not app_state.current_class_id:
+ return
+
+ if not statics.inited:
+ statics.select = 0
+ statics.students = Student.select().where(Student.class_id == app_state.current_class_id)
+ statics.inited = True
+
+ if not statics.students:
+ imgui.text("No Studends found")
+ return
+
+ for n, student in enumerate(statics.students, start=1):
+ display = f"{n}. {student.prename} {student.surname}"
+ _, clicked = imgui.selectable(display, statics.select == n-1)
+ if clicked:
+ statics.select = n-1
+ app_state.current_student_id = statics.students[statics.select].id
+
+
+@immapp.static(inited=False)
+def student_list(app_state: AppState) -> None:
+ statics = student_list
+
+ select_class(app_state)
+ imgui.separator()
+ select_student(app_state)
+
+
+def plot_bar_line_percentage(data: np.array, labels: list, avg: float) -> None:
+ if not data.size > 0:
+ imgui.text("No Data available")
+ return
+
+ name = hash(avg)
+
+ if avg.is_integer():
+ avg = int(avg)
+ avg = np.ones(len(data)) * avg
+
+ w, h = imgui.get_window_size()
+ implot.push_colormap(implot.Colormap_.hot.value)
+ if implot.begin_plot(f"Performance##{name}", ImVec2(-1, h*0.4), implot.Flags_.no_mouse_text.value | implot.Flags_.no_inputs.value):
+ implot.setup_axes("Lectures", "Percentage")
+ implot.setup_axes_limits(-1, len(data), 0, 110)
+ implot.push_style_var(implot.StyleVar_.fill_alpha.value, 0.6)
+ implot.push_style_var(implot.StyleVar_.line_weight.value, 3)
+ implot.setup_axis_ticks(implot.ImAxis_.x1.value, 0, len(labels), len(labels), [" " for _ in labels], False)
+ implot.plot_bars("Submissions", data)
+ implot.plot_line("Average", avg)
+
+ implot.push_style_color(implot.Col_.inlay_text, ImVec4(190,190,40,255)/255)
+ for x_pos, label in enumerate(labels):
+ y_pos = 50
+ implot.plot_text(label, x_pos, y_pos//2, ImVec2(0,0), implot.TextFlags_.vertical.value)
+ implot.pop_style_color()
+
+ implot.pop_style_var()
+ implot.end_plot()
+
+COLOR_TEXT_PASSED = tuple([e/255 for e in ImageColor.getcolor("#1AFE49","RGBA")])
+COLOR_TEXT_FAILED = tuple([e/255 for e in ImageColor.getcolor("#FF124F","RGBA")])
+COLOR_TEXT_PROJECT = tuple([e/255 for e in ImageColor.getcolor("#0A9CF5","RGBA")])
+@immapp.static(inited=False)
+def student_graph(app_state: AppState) -> None:
+ statics = student_graph
+ if not statics.inited:
+ statics.id = -1
+ statics.student = None
+ statics.group = None
+ statics.lectures = None
+ statics.points = None
+ statics.sub_points = None
+ statics.subs_data = None
+ statics.subs_labels = None
+ statics.max_points = None
+ statics.avg = None
+ statics.inited = True
+
+ if id != app_state.current_student_id:
+ statics.id = app_state.current_student_id
+
+ if not app_state.current_student_id:
+ imgui.text("No Students in Database")
+ return
+ statics.student = Student.get_by_id(app_state.current_student_id)
+ submissions = Submission.select().where(Submission.student_id == statics.student.id)
+
+ statics.group = Group.get_by_id(statics.student.group_id)
+ statics.lectures = [Lecture.get_by_id(sub.lecture_id) for sub in submissions]
+ statics.max_points = np.sum([l.points for l in statics.lectures])
+ statics.sub_points = [sub.points for sub in submissions]
+ statics.points = np.sum(statics.sub_points)
+ if statics.points.is_integer():
+ statics.points = int(statics.points)
+ #statics.grader = get_grader("Oberstufe")
+ statics.grader = get_grader(statics.student.grader)
+
+ statics.subs_data = np.array([p/mp.points for p, mp in zip(statics.sub_points, statics.lectures)], dtype=np.float32)*100
+ statics.subs_labels = [f"{l.title} {int(points) if points.is_integer() else points}/{l.points}" for l, points in zip(statics.lectures, statics.sub_points)]
+ statics.avg = statics.points/statics.max_points*100
+
+ w, h = imgui.get_window_size()
+ imgui_md.render(f"# {statics.student.prename} {statics.student.surname} ({statics.group.name})")
+ imgui_md.render(f"### {statics.points}/{statics.max_points} ({statics.student.grader})")
+ imgui.text(" ")
+ imgui.progress_bar(statics.points/statics.max_points, ImVec2(w*0.9, h*0.05), f"{statics.points}/{statics.max_points} {statics.points/statics.max_points:.1%}")
+ plot_bar_line_percentage(statics.subs_data, statics.subs_labels, statics.avg)
+ imgui.separator()
+ imgui.text_colored(COLOR_TEXT_PROJECT, f"{statics.group.name}: {statics.group.project}")
+ for n, data in enumerate(zip(statics.lectures, statics.sub_points), start=1):
+ lecture, points = data
+ COLOR = statics.grader.get_grade_color(points, lecture.points)
+ imgui.text_colored(COLOR, f"{n}. {lecture.title} {points}/{lecture.points} ({statics.grader.get_grade(points, lecture.points)}) ")
+
+@immapp.static(inited=False)
+def sex_graph(app_state: AppState) -> None:
+ statics = sex_graph
+
+ if db.is_closed():
+ imgui.text("No Database loaded")
+ return
+
+ if not statics.inited:
+ statics.max_points = None
+ statics.male_points = None
+ statics.female_points = None
+ statics.male_percentage = None
+ statics.female_percentage = None
+ statics.male_labels = None
+ statics.female_labels = None
+ statics.lectures = None
+ statics.class_id = -1
+ statics.state = 0
+ statics.inited = True
+
+ if not app_state.current_class_id:
+ imgui.text("No Class found")
+ return
+
+ if statics.class_id != app_state.current_class_id:
+ statics.class_id = app_state.current_class_id
+ lectures = Lecture.select().where(Lecture.class_id == statics.class_id)
+ statics.lectures = lectures
+
+ statics.max_points = np.empty(len(lectures))
+ statics.male_points = np.empty(len(lectures))
+ statics.female_points = np.empty(len(lectures))
+ for n, lecture in enumerate(lectures):
+ statics.max_points[n] = lecture.points # Acc points
+
+ m_count = 0
+ f_count = 0
+ m_points = 0
+ f_points = 0
+
+ for sub in Submission.select().where(Submission.lecture_id == lecture.id):
+ if Student.get_by_id(sub.student_id).sex == 'Male':
+ m_points += sub.points
+ m_count += 1
+ else:
+ f_points += sub.points
+ f_count += 1
+ statics.male_points[n] = m_points/m_count/lecture.points
+ statics.female_points[n] = f_points/f_count/lecture.points
+
+ statics.male_percentage = np.sum(statics.male_points)/len(statics.male_points)
+ statics.female_percentage = np.sum(statics.female_points)/len(statics.female_points)
+
+ statics.male_labels = [f"{l.title} | {points:.1%}" for l, points in zip(lectures, statics.male_points)]
+ statics.female_labels = [f"{l.title} | {points:.1%}" for l, points in zip(lectures, statics.female_points)]
+
+ w, h = imgui.get_window_size()
+ if statics.state == 0:
+ imgui_md.render("# Male")
+ imgui.progress_bar(statics.male_percentage, ImVec2(w*0.9, h*0.05), f"{statics.male_percentage:.1%} n={len(Student.select().where(Student.sex == 'Male'))}")
+ plot_bar_line_percentage(statics.male_points*100, statics.male_labels, statics.male_percentage*100)
+
+ imgui_md.render("# Female")
+ imgui.progress_bar(statics.female_percentage, ImVec2(w*0.9, h*0.05), f"{statics.female_percentage:.1%} n={len(Student.select().where(Student.sex == 'Female'))}")
+ plot_bar_line_percentage(statics.female_points*100, statics.female_labels, statics.female_percentage*100)
+
+ if statics.state == 1:
+ male_xs = np.arange(1, len(statics.male_points)+1, dtype=np.float32)
+ male_ys = male_xs*0
+ male_zs = np.array(statics.male_points*100, dtype=np.float32)
+
+ female_xs = np.arange(1, len(statics.female_points)+1, dtype=np.float32)
+ female_ys = np.ones(len(statics.female_points), dtype=np.float32)
+ female_zs = np.array(statics.female_points*100, dtype=np.float32)
+
+ if implot3d.begin_plot("3D Gender Plot", ImVec2(w*0.9, h*0.9)):
+ implot3d.setup_axes("Lecture", "Gender", "Percentage")
+ implot3d.setup_box_scale(1.1, 1.1, 1.1)
+ implot3d.setup_axes_limits(0, len(statics.male_points)+1, -0.2, 1.2, 0, 110)
+
+ implot3d.set_next_marker_style(implot3d.Marker_.square.value)
+ implot3d.plot_line("Male", male_xs, male_ys, male_zs)
+
+ implot3d.set_next_marker_style(implot3d.Marker_.circle.value)
+ implot3d.plot_line("Female", female_xs, female_ys, female_zs)
+
+ for n, l in enumerate(statics.lectures, start=1):
+ implot3d.plot_text(l.title, n, np.clip(n/len(statics.lectures)), 50, np.pi/4)
+
+ implot3d.end_plot()
+
+ if imgui.button("Change 2D/3D"):
+ statics.state = not statics.state
+
+
+
+COLOR_TEXT_FIRST = tuple([e/255 for e in ImageColor.getcolor("#FFCF40","RGBA")])
+COLOR_TEXT_SECOND = tuple([e/255 for e in ImageColor.getcolor("#A5A9B4","RGBA")])
+COLOR_TEXT_THIRD = tuple([e/255 for e in ImageColor.getcolor("#97564A","RGBA")])
+COLOR_TEXT_ELSE = tuple([e/255 for e in ImageColor.getcolor("#85EBD9","RGBA")])
+@immapp.static(inited=False)
+def ranking() -> None:
+ statics = ranking
+
+ if not statics.inited:
+ statics.id = -1
+ statics.rank = None
+ statics.state = 1
+ statics.inited = True
+
+ imgui_md.render("# Student Ranking")
+
+ if not app_state.current_class_id:
+ imgui.text("No class selected")
+ return
+
+ if statics.id != app_state.current_class_id:
+ statics.id = app_state.current_class_id
+
+ students = Student.select().where(Student.class_id == statics.id)
+ max_points = np.sum([l.points for l in Lecture.select().where(Lecture.class_id == statics.id)])
+
+ statics.rank = list()
+ for student in students:
+ points = list()
+ for sub in Submission.select().where(Submission.student_id == student.id):
+ points.append(sub.points)
+ statics.rank.append((student, sum(points)/max_points))
+ statics.rank = sorted(statics.rank, key=lambda item: item[1], reverse=True)
+
+ if statics.state == 0:
+ for n, data in enumerate(statics.rank, start=1):
+ student, points = data
+ display = f"{n}. {student.prename} {student.surname} {points:.1%}"
+ COLOR = COLOR_TEXT_ELSE
+ if n == 1:
+ COLOR = COLOR_TEXT_FIRST
+ if n == 2:
+ COLOR = COLOR_TEXT_SECOND
+ if n == 3:
+ COLOR = COLOR_TEXT_THIRD
+ imgui.text_colored(COLOR, display)
+
+ if statics.state == 1:
+ w, h = imgui.get_window_size()
+ if implot.begin_plot("Ranking", ImVec2(w*0.9, h*0.9)):
+ implot.plot_bars("Ranking", np.array([p[1] for p in reversed(statics.rank)])*100, 0.67, 0, implot.BarsFlags_.horizontal.value)
+ for n, s in enumerate(reversed(statics.rank)):
+ student = s[0]
+ implot.plot_text(f"{student.prename} {student.surname}", s[1]*50, n)
+ implot.end_plot()
+
+ if imgui.button("Change"):
+ statics.state = not statics.state
+
+
+
diff --git a/learnlytics/gui/analyzer/analyzer_state.py b/learnlytics/gui/analyzer/analyzer_state.py
new file mode 100644
index 0000000..77c4d45
--- /dev/null
+++ b/learnlytics/gui/analyzer/analyzer_state.py
@@ -0,0 +1,19 @@
+class AnalyzerState:
+ _instance = None
+
+ def __new__(cls, *args, **kwargs):
+ if not cls._instance:
+ cls._instance = super(AnalyzerState, cls).__new__(cls, *args, **kwargs)
+ return cls._instance
+
+ def __init__(self):
+ self.class_id = 1
+ self.group_id = 1
+ self.student_id = 1
+
+ def __str__(self):
+ return f'''
+ Class: {self.class_id}
+ Group: {self.group_id}
+ Student: {self.student_id}
+ '''
diff --git a/learnlytics/gui/analyzer/group_graph.py b/learnlytics/gui/analyzer/group_graph.py
new file mode 100644
index 0000000..c6f476f
--- /dev/null
+++ b/learnlytics/gui/analyzer/group_graph.py
@@ -0,0 +1,204 @@
+from imgui_bundle import (
+ imgui,
+ immapp,
+ imgui_md,
+ im_file_dialog,
+ ImVec2
+)
+
+import numpy as np
+from pathlib import Path
+import subprocess, os, platform
+from peewee import fn
+
+from dbmodel import *
+from .analyzer_state import AnalyzerState
+from .plotter import plot_pie, plot_pdf
+
+state = AnalyzerState()
+
+
+@immapp.static(inited=False)
+def header(group_id: int) -> None:
+ statics = header
+
+ if group_id < 1:
+ return
+
+ if not statics.inited:
+ statics.group_id = group_id
+ statics.group = Group.get_by_id(group_id)
+ statics.data = [
+ (s, np.sum([sub.points for sub in Submission.select().where(Submission.student_id == s.id)]))
+ for s in Student.select().where(Student.group_id == group_id)
+ ]
+ statics.data.sort(key=lambda tup: tup[1], reverse=True)
+ statics.inited = True
+
+ if statics.group_id != group_id:
+ statics.inited = False
+
+ imgui_md.render(f"# {statics.group.name} - {statics.group.project}")
+ imgui.text("")
+
+ if statics.group.name != "NoGroup":
+ changed, _ = imgui.checkbox("Passed", statics.group.has_passed)
+ if changed:
+ statics.group.has_passed = not statics.group.has_passed
+ statics.group.save()
+
+ if imgui.begin_table("Students", len(statics.data), imgui.TableFlags_.sizing_fixed_fit):
+ for n, d in enumerate(statics.data, start=1):
+ s, points = d
+ if points.is_integer():
+ points = int(points)
+ imgui.table_next_row()
+ imgui.table_next_column()
+ imgui.set_next_item_width(-1)
+ imgui.text(f"{n}. {s.prename} {s.surname} - {points} Points")
+ imgui.end_table()
+
+
+
+@immapp.static(inited=False)
+def plot(group_id: int) -> None:
+ statics = plot
+
+ if group_id < 1:
+ return
+
+ if not statics.inited:
+ statics.group_id = group_id
+ students = Student.select().where(Student.group_id == group_id)
+
+ statics.data = {
+ f"{s.prename} {s.surname}":
+ np.sum([sub.points for sub in Submission.select().where(Submission.student_id == s.id)], dtype=np.float32)
+ for s in students
+ }
+
+ data = np.array(list(statics.data.values()))
+ statics.labels = list(statics.data.keys())
+ statics.data = data / data.sum() * 100
+
+ statics.inited = True
+
+ if statics.group_id != group_id:
+ statics.inited = False
+
+ plot_pie(statics.data, statics.labels)
+
+
+@immapp.static(inited=False)
+def presentation(group_id: int) -> None:
+ statics = presentation
+
+ if group_id < 1:
+ return
+
+ if not statics.inited:
+ statics.group_id = group_id
+ statics.group = Group.get_by_id(statics.group_id)
+ statics.res = None
+ statics.inited = True
+
+ if statics.group_id != group_id:
+ statics.inited = False
+
+ if statics.group.name == "NoGroup":
+ return
+
+ if not statics.group.presentation:
+ imgui.text("No Presentation set for this Group")
+ else:
+ plot_pdf(Path(statics.group.presentation))
+ imgui.same_line()
+
+ # Button to open File Selection
+ if imgui.button("Set Presentation"):
+ im_file_dialog.FileDialog.instance().open(
+ "SelectPresentation", "Open Presentation", "Presentation (*.pdf; *.html){.pdf,.html}"
+ )
+
+ # Handle File Dialog
+ if im_file_dialog.FileDialog.instance().is_done("SelectPresentation"):
+ if im_file_dialog.FileDialog.instance().has_result():
+ statics.res = im_file_dialog.FileDialog.instance().get_result()
+ im_file_dialog.FileDialog.instance().close()
+
+ # Save path to database
+ if statics.res:
+ file = statics.res.path()
+ statics.group.presentation = file
+ statics.group.save()
+ statics.res = None
+
+ imgui.same_line()
+ # Open System Window for PDF
+ if statics.group.presentation and imgui.button("Open"):
+ # I Hate everything about it
+ if platform.system() == 'Darwin': # MacOS
+ subprocess.Popen(('open', statics.group.presentation))
+ elif platform.system() == 'Windows': # Windows
+ os.startfile(statics.group.presentation)
+ else: # Linux & Variants
+ subprocess.Popen(('xdg-open', statics.group.presentation))
+
+
+@immapp.static(inited=False)
+def group_plot(class_id: int) -> None:
+ if class_id < 1:
+ return
+
+ statics = group_plot
+
+ if not statics.inited:
+ statics.class_id = class_id
+ max_points = Lecture.select(fn.SUM(Lecture.points)).where(Lecture.class_id == class_id).scalar()
+ data = {
+ group.name:
+ np.sum([Submission.select(fn.SUM(Submission.points)).where(Submission.student_id == s.id).scalar()
+ / Student.select().where(Student.group_id == group.id).count()
+ for s in Student.select().where(Student.group_id == group.id)])/max_points
+
+ for group in Group.select().where(Group.class_id == class_id)
+ }
+
+ statics.labels = list(data.keys())
+ data = np.array(list(data.values()))
+ statics.data = data / data.sum() *100
+
+ statics.inited = True
+
+ if statics.class_id != class_id:
+ statics.inited = False
+
+ plot_pie(statics.data, statics.labels, title="Performance per Group")
+
+def group_graph() -> None:
+ if db.is_closed():
+ imgui.text("No DB loaded")
+ return
+
+ w, h = imgui.get_content_region_avail()
+
+ if imgui.begin_child("Header", ImVec2(w * 0.3, h*0.5), imgui.ChildFlags_.borders.value):
+ header(state.group_id)
+ imgui.end_child()
+
+ imgui.same_line()
+
+ if imgui.begin_child("Plot", ImVec2(w * 0.7, h*0.5), imgui.ChildFlags_.borders.value):
+ plot(state.group_id)
+ imgui.end_child()
+
+ if imgui.begin_child("PDF", ImVec2(w*0.5, h*0.49), imgui.ChildFlags_.borders.value):
+ #plot_pdf(Path("/storage/programming/Learnlytics/assets/MeWi_4.pdf"))
+ presentation(state.group_id)
+ imgui.end_child()
+
+ imgui.same_line()
+
+ if imgui.begin_child("Groups", ImVec2(w*0.5, h*0.49), imgui.ChildFlags_.borders.value):
+ group_plot(state.class_id)
+ imgui.end_child()
diff --git a/learnlytics/gui/analyzer/plotter.py b/learnlytics/gui/analyzer/plotter.py
new file mode 100644
index 0000000..c2f42ad
--- /dev/null
+++ b/learnlytics/gui/analyzer/plotter.py
@@ -0,0 +1,112 @@
+from imgui_bundle import (
+ implot,
+ imgui,
+ immapp,
+ immvision,
+ ImVec4,
+ ImVec2
+)
+
+from PIL import Image
+import numpy as np
+from pdf2image import convert_from_path
+from pathlib import Path
+
+
+immvision.use_rgb_color_order()
+
+def plot_bar_line_percentage(data: np.array, labels: list, avg: float) -> None:
+ if not data.size > 0:
+ imgui.text("No Data available")
+ return
+
+ name = hash(avg)
+
+ if avg.is_integer():
+ avg = int(avg)
+ avg = np.ones(len(data)) * avg
+
+ w, h = imgui.get_window_size()
+ implot.push_colormap(implot.Colormap_.hot.value)
+ if implot.begin_plot(f"Performance##{name}", ImVec2(-1, h*0.8), implot.Flags_.no_mouse_text.value | implot.Flags_.no_inputs.value):
+ implot.setup_axes("Lectures", "Percentage")
+ implot.setup_axes_limits(-1, len(data), 0, 110)
+ implot.push_style_var(implot.StyleVar_.fill_alpha.value, 0.6)
+ implot.push_style_var(implot.StyleVar_.line_weight.value, 3)
+ implot.setup_axis_ticks(implot.ImAxis_.x1.value, 0, len(labels), len(labels), [" " for _ in labels], False)
+ implot.plot_bars("Submissions", data)
+ implot.plot_line("Average", avg)
+
+ implot.push_style_color(implot.Col_.inlay_text, ImVec4(190,190,40,255)/255)
+ for x_pos, label in enumerate(labels):
+ y_pos = 50
+ implot.plot_text(label, x_pos, y_pos//2, ImVec2(0,0), implot.TextFlags_.vertical.value)
+ implot.pop_style_color()
+
+ implot.pop_style_var()
+ implot.end_plot()
+
+def plot_pie(data: np.array, labels: list, title: str = "Group Performance") -> None:
+ if not data.size > 0:
+ imgui.text("No Data available")
+ return
+
+ name = hash(labels[-1])
+
+ w, h = imgui.get_window_size()
+
+ implot.push_colormap(implot.Colormap_.hot.value)
+ if implot.begin_plot(f"{title}##{name}", ImVec2(w*0.95, h*0.95), implot.Flags_.no_mouse_text.value | implot.Flags_.no_inputs.value):
+ implot.setup_axes("", "", implot.AxisFlags_.no_decorations.value, implot.AxisFlags_.no_decorations.value)
+ implot.setup_axes_limits(-1.1, 1.1, -1.1, 1.1)
+ implot.plot_pie_chart(labels, data, 0, 0, 1, "%.2f%%", implot.PieChartFlags_.normalize.value)
+
+ implot.end_plot()
+
+@immapp.static(inited=False)
+def plot_pdf(pdf: Path, label: str = "Presentation") -> None:
+ if not pdf.exists():
+ return
+
+ statics = plot_pdf
+ if not statics.inited:
+ statics.pdf = pdf
+ statics.images = convert_from_path(statics.pdf.resolve(), size=720, fmt='jpeg')
+
+ statics.params = immvision.ImageParams(
+ refresh_image = True,
+ show_image_info = False,
+ show_pixel_info = False,
+ show_zoom_buttons = False,
+ show_options_button = False,
+ can_resize = False,
+ zoom_pan_matrix = None,
+ pan_with_mouse = False,
+ zoom_with_mouse_wheel = False,
+ image_display_size = (500, 0)
+ )
+ statics.selected = 0
+ statics.inited = True
+
+ if statics.pdf != pdf:
+ statics.inited = False
+
+
+ h = imgui.get_window_size().y
+
+ image = np.array(statics.images[statics.selected].convert("RGB"))
+ size = (0,int(h*0.8))
+ immvision.image_display(label, image, size, True)
+
+ if imgui.button("Back"):
+ statics.selected -= 1
+ if statics.selected < 0:
+ statics.selected = 0
+
+ imgui.same_line()
+
+ if imgui.button("Next"):
+ statics.selected += 1
+ if statics.selected > len(statics.images)-1:
+ statics.selected = len(statics.images)-1
+
diff --git a/learnlytics/gui/analyzer/student_graph.py b/learnlytics/gui/analyzer/student_graph.py
new file mode 100644
index 0000000..62221de
--- /dev/null
+++ b/learnlytics/gui/analyzer/student_graph.py
@@ -0,0 +1,80 @@
+from imgui_bundle import (
+ imgui,
+ imgui_md,
+ immapp,
+ ImVec2,
+ ImVec4
+)
+
+import numpy as np
+from peewee import fn
+
+from dbmodel import *
+
+from .analyzer_state import AnalyzerState
+from .plotter import plot_bar_line_percentage
+
+state = AnalyzerState()
+PROGRESS_BAR_COLOR = ImVec4(190, 190, 40, 255)/255
+
+
+@immapp.static(inited=False)
+def header(student_id: int) -> None:
+ statics = header
+
+ if student_id < 1:
+ return
+
+ if not statics.inited:
+ statics.student = Student.get_by_id(student_id)
+ statics.inited = True
+
+ if statics.student.id != student_id:
+ statics.inited = False
+
+ imgui_md.render(f"# {statics.student.prename} {statics.student.surname}")
+
+@immapp.static(inited=False)
+def plot(student_id: int) -> None:
+ statics = plot
+
+ if student_id < 1:
+ return
+
+ if not statics.inited:
+ statics.student = Student.get_by_id(student_id)
+ submissions = Submission.select().where(Submission.student_id == statics.student.id)
+ lectures = Lecture.select().where(Lecture.class_id == statics.student.class_id)
+
+ statics.labels = [l.title for l in lectures]
+ statics.data = np.array([sub.points/l.points for sub, l in zip(submissions, lectures)], dtype=np.float32) * 100
+ statics.avg = np.mean(statics.data)/100
+ statics.points = Submission.select(fn.SUM(Submission.points)).where(Submission.student_id == statics.student.id).scalar()
+ if statics.points.is_integer():
+ statics.points = int(statics.points)
+ statics.max_points = Lecture.select(fn.SUM(Lecture.points)).where(Lecture.class_id == statics.student.class_id).scalar()
+ statics.inited = True
+
+ if statics.student.id != student_id:
+ statics.inited = False
+
+ imgui.text(f"{statics.points}/{statics.max_points}")
+ imgui.same_line()
+ w, h = imgui.get_window_size()
+ imgui.push_style_color(imgui.Col_.plot_histogram.value, PROGRESS_BAR_COLOR)
+ imgui.progress_bar(statics.avg, ImVec2(w*0.9, h*0.07), f"{statics.avg:.1%}")
+ imgui.pop_style_color()
+ plot_bar_line_percentage(statics.data, statics.labels, statics.avg*100)
+
+def student_graph() -> None:
+ if db.is_closed():
+ imgui.text("No DB loaded")
+ return
+
+ w, h = imgui.get_content_region_avail()
+
+ if imgui.begin_child("Header", ImVec2(w, h*0.5), imgui.ChildFlags_.borders.value):
+ header(state.student_id)
+ plot(state.student_id)
+ imgui.end_child()
+ imgui.separator()
diff --git a/learnlytics/gui/analyzer/student_list.py b/learnlytics/gui/analyzer/student_list.py
new file mode 100644
index 0000000..216732f
--- /dev/null
+++ b/learnlytics/gui/analyzer/student_list.py
@@ -0,0 +1,73 @@
+from imgui_bundle import (
+ imgui,
+ immapp,
+ imgui_md
+)
+
+from dbmodel import *
+
+from .analyzer_state import AnalyzerState
+
+state = AnalyzerState()
+
+@immapp.static(inited=False)
+def class_selector() -> int:
+ statics = class_selector
+
+ if not statics.inited:
+ statics.selector = 0
+ statics.inited = True
+
+ labels = (c.name for c in Class.select())
+ _, statics.selector = imgui.combo("##Classes", statics.selector, list(labels))
+ imgui.separator()
+ return Class.select()[statics.selector].id
+
+@immapp.static(inited=False)
+def tree(class_id: int) -> None:
+ statics = tree
+
+ if not statics.inited:
+ statics.class_id = class_id
+ statics.data = {
+ group: list(Student.select().where(Student.group_id == group.id))
+ for group in Group.select().where(Group.class_id == statics.class_id)
+ }
+ statics.selected = -1
+ statics.ret = (-1, -1)
+ statics.flags = imgui.TreeNodeFlags_.none.value
+ statics.inited = True
+
+ if statics.class_id != class_id:
+ statics.inited = False
+
+ if imgui.button("Expand"):
+ statics.flags = imgui.TreeNodeFlags_.default_open.value
+
+ imgui.same_line()
+
+ if imgui.button("Collapse"):
+ statics.flags = imgui.TreeNodeFlags_.none.value
+
+ n = 0
+ for group, students in statics.data.items():
+ if imgui.tree_node_ex(f"{group.name} - {group.project}", statics.flags):
+ for student in students:
+ changed, _ = imgui.selectable(f"{student.prename} {student.surname}", statics.selected == n)
+ if changed:
+ statics.selected = n
+ statics.ret = (group.id, student.id)
+ n += 1
+ n += 1
+ imgui.tree_pop()
+
+ return statics.ret
+
+def student_list() -> None:
+
+ if db.is_closed():
+ imgui.text("No DB loaded")
+ return
+
+ state.class_id = class_selector()
+ state.group_id, state.student_id = tree(state.class_id)
diff --git a/learnlytics/gui/database/__init__.py b/learnlytics/gui/database/__init__.py
new file mode 100644
index 0000000..8566221
--- /dev/null
+++ b/learnlytics/gui/database/__init__.py
@@ -0,0 +1,81 @@
+from imgui_bundle import hello_imgui, imgui
+#from ..app_state import AppState
+from typing import List
+
+from .database import *
+
+from .editor import editor
+
+def database_docking_splits() -> List[hello_imgui.DockingSplit]:
+ """
+ Defines the docking layout for the database editor.
+
+ Returns a list of docking splits that define the structure of the editor layout.
+
+ :return: A list of `hello_imgui.DockingSplit` objects defining docking positions and sizes.
+ """
+ split_main_command = hello_imgui.DockingSplit()
+ split_main_command.initial_dock = "MainDockSpace"
+ split_main_command.new_dock = "CommandSpace"
+ split_main_command.direction = imgui.Dir.down
+ split_main_command.ratio = 0.3
+
+ split_main_command2 = hello_imgui.DockingSplit()
+ split_main_command2.initial_dock = "CommandSpace"
+ split_main_command2.new_dock = "CommandSpace2"
+ split_main_command2.direction = imgui.Dir.right
+ split_main_command2.ratio = 0.3
+
+ split_main_misc = hello_imgui.DockingSplit()
+ split_main_misc.initial_dock = "MainDockSpace"
+ split_main_misc.new_dock = "MiscSpace"
+ split_main_misc.direction = imgui.Dir.left
+ split_main_misc.ratio = 0.2
+
+ return [split_main_misc, split_main_command, split_main_command2]
+
+def set_database_editor_layout() -> List[hello_imgui.DockableWindow]:
+ """
+ Defines the dockable windows for the database editor.
+
+ Creates and returns a list of dockable windows, including the database file selector, log window,
+ table viewer, and editor.
+
+ :param app_state: The application state.
+ :return: A list of `hello_imgui.DockableWindow` objects representing the UI windows.
+ """
+ file_dialog = hello_imgui.DockableWindow()
+ file_dialog.label = "Database"
+ file_dialog.dock_space_name = "MiscSpace"
+ file_dialog.gui_function = lambda: select_file()
+
+ log = hello_imgui.DockableWindow()
+ log.label = "Logs"
+ log.dock_space_name = "CommandSpace2"
+ log.gui_function = hello_imgui.log_gui
+
+ table_view = hello_imgui.DockableWindow()
+ 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:
+ """
+ Configures and returns the docking layout for the database editor.
+
+ :param app_state: The application state.
+ :return: A `hello_imgui.DockingParams` object defining the layout configuration.
+ """
+ docking_params = hello_imgui.DockingParams()
+ docking_params.layout_name = "Database Editor"
+ docking_params.docking_splits = database_docking_splits()
+ docking_params.dockable_windows = set_database_editor_layout()
+ return docking_params
+
diff --git a/learnlytics/gui/database/__pycache__/__init__.cpython-312.pyc b/learnlytics/gui/database/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..08f01d0
Binary files /dev/null and b/learnlytics/gui/database/__pycache__/__init__.cpython-312.pyc differ
diff --git a/learnlytics/gui/database/__pycache__/database.cpython-312.pyc b/learnlytics/gui/database/__pycache__/database.cpython-312.pyc
new file mode 100644
index 0000000..b6df750
Binary files /dev/null and b/learnlytics/gui/database/__pycache__/database.cpython-312.pyc differ
diff --git a/learnlytics/gui/database/__pycache__/editor.cpython-312.pyc b/learnlytics/gui/database/__pycache__/editor.cpython-312.pyc
new file mode 100644
index 0000000..35b5ab9
Binary files /dev/null and b/learnlytics/gui/database/__pycache__/editor.cpython-312.pyc differ
diff --git a/learnlytics/gui/database/database.py b/learnlytics/gui/database/database.py
new file mode 100644
index 0000000..dd45ef9
--- /dev/null
+++ b/learnlytics/gui/database/database.py
@@ -0,0 +1,348 @@
+"""
+Database Editor UI Module
+
+This module defines the graphical user interface (GUI) components and layout for a database editor using the
+HelloImGui and ImGui frameworks. It provides a class editor, docking layout configurations, and functions
+to set up the database editing environment.
+"""
+
+# Custom
+from dbmodel import *
+from gui import *
+from grader import get_gradings
+
+# External
+from imgui_bundle import (
+ imgui,
+ imgui_ctx,
+ immapp,
+ imgui_md,
+ im_file_dialog,
+ hello_imgui
+)
+
+import peewee
+
+# Built In
+from typing import List
+import shelve
+from pathlib import Path
+from datetime import datetime, timezone
+import pytz
+from tzlocal import get_localzone
+
+
+def file_info(path: Path) -> None:
+ """
+ Displays file information in an ImGui table.
+
+ Args:
+ path (Path): The file path whose information is to be displayed.
+
+ The function retrieves the file's size, last access time, and creation time,
+ formats the data, and presents it using ImGui tables.
+ """
+ # Retrieve file statistics
+ stat = path.stat()
+ modified = datetime.fromtimestamp(stat.st_atime) # Last access time
+ created = datetime.fromtimestamp(stat.st_ctime) # Creation time
+ format = '%c' # Standard date-time format
+
+ # Prepare file data dictionary
+ data = {
+ "File": path.name,
+ "Size": f"{stat.st_size/100:.2f} KB", # Convert bytes to KB (incorrect divisor, should be 1024)
+ "Modified": modified.strftime(format),
+ "Created": created.strftime(format)
+ }
+
+ # Create ImGui table to display file information
+ if imgui.begin_table("File Info", 2):
+ imgui.table_setup_column(" ", 0)
+ imgui.table_setup_column(" ")
+
+ # Iterate over file data and populate table
+ for k, v in data.items():
+ imgui.push_id(k)
+ imgui.table_next_row()
+ imgui.table_next_column()
+ imgui.text(k)
+ imgui.table_next_column()
+ imgui.text(v)
+ imgui.pop_id()
+
+ imgui.end_table()
+
+@immapp.static(inited=False, res=False)
+def select_file() -> None:
+ """
+ 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:
+ statics.res = None # Stores the selected file result
+ statics.current = None # Stores the currently loaded database file path
+
+ # Retrieve the last used database file from persistent storage
+ with shelve.open("state") as state:
+ statics.current = Path(state.get("DB") or "")
+ statics.inited = True
+
+ # Render UI title and display file information
+ imgui_md.render("# Database Manager")
+ file_info(statics.current)
+
+ # Button to open the file selection dialog
+ if imgui.button("Open File"):
+ im_file_dialog.FileDialog.instance().open(
+ "SelectDatabase", "Open Database", "Database File (*.json;*.db){.json,.db}"
+ )
+
+ # Handle the file dialog result
+ if im_file_dialog.FileDialog.instance().is_done("SelectDatabase"):
+ if im_file_dialog.FileDialog.instance().has_result():
+ statics.res = im_file_dialog.FileDialog.instance().get_result()
+ 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)
+def table() -> None:
+ statics = table
+
+ if db.is_closed():
+ imgui.text("DB")
+ return
+
+ if not statics.inited:
+ statics.table_flags = (
+ imgui.TableFlags_.row_bg.value
+ | imgui.TableFlags_.borders.value
+ | imgui.TableFlags_.resizable.value
+ | imgui.TableFlags_.sizing_stretch_same.value
+ )
+ statics.class_id = None
+ statics.inited = True
+
+ statics.students = Student.select().where(Student.class_id == statics.class_id)
+ statics.lectures = Lecture.select().where(Lecture.class_id == statics.class_id)
+
+ statics.rows = len(statics.students)
+ statics.cols = len(statics.lectures)
+ statics.grid = list()
+
+ for student in statics.students:
+ t_list = list()
+ sub = Submission.select().where(Submission.student_id == student.id)
+ for s in sub:
+ t_list.append(s)
+ statics.grid.append(t_list)
+
+ statics.table_header = [f"{lecture.title} ({lecture.points})" for lecture in statics.lectures]
+
+ if imgui.begin_table("Student Grid", statics.cols+1, statics.table_flags):
+ # Setup Header
+ imgui.table_setup_column("Students")
+ for header in statics.table_header:
+ imgui.table_setup_column(header)
+ imgui.table_headers_row()
+
+ # Fill Student names
+ for row in range(statics.rows):
+ imgui.table_next_row()
+ imgui.table_set_column_index(0)
+ student = statics.students[row]
+ imgui.text(f"{student.prename} {student.surname}")
+
+ for col in range(statics.cols):
+ imgui.table_set_column_index(col+1)
+ changed, value = imgui.input_float(f"##{statics.grid[row][col]}", statics.grid[row][col].points, 0.0, 0.0, "%.1f")
+
+ if changed:
+ # Boundary Check
+ if value < 0:
+ value = 0
+ if value > statics.lectures[col].points:
+ value = statics.lectures[col].points
+
+ old_value = statics.grid[row][col].points
+ statics.grid[row][col].points = value
+ statics.grid[row][col].save()
+
+ student = statics.students[row]
+ lecture = statics.lectures[col]
+ sub = statics.grid[row][col]
+
+ imgui.end_table()
+
+@immapp.static(inited=False)
+def editor() -> None:
+ """
+ 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
+ for k, v in table._meta.fields.items():
+ # Don't show Fields
+ match type(v):
+ case peewee.AutoField:
+ continue
+ case peewee.DateTimeField:
+ 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()
+
+
+
+
diff --git a/learnlytics/gui/database/editor.py b/learnlytics/gui/database/editor.py
new file mode 100644
index 0000000..c43153c
--- /dev/null
+++ b/learnlytics/gui/database/editor.py
@@ -0,0 +1,141 @@
+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()
+
+
diff --git a/learnlytics/gui/gui.py b/learnlytics/gui/gui.py
new file mode 100644
index 0000000..5b68b65
--- /dev/null
+++ b/learnlytics/gui/gui.py
@@ -0,0 +1,30 @@
+"""
+Student Analyzer Application
+
+This script initializes and runs the Student Analyzer application, which provides an interface for
+managing student data, class records, and submissions. It uses the Hello ImGui framework for UI rendering
+and integrates a database to store and manipulate student information.
+
+Modules:
+ - Custom Imports: Imports internal models and application state.
+ - Layouts: Defines different UI layouts for the analyzer and database editor.
+ - External Libraries: Uses imgui_bundle and Hello ImGui for UI rendering.
+ - Built-in Libraries: Uses shelve for persistent state storage and typing for type hints.
+"""
+
+# Custom
+from dbmodel import * # Importing database models like Class, Student, Lecture, and Submission
+
+# External
+from imgui_bundle import imgui, hello_imgui # ImGui-based UI framework
+
+def menu_bar(runner_params: hello_imgui.RunnerParams) -> None:
+ """Defines the application's menu bar."""
+ hello_imgui.show_app_menu(runner_params)
+ hello_imgui.show_view_menu(runner_params)
+
+def status_bar() -> None:
+ """Displays the status bar information."""
+ imgui.text("Student Analyzer by @DerGrumpf")
+
+
diff --git a/learnlytics/main.py b/learnlytics/main.py
new file mode 100644
index 0000000..6c34e37
--- /dev/null
+++ b/learnlytics/main.py
@@ -0,0 +1,73 @@
+import shelve
+
+from imgui_bundle import (
+ hello_imgui,
+ immapp
+)
+
+from gui import (
+ analyzer_layout,
+ database_editor_layout,
+ menu_bar,
+ status_bar
+)
+
+from dbmodel import init_db
+
+def main() -> None:
+ """Main function to initialize and run the application."""
+
+ # Load Database
+ try:
+ with shelve.open("state") as state:
+ v = state.get("DB") # Retrieve stored database connection info
+ init_db(v)
+ except Exception as e:
+ print(e)
+
+ # Set Window Parameters
+ runner_params = hello_imgui.RunnerParams()
+ runner_params.app_window_params.window_title = "Analyzer"
+ runner_params.imgui_window_params.menu_app_title = "Analyzer"
+ runner_params.app_window_params.window_geometry.size = (1000, 900)
+ runner_params.app_window_params.restore_previous_geometry = True
+ runner_params.app_window_params.borderless = True
+ runner_params.app_window_params.borderless_movable = True
+ runner_params.app_window_params.borderless_resizable = True
+ runner_params.app_window_params.borderless_closable = True
+
+ # Configure UI Elements
+ runner_params.imgui_window_params.show_menu_bar = True
+ runner_params.imgui_window_params.show_menu_app = False
+ runner_params.imgui_window_params.show_menu_view = False
+ runner_params.imgui_window_params.show_status_bar = True
+ runner_params.callbacks.show_menus = lambda: menu_bar(runner_params)
+ runner_params.callbacks.show_status = lambda: status_bar()
+
+ # Application layout
+ runner_params.imgui_window_params.default_imgui_window_type = (
+ hello_imgui.DefaultImGuiWindowType.provide_full_screen_dock_space
+ )
+ runner_params.imgui_window_params.enable_viewports = True
+ runner_params.docking_params = analyzer_layout()
+ runner_params.alternative_docking_layouts = [
+ database_editor_layout()
+ ]
+
+ # Save App Settings
+ runner_params.ini_folder_type = hello_imgui.IniFolderType.app_user_config_folder
+ runner_params.ini_filename = "Analyzer/Analyzer.ini"
+ runner_params.docking_params.layout_condition = hello_imgui.DockingLayoutCondition.application_start
+
+ # Run the Application
+ add_ons_params = immapp.AddOnsParams()
+ add_ons_params.with_markdown = True
+ add_ons_params.with_implot = True
+ add_ons_params.with_implot3d = True
+
+ immapp.run(runner_params, add_ons_params)
+
+if __name__ == "__main__":
+ main()
+
+
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..07e8f53
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,696 @@
+# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+description = "Reusable constraint types to use with typing.Annotated"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
+ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
+]
+
+[[package]]
+name = "colour"
+version = "0.1.5"
+description = "converts and manipulates various color representation (HSL, RVB, web, X11, ...)"
+optional = false
+python-versions = "*"
+files = [
+ {file = "colour-0.1.5-py2.py3-none-any.whl", hash = "sha256:33f6db9d564fadc16e59921a56999b79571160ce09916303d35346dddc17978c"},
+ {file = "colour-0.1.5.tar.gz", hash = "sha256:af20120fefd2afede8b001fbef2ea9da70ad7d49fafdb6489025dae8745c3aee"},
+]
+
+[package.extras]
+test = ["nose"]
+
+[[package]]
+name = "flatpak-pip-generator"
+version = "24.0.0"
+description = "this is an unofficial pypi distribution of the flatpak_pip_generator script"
+optional = false
+python-versions = "*"
+files = [
+ {file = "flatpak_pip_generator-24.0.0-py3-none-any.whl", hash = "sha256:9ce8dac77eb2b6423af32ed44987204cf348f2e0e76400c1ba59304c124f8b19"},
+ {file = "flatpak_pip_generator-24.0.0.tar.gz", hash = "sha256:cfe72c05b8d13084bd2aaabc107384924f1bcd5132832162b2a3dc0d2d2bda34"},
+]
+
+[package.dependencies]
+pyyaml = "*"
+requirements-parser = "*"
+
+[package.extras]
+build = ["build", "setuptools", "twine"]
+
+[[package]]
+name = "glfw"
+version = "2.8.0"
+description = "A ctypes-based wrapper for GLFW3."
+optional = false
+python-versions = "*"
+files = [
+ {file = "glfw-2.8.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-macosx_10_6_intel.whl", hash = "sha256:28aaef2f022b57cd37525ad1d11ba9049931a273359964fb3cd36762eb093ed1"},
+ {file = "glfw-2.8.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-macosx_11_0_arm64.whl", hash = "sha256:0239e478ff065719064fd1272ad29f8542e8178b11c614674bb930d85aa2d1e7"},
+ {file = "glfw-2.8.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux2014_aarch64.whl", hash = "sha256:44b5313cffa4a037e8e2988bccba6fa7de0a24123509f4ccf3e49b831cf72abb"},
+ {file = "glfw-2.8.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux2014_x86_64.whl", hash = "sha256:0fd982cf42a8e3137e05e392280826311961f9e99c82a0ccf22a63a0d2acd143"},
+ {file = "glfw-2.8.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux_2_28_aarch64.whl", hash = "sha256:523e1fc03898bcb0711f78d6f21eee58c1f865bb764cbd36b9549580a4c43454"},
+ {file = "glfw-2.8.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux_2_28_x86_64.whl", hash = "sha256:9cd20351b14587c6fe7063afb33cc03152e38dd2bff2b69613cb044bf3bdb635"},
+ {file = "glfw-2.8.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-win32.whl", hash = "sha256:13a75d8d3e8d4bb24595f2354a392ccf7c54ddd20dacb88e1188b760f2a7714b"},
+ {file = "glfw-2.8.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-win_amd64.whl", hash = "sha256:1416f10d2c2748c39910e9d9e6a10a5473743c5a745518061e4051be4c5caaa1"},
+ {file = "glfw-2.8.0.tar.gz", hash = "sha256:90e90d328b0b26fed6e1631d21801e2d8a7a0c5dcb480e733c177567ec9666f0"},
+]
+
+[package.extras]
+preview = ["glfw-preview"]
+
+[[package]]
+name = "imgui-bundle"
+version = "1.6.2"
+description = "Dear ImGui Bundle: easily create ImGui applications in Python and C++. Batteries included!"
+optional = false
+python-versions = ">=3.10"
+files = [
+ {file = "imgui_bundle-1.6.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:dbf2a82baf6d8ab4787ed531baa339c2c3f8f76f2c70f3c764f8f3c3e6ed2097"},
+ {file = "imgui_bundle-1.6.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:519194a5b4fd3c1748bcf5b9afb6575b883e68c87947ee1332f4fa27fbcfcd14"},
+ {file = "imgui_bundle-1.6.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80e96d31e1ce56df6de1120c0f0764ab54e6af1efd1752499133cba7e879b1e9"},
+ {file = "imgui_bundle-1.6.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3baefbb7374909541900dec15d4630f06e4f1413c3e6560ec619e210a0f5d101"},
+ {file = "imgui_bundle-1.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:e6ba109dbc788a217d283cb7159391f40f6efacd79d8d1a4fa61962220573fbc"},
+ {file = "imgui_bundle-1.6.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:486e8408d4b0e3c2c8ef1814ddfde0aa87d25e6f8a650b7ccda672a2e1b47062"},
+ {file = "imgui_bundle-1.6.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:1ec8db8526eb6ad7dfe77c38ea4a19d5beaef2f79a0b080515929df77ac0c5fc"},
+ {file = "imgui_bundle-1.6.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5152504650622110559d5039fa60b09da91a5b7f8bcd4065fdf7f053b1967ad7"},
+ {file = "imgui_bundle-1.6.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:40a4f4d1a89b1e5b421077cbdde3f217b0b24468875c930625c9f5749421617f"},
+ {file = "imgui_bundle-1.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:add71c6733b6785e48bb059472e10e49d7ef56108fac7eb2bf05692cc6040b73"},
+ {file = "imgui_bundle-1.6.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f2e4494339209a8417d745658b4d36efe03949536a2b02492a03b2eb8f187850"},
+ {file = "imgui_bundle-1.6.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:2a26dd3475271caf0ea5ca800bde95c9bc4cc889739869d9c6c0335c11d579e4"},
+ {file = "imgui_bundle-1.6.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3316e7227d25a1618205db478a7c8258a6f667f9e6a532e6a1075e26c33ded1e"},
+ {file = "imgui_bundle-1.6.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c76d14d2a270ff8b5de55aa8fafc255402570ebe6f3cbe0a6fd030c26f48b217"},
+ {file = "imgui_bundle-1.6.2-cp313-cp313-win_amd64.whl", hash = "sha256:4bf9c0afb85dfdb9ca49e63d388a2a6e5b8935a5d9552fe7c51b3969caa5d4f8"},
+ {file = "imgui_bundle-1.6.2.tar.gz", hash = "sha256:17b2019a0d4ebb66bc29b234c086ea5a3cccc7c0c540517fe569d84372316970"},
+]
+
+[package.dependencies]
+glfw = "*"
+munch = "*"
+numpy = "*"
+pillow = "*"
+pydantic = "*"
+PyOpenGL = "*"
+
+[package.extras]
+test = ["pytest"]
+
+[[package]]
+name = "munch"
+version = "4.0.0"
+description = "A dot-accessible dictionary (a la JavaScript objects)"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "munch-4.0.0-py2.py3-none-any.whl", hash = "sha256:71033c45db9fb677a0b7eb517a4ce70ae09258490e419b0e7f00d1e386ecb1b4"},
+ {file = "munch-4.0.0.tar.gz", hash = "sha256:542cb151461263216a4e37c3fd9afc425feeaf38aaa3025cd2a981fadb422235"},
+]
+
+[package.extras]
+testing = ["astroid (>=2.0)", "coverage", "pylint (>=2.3.1,<2.4.0)", "pytest"]
+yaml = ["PyYAML (>=5.1.0)"]
+
+[[package]]
+name = "numpy"
+version = "2.2.3"
+description = "Fundamental package for array computing in Python"
+optional = false
+python-versions = ">=3.10"
+files = [
+ {file = "numpy-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cbc6472e01952d3d1b2772b720428f8b90e2deea8344e854df22b0618e9cce71"},
+ {file = "numpy-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdfe0c22692a30cd830c0755746473ae66c4a8f2e7bd508b35fb3b6a0813d787"},
+ {file = "numpy-2.2.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:e37242f5324ffd9f7ba5acf96d774f9276aa62a966c0bad8dae692deebec7716"},
+ {file = "numpy-2.2.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:95172a21038c9b423e68be78fd0be6e1b97674cde269b76fe269a5dfa6fadf0b"},
+ {file = "numpy-2.2.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5b47c440210c5d1d67e1cf434124e0b5c395eee1f5806fdd89b553ed1acd0a3"},
+ {file = "numpy-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0391ea3622f5c51a2e29708877d56e3d276827ac5447d7f45e9bc4ade8923c52"},
+ {file = "numpy-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f6b3dfc7661f8842babd8ea07e9897fe3d9b69a1d7e5fbb743e4160f9387833b"},
+ {file = "numpy-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ad78ce7f18ce4e7df1b2ea4019b5817a2f6a8a16e34ff2775f646adce0a5027"},
+ {file = "numpy-2.2.3-cp310-cp310-win32.whl", hash = "sha256:5ebeb7ef54a7be11044c33a17b2624abe4307a75893c001a4800857956b41094"},
+ {file = "numpy-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:596140185c7fa113563c67c2e894eabe0daea18cf8e33851738c19f70ce86aeb"},
+ {file = "numpy-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:16372619ee728ed67a2a606a614f56d3eabc5b86f8b615c79d01957062826ca8"},
+ {file = "numpy-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5521a06a3148686d9269c53b09f7d399a5725c47bbb5b35747e1cb76326b714b"},
+ {file = "numpy-2.2.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:7c8dde0ca2f77828815fd1aedfdf52e59071a5bae30dac3b4da2a335c672149a"},
+ {file = "numpy-2.2.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:77974aba6c1bc26e3c205c2214f0d5b4305bdc719268b93e768ddb17e3fdd636"},
+ {file = "numpy-2.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d42f9c36d06440e34226e8bd65ff065ca0963aeecada587b937011efa02cdc9d"},
+ {file = "numpy-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2712c5179f40af9ddc8f6727f2bd910ea0eb50206daea75f58ddd9fa3f715bb"},
+ {file = "numpy-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c8b0451d2ec95010d1db8ca733afc41f659f425b7f608af569711097fd6014e2"},
+ {file = "numpy-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9b4a8148c57ecac25a16b0e11798cbe88edf5237b0df99973687dd866f05e1b"},
+ {file = "numpy-2.2.3-cp311-cp311-win32.whl", hash = "sha256:1f45315b2dc58d8a3e7754fe4e38b6fce132dab284a92851e41b2b344f6441c5"},
+ {file = "numpy-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f48ba6f6c13e5e49f3d3efb1b51c8193215c42ac82610a04624906a9270be6f"},
+ {file = "numpy-2.2.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12c045f43b1d2915eca6b880a7f4a256f59d62df4f044788c8ba67709412128d"},
+ {file = "numpy-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87eed225fd415bbae787f93a457af7f5990b92a334e346f72070bf569b9c9c95"},
+ {file = "numpy-2.2.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:712a64103d97c404e87d4d7c47fb0c7ff9acccc625ca2002848e0d53288b90ea"},
+ {file = "numpy-2.2.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a5ae282abe60a2db0fd407072aff4599c279bcd6e9a2475500fc35b00a57c532"},
+ {file = "numpy-2.2.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5266de33d4c3420973cf9ae3b98b54a2a6d53a559310e3236c4b2b06b9c07d4e"},
+ {file = "numpy-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b787adbf04b0db1967798dba8da1af07e387908ed1553a0d6e74c084d1ceafe"},
+ {file = "numpy-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:34c1b7e83f94f3b564b35f480f5652a47007dd91f7c839f404d03279cc8dd021"},
+ {file = "numpy-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4d8335b5f1b6e2bce120d55fb17064b0262ff29b459e8493d1785c18ae2553b8"},
+ {file = "numpy-2.2.3-cp312-cp312-win32.whl", hash = "sha256:4d9828d25fb246bedd31e04c9e75714a4087211ac348cb39c8c5f99dbb6683fe"},
+ {file = "numpy-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:83807d445817326b4bcdaaaf8e8e9f1753da04341eceec705c001ff342002e5d"},
+ {file = "numpy-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bfdb06b395385ea9b91bf55c1adf1b297c9fdb531552845ff1d3ea6e40d5aba"},
+ {file = "numpy-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23c9f4edbf4c065fddb10a4f6e8b6a244342d95966a48820c614891e5059bb50"},
+ {file = "numpy-2.2.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:a0c03b6be48aaf92525cccf393265e02773be8fd9551a2f9adbe7db1fa2b60f1"},
+ {file = "numpy-2.2.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:2376e317111daa0a6739e50f7ee2a6353f768489102308b0d98fcf4a04f7f3b5"},
+ {file = "numpy-2.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fb62fe3d206d72fe1cfe31c4a1106ad2b136fcc1606093aeab314f02930fdf2"},
+ {file = "numpy-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52659ad2534427dffcc36aac76bebdd02b67e3b7a619ac67543bc9bfe6b7cdb1"},
+ {file = "numpy-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b416af7d0ed3271cad0f0a0d0bee0911ed7eba23e66f8424d9f3dfcdcae1304"},
+ {file = "numpy-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1402da8e0f435991983d0a9708b779f95a8c98c6b18a171b9f1be09005e64d9d"},
+ {file = "numpy-2.2.3-cp313-cp313-win32.whl", hash = "sha256:136553f123ee2951bfcfbc264acd34a2fc2f29d7cdf610ce7daf672b6fbaa693"},
+ {file = "numpy-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5b732c8beef1d7bc2d9e476dbba20aaff6167bf205ad9aa8d30913859e82884b"},
+ {file = "numpy-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:435e7a933b9fda8126130b046975a968cc2d833b505475e588339e09f7672890"},
+ {file = "numpy-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7678556eeb0152cbd1522b684dcd215250885993dd00adb93679ec3c0e6e091c"},
+ {file = "numpy-2.2.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2e8da03bd561504d9b20e7a12340870dfc206c64ea59b4cfee9fceb95070ee94"},
+ {file = "numpy-2.2.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:c9aa4496fd0e17e3843399f533d62857cef5900facf93e735ef65aa4bbc90ef0"},
+ {file = "numpy-2.2.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4ca91d61a4bf61b0f2228f24bbfa6a9facd5f8af03759fe2a655c50ae2c6610"},
+ {file = "numpy-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deaa09cd492e24fd9b15296844c0ad1b3c976da7907e1c1ed3a0ad21dded6f76"},
+ {file = "numpy-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:246535e2f7496b7ac85deffe932896a3577be7af8fb7eebe7146444680297e9a"},
+ {file = "numpy-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:daf43a3d1ea699402c5a850e5313680ac355b4adc9770cd5cfc2940e7861f1bf"},
+ {file = "numpy-2.2.3-cp313-cp313t-win32.whl", hash = "sha256:cf802eef1f0134afb81fef94020351be4fe1d6681aadf9c5e862af6602af64ef"},
+ {file = "numpy-2.2.3-cp313-cp313t-win_amd64.whl", hash = "sha256:aee2512827ceb6d7f517c8b85aa5d3923afe8fc7a57d028cffcd522f1c6fd082"},
+ {file = "numpy-2.2.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3c2ec8a0f51d60f1e9c0c5ab116b7fc104b165ada3f6c58abf881cb2eb16044d"},
+ {file = "numpy-2.2.3-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:ed2cf9ed4e8ebc3b754d398cba12f24359f018b416c380f577bbae112ca52fc9"},
+ {file = "numpy-2.2.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39261798d208c3095ae4f7bc8eaeb3481ea8c6e03dc48028057d3cbdbdb8937e"},
+ {file = "numpy-2.2.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:783145835458e60fa97afac25d511d00a1eca94d4a8f3ace9fe2043003c678e4"},
+ {file = "numpy-2.2.3.tar.gz", hash = "sha256:dbdc15f0c81611925f382dfa97b3bd0bc2c1ce19d4fe50482cb0ddc12ba30020"},
+]
+
+[[package]]
+name = "packaging"
+version = "24.2"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
+ {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
+]
+
+[[package]]
+name = "pandas"
+version = "2.2.3"
+description = "Powerful data structures for data analysis, time series, and statistics"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5"},
+ {file = "pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348"},
+ {file = "pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed"},
+ {file = "pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57"},
+ {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42"},
+ {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f"},
+ {file = "pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645"},
+ {file = "pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039"},
+ {file = "pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd"},
+ {file = "pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698"},
+ {file = "pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc"},
+ {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3"},
+ {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32"},
+ {file = "pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5"},
+ {file = "pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9"},
+ {file = "pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4"},
+ {file = "pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3"},
+ {file = "pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319"},
+ {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8"},
+ {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a"},
+ {file = "pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13"},
+ {file = "pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015"},
+ {file = "pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28"},
+ {file = "pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0"},
+ {file = "pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24"},
+ {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659"},
+ {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb"},
+ {file = "pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d"},
+ {file = "pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468"},
+ {file = "pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18"},
+ {file = "pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2"},
+ {file = "pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4"},
+ {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d"},
+ {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a"},
+ {file = "pandas-2.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39"},
+ {file = "pandas-2.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30"},
+ {file = "pandas-2.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8cd6d7cc958a3910f934ea8dbdf17b2364827bb4dafc38ce6eef6bb3d65ff09c"},
+ {file = "pandas-2.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c"},
+ {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31d0ced62d4ea3e231a9f228366919a5ea0b07440d9d4dac345376fd8e1477ea"},
+ {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761"},
+ {file = "pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e"},
+ {file = "pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667"},
+]
+
+[package.dependencies]
+numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""}
+python-dateutil = ">=2.8.2"
+pytz = ">=2020.1"
+tzdata = ">=2022.7"
+
+[package.extras]
+all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"]
+aws = ["s3fs (>=2022.11.0)"]
+clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"]
+compression = ["zstandard (>=0.19.0)"]
+computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"]
+consortium-standard = ["dataframe-api-compat (>=0.1.7)"]
+excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"]
+feather = ["pyarrow (>=10.0.1)"]
+fss = ["fsspec (>=2022.11.0)"]
+gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"]
+hdf5 = ["tables (>=3.8.0)"]
+html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"]
+mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"]
+output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"]
+parquet = ["pyarrow (>=10.0.1)"]
+performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"]
+plot = ["matplotlib (>=3.6.3)"]
+postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"]
+pyarrow = ["pyarrow (>=10.0.1)"]
+spss = ["pyreadstat (>=1.2.0)"]
+sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"]
+test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"]
+xml = ["lxml (>=4.9.2)"]
+
+[[package]]
+name = "pdf2image"
+version = "1.17.0"
+description = "A wrapper around the pdftoppm and pdftocairo command line tools to convert PDF to a PIL Image list."
+optional = false
+python-versions = "*"
+files = [
+ {file = "pdf2image-1.17.0-py3-none-any.whl", hash = "sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2"},
+ {file = "pdf2image-1.17.0.tar.gz", hash = "sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57"},
+]
+
+[package.dependencies]
+pillow = "*"
+
+[[package]]
+name = "peewee"
+version = "3.17.9"
+description = "a little orm"
+optional = false
+python-versions = "*"
+files = [
+ {file = "peewee-3.17.9.tar.gz", hash = "sha256:fe15cd001758e324c8e3ca8c8ed900e7397c2907291789e1efc383e66b9bc7a8"},
+]
+
+[[package]]
+name = "pillow"
+version = "11.1.0"
+description = "Python Imaging Library (Fork)"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pillow-11.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:e1abe69aca89514737465752b4bcaf8016de61b3be1397a8fc260ba33321b3a8"},
+ {file = "pillow-11.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c640e5a06869c75994624551f45e5506e4256562ead981cce820d5ab39ae2192"},
+ {file = "pillow-11.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a07dba04c5e22824816b2615ad7a7484432d7f540e6fa86af60d2de57b0fcee2"},
+ {file = "pillow-11.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e267b0ed063341f3e60acd25c05200df4193e15a4a5807075cd71225a2386e26"},
+ {file = "pillow-11.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bd165131fd51697e22421d0e467997ad31621b74bfc0b75956608cb2906dda07"},
+ {file = "pillow-11.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:abc56501c3fd148d60659aae0af6ddc149660469082859fa7b066a298bde9482"},
+ {file = "pillow-11.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:54ce1c9a16a9561b6d6d8cb30089ab1e5eb66918cb47d457bd996ef34182922e"},
+ {file = "pillow-11.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73ddde795ee9b06257dac5ad42fcb07f3b9b813f8c1f7f870f402f4dc54b5269"},
+ {file = "pillow-11.1.0-cp310-cp310-win32.whl", hash = "sha256:3a5fe20a7b66e8135d7fd617b13272626a28278d0e578c98720d9ba4b2439d49"},
+ {file = "pillow-11.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:b6123aa4a59d75f06e9dd3dac5bf8bc9aa383121bb3dd9a7a612e05eabc9961a"},
+ {file = "pillow-11.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:a76da0a31da6fcae4210aa94fd779c65c75786bc9af06289cd1c184451ef7a65"},
+ {file = "pillow-11.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e06695e0326d05b06833b40b7ef477e475d0b1ba3a6d27da1bb48c23209bf457"},
+ {file = "pillow-11.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96f82000e12f23e4f29346e42702b6ed9a2f2fea34a740dd5ffffcc8c539eb35"},
+ {file = "pillow-11.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3cd561ded2cf2bbae44d4605837221b987c216cff94f49dfeed63488bb228d2"},
+ {file = "pillow-11.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f189805c8be5ca5add39e6f899e6ce2ed824e65fb45f3c28cb2841911da19070"},
+ {file = "pillow-11.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:dd0052e9db3474df30433f83a71b9b23bd9e4ef1de13d92df21a52c0303b8ab6"},
+ {file = "pillow-11.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:837060a8599b8f5d402e97197d4924f05a2e0d68756998345c829c33186217b1"},
+ {file = "pillow-11.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aa8dd43daa836b9a8128dbe7d923423e5ad86f50a7a14dc688194b7be5c0dea2"},
+ {file = "pillow-11.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0a2f91f8a8b367e7a57c6e91cd25af510168091fb89ec5146003e424e1558a96"},
+ {file = "pillow-11.1.0-cp311-cp311-win32.whl", hash = "sha256:c12fc111ef090845de2bb15009372175d76ac99969bdf31e2ce9b42e4b8cd88f"},
+ {file = "pillow-11.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fbd43429d0d7ed6533b25fc993861b8fd512c42d04514a0dd6337fb3ccf22761"},
+ {file = "pillow-11.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:f7955ecf5609dee9442cbface754f2c6e541d9e6eda87fad7f7a989b0bdb9d71"},
+ {file = "pillow-11.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2062ffb1d36544d42fcaa277b069c88b01bb7298f4efa06731a7fd6cc290b81a"},
+ {file = "pillow-11.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a85b653980faad27e88b141348707ceeef8a1186f75ecc600c395dcac19f385b"},
+ {file = "pillow-11.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9409c080586d1f683df3f184f20e36fb647f2e0bc3988094d4fd8c9f4eb1b3b3"},
+ {file = "pillow-11.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fdadc077553621911f27ce206ffcbec7d3f8d7b50e0da39f10997e8e2bb7f6a"},
+ {file = "pillow-11.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:93a18841d09bcdd774dcdc308e4537e1f867b3dec059c131fde0327899734aa1"},
+ {file = "pillow-11.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9aa9aeddeed452b2f616ff5507459e7bab436916ccb10961c4a382cd3e03f47f"},
+ {file = "pillow-11.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3cdcdb0b896e981678eee140d882b70092dac83ac1cdf6b3a60e2216a73f2b91"},
+ {file = "pillow-11.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36ba10b9cb413e7c7dfa3e189aba252deee0602c86c309799da5a74009ac7a1c"},
+ {file = "pillow-11.1.0-cp312-cp312-win32.whl", hash = "sha256:cfd5cd998c2e36a862d0e27b2df63237e67273f2fc78f47445b14e73a810e7e6"},
+ {file = "pillow-11.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a697cd8ba0383bba3d2d3ada02b34ed268cb548b369943cd349007730c92bddf"},
+ {file = "pillow-11.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:4dd43a78897793f60766563969442020e90eb7847463eca901e41ba186a7d4a5"},
+ {file = "pillow-11.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc"},
+ {file = "pillow-11.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0"},
+ {file = "pillow-11.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1"},
+ {file = "pillow-11.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec"},
+ {file = "pillow-11.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5"},
+ {file = "pillow-11.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114"},
+ {file = "pillow-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352"},
+ {file = "pillow-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3"},
+ {file = "pillow-11.1.0-cp313-cp313-win32.whl", hash = "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9"},
+ {file = "pillow-11.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c"},
+ {file = "pillow-11.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65"},
+ {file = "pillow-11.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861"},
+ {file = "pillow-11.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081"},
+ {file = "pillow-11.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c"},
+ {file = "pillow-11.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547"},
+ {file = "pillow-11.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab"},
+ {file = "pillow-11.1.0-cp313-cp313t-win32.whl", hash = "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9"},
+ {file = "pillow-11.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe"},
+ {file = "pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756"},
+ {file = "pillow-11.1.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:bf902d7413c82a1bfa08b06a070876132a5ae6b2388e2712aab3a7cbc02205c6"},
+ {file = "pillow-11.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c1eec9d950b6fe688edee07138993e54ee4ae634c51443cfb7c1e7613322718e"},
+ {file = "pillow-11.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e275ee4cb11c262bd108ab2081f750db2a1c0b8c12c1897f27b160c8bd57bbc"},
+ {file = "pillow-11.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4db853948ce4e718f2fc775b75c37ba2efb6aaea41a1a5fc57f0af59eee774b2"},
+ {file = "pillow-11.1.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:ab8a209b8485d3db694fa97a896d96dd6533d63c22829043fd9de627060beade"},
+ {file = "pillow-11.1.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:54251ef02a2309b5eec99d151ebf5c9904b77976c8abdcbce7891ed22df53884"},
+ {file = "pillow-11.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5bb94705aea800051a743aa4874bb1397d4695fb0583ba5e425ee0328757f196"},
+ {file = "pillow-11.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89dbdb3e6e9594d512780a5a1c42801879628b38e3efc7038094430844e271d8"},
+ {file = "pillow-11.1.0-cp39-cp39-win32.whl", hash = "sha256:e5449ca63da169a2e6068dd0e2fcc8d91f9558aba89ff6d02121ca8ab11e79e5"},
+ {file = "pillow-11.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3362c6ca227e65c54bf71a5f88b3d4565ff1bcbc63ae72c34b07bbb1cc59a43f"},
+ {file = "pillow-11.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:b20be51b37a75cc54c2c55def3fa2c65bb94ba859dde241cd0a4fd302de5ae0a"},
+ {file = "pillow-11.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8c730dc3a83e5ac137fbc92dfcfe1511ce3b2b5d7578315b63dbbb76f7f51d90"},
+ {file = "pillow-11.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7d33d2fae0e8b170b6a6c57400e077412240f6f5bb2a342cf1ee512a787942bb"},
+ {file = "pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8d65b38173085f24bc07f8b6c505cbb7418009fa1a1fcb111b1f4961814a442"},
+ {file = "pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:015c6e863faa4779251436db398ae75051469f7c903b043a48f078e437656f83"},
+ {file = "pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d44ff19eea13ae4acdaaab0179fa68c0c6f2f45d66a4d8ec1eda7d6cecbcc15f"},
+ {file = "pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d3d8da4a631471dfaf94c10c85f5277b1f8e42ac42bade1ac67da4b4a7359b73"},
+ {file = "pillow-11.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4637b88343166249fe8aa94e7c4a62a180c4b3898283bb5d3d2fd5fe10d8e4e0"},
+ {file = "pillow-11.1.0.tar.gz", hash = "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20"},
+]
+
+[package.extras]
+docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
+fpx = ["olefile"]
+mic = ["olefile"]
+tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"]
+typing = ["typing-extensions"]
+xmp = ["defusedxml"]
+
+[[package]]
+name = "pydantic"
+version = "2.10.6"
+description = "Data validation using Python type hints"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"},
+ {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"},
+]
+
+[package.dependencies]
+annotated-types = ">=0.6.0"
+pydantic-core = "2.27.2"
+typing-extensions = ">=4.12.2"
+
+[package.extras]
+email = ["email-validator (>=2.0.0)"]
+timezone = ["tzdata"]
+
+[[package]]
+name = "pydantic-core"
+version = "2.27.2"
+description = "Core functionality for Pydantic validation and serialization"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"},
+ {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"},
+ {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"},
+ {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"},
+ {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"},
+ {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"},
+ {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"},
+ {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"},
+ {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"},
+ {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
+
+[[package]]
+name = "pyopengl"
+version = "3.1.9"
+description = "Standard OpenGL bindings for Python"
+optional = false
+python-versions = "*"
+files = [
+ {file = "PyOpenGL-3.1.9-py3-none-any.whl", hash = "sha256:15995fd3b0deb991376805da36137a4ae5aba6ddbb5e29ac1f35462d130a3f77"},
+ {file = "pyopengl-3.1.9.tar.gz", hash = "sha256:28ebd82c5f4491a418aeca9672dffb3adbe7d33b39eada4548a5b4e8c03f60c8"},
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "pytz"
+version = "2025.1"
+description = "World timezone definitions, modern and historical"
+optional = false
+python-versions = "*"
+files = [
+ {file = "pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57"},
+ {file = "pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e"},
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.2"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
+ {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
+ {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
+ {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
+ {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
+ {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
+ {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
+ {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
+ {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
+ {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
+ {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
+ {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
+ {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
+ {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
+ {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
+ {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
+ {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
+ {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
+ {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
+ {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
+ {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
+ {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
+ {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
+ {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
+ {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
+ {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
+ {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
+ {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
+ {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
+ {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
+ {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
+ {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
+ {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
+ {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
+ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
+]
+
+[[package]]
+name = "requirements-parser"
+version = "0.11.0"
+description = "This is a small Python module for parsing Pip requirement files."
+optional = false
+python-versions = "<4.0,>=3.8"
+files = [
+ {file = "requirements_parser-0.11.0-py3-none-any.whl", hash = "sha256:50379eb50311834386c2568263ae5225d7b9d0867fb55cf4ecc93959de2c2684"},
+ {file = "requirements_parser-0.11.0.tar.gz", hash = "sha256:35f36dc969d14830bf459803da84f314dc3d17c802592e9e970f63d0359e5920"},
+]
+
+[package.dependencies]
+packaging = ">=23.2"
+types-setuptools = ">=69.1.0"
+
+[[package]]
+name = "six"
+version = "1.17.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
+]
+
+[[package]]
+name = "types-setuptools"
+version = "75.8.0.20250225"
+description = "Typing stubs for setuptools"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "types_setuptools-75.8.0.20250225-py3-none-any.whl", hash = "sha256:94c86b439cc60bcc68c1cda3fd2c301f007f8f9502f4fbb54c66cb5ce9b875af"},
+ {file = "types_setuptools-75.8.0.20250225.tar.gz", hash = "sha256:6038f7e983d55792a5f90d8fdbf5d4c186026214a16bb65dd6ae83c624ae9636"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+]
+
+[[package]]
+name = "tzdata"
+version = "2025.1"
+description = "Provider of IANA time zone data"
+optional = false
+python-versions = ">=2"
+files = [
+ {file = "tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639"},
+ {file = "tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694"},
+]
+
+[[package]]
+name = "tzlocal"
+version = "5.3"
+description = "tzinfo object for the local timezone"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "tzlocal-5.3-py3-none-any.whl", hash = "sha256:3814135a1bb29763c6e4f08fd6e41dbb435c7a60bfbb03270211bcc537187d8c"},
+ {file = "tzlocal-5.3.tar.gz", hash = "sha256:2fafbfc07e9d8b49ade18f898d6bcd37ae88ce3ad6486842a2e4f03af68323d2"},
+]
+
+[package.dependencies]
+tzdata = {version = "*", markers = "platform_system == \"Windows\""}
+
+[package.extras]
+devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.12"
+content-hash = "7112b24c85d31e228e464b5c9621e985ae19504e26e1fa6c7f32d8afbf33b86d"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..c5cdf6e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,31 @@
+[tool.poetry]
+name = "learnlytics"
+version = "0.1.0"
+description = ""
+authors = ["DerGrumpf "]
+readme = "README.md"
+
+[tool.poetry.group.dev.dependencies]
+flatpak-pip-generator = "^24.0.0"
+pandas = "^2.2.3"
+
+[project]
+package-mode = false
+
+[project.scripts]
+main = "learnlytics:main.py"
+
+[tool.poetry.dependencies]
+python = "^3.12"
+numpy = "^2.2.3"
+imgui-bundle = "^1.6.2"
+peewee = "^3.17.9"
+colour = "^0.1.5"
+pytz = "^2025.1"
+tzlocal = "^5.3"
+pdf2image = "^1.17.0"
+
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/state b/state
new file mode 100644
index 0000000..acb6f81
Binary files /dev/null and b/state differ
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29