diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e9baacd --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +init: + pip install -r requirements.txt + +.PHONY: init diff --git a/assets/Student_list.csv b/assets/Student_list.csv index 5271b0d..53a3298 100644 --- a/assets/Student_list.csv +++ b/assets/Student_list.csv @@ -1,38 +1,39 @@ 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 Percent,30.5,15,18,28,17,17,17,22,0,18 -Marleen,Adolphi,Female,MeWi6,30 Percent,29.5,15,18,32,19,20,17,24,23,0 -Sarina,Apel,Female,MeWi1,30 Percent,28.5,15,18,32,20,20,21,24,20,23 -Skofiare,Berisha,Female,DiKum,30 Percent,29.5,13,18,34,20,17,20,26,16,0 -Aurela,Brahimi,Female,MeWi2,30 Percent,17.5,15,15.5,26,16,17,19,16,0,0 -Cam Thu,Do,Female,MeWi3,30 Percent,31,15,18,34,19,20,21.5,22,12,0 -Nova,Eib,Female,MeWi4,30 Percent,31,15,15,34,20,20,21,27,19,21 -Lena,Fricke,Female,MeWi4,30 Percent,0,0,0,0,0,0,0,0,0,0 -Nele,Grundke,Female,MeWi6,30 Percent,23.5,13,16,28,20,17,21,18,22,11 -Anna,Grünewald,Female,MeWi3,30 Percent,12,14,16,29,16,15,19,9,0,0 -Yannik,Haupt,Male,NoGroup,30 Percent,18,6,14,21,13,2,9,0,0,0 -Janna,Heiny,Female,MeWi1,30 Percent,30,15,18,33,18,20,22,25,24,30 -Milena,Krieger,Female,MeWi1,30 Percent,30,15,18,33,20,20,21.5,26,20,22 -Julia,Limbach,Female,MeWi6,30 Percent,27.5,12,18,29,11,19,17.5,26,24,28 -Viktoria,Litza,Female,MeWi5,30 Percent,21.5,15,18,27,13,20,22,21,21,30 -Leonie,Manthey,Female,MeWi1,30 Percent,28.5,14,18,29,20,10,18,23,16,28 -Izabel,Mike,Female,MeWi2,30 Percent,29.5,15,15,35,11,15,19,21,21,27 -Lea,Noglik,Female,MeWi5,30 Percent,22.5,15,17,34,13,10,20,21,19,6 -Donika,Nuhiu,Female,MeWi5,30 Percent,31,13.5,18,35,14,10,17,18,19,8 -Julia,Renner,Female,MeWi4,30 Percent,27.5,10,14,32,20,17,11,20,24,14 -Fabian,Rothberger,Male,MeWi3,30 Percent,30.5,15,18,34,17,17,19,22,18,30 -Natascha,Rott,Female,MeWi1,30 Percent,29.5,12,18,32,19,20,21,26,23,26 -Isabel,Rudolf,Female,MeWi4,30 Percent,27.5,9,17,34,16,19,19,21,16,14 -Melina,Sablotny,Female,MeWi6,30 Percent,31,15,18,33,20,20,21,19,11,28 -Alea,Schleier,Female,DiKum,30 Percent,27,14,18,34,16,18,21.5,22,15,22 -Flemming,Schur,Male,MeWi3,30 Percent,29.5,15,17,34,19,20,19,22,18,27 -Marie,Seeger,Female,DiKum,30 Percent,27.5,15,18,32,14,9,17,22,9,25 -Lucy,Thiele,Female,MeWi6,30 Percent,27.5,15,18,27,20,17,19,18,22,25 -Lara,Troschke,Female,MeWi2,30 Percent,28.5,14,17,28,13,19,21,25,12,24 -Inga-Brit,Turschner,Female,MeWi2,30 Percent,25.5,14,18,34,20,16,19,22,17,30 -Alea,Unger,Female,MeWi5,30 Percent,30,12,18,31,20,20,21,22,15,21.5 -Marie,Wallbaum,Female,MeWi5,30 Percent,28.5,14,18,34,17,20,19,24,12,22 -Katharina,Walz,Female,MeWi4,30 Percent,31,15,18,31,19,19,17,24,17,14.5 -Xiaowei,Wang,Male,NoGroup,30 Percent,30.5,14,18,26,19,17,0,0,0,0 -Lilly-Lu,Warnken,Female,DiKum,30 Percent,30,15,18,30,14,17,19,14,16,24 +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 index 8e2e875..8b67801 100644 Binary files a/assets/WiSe_24_25.db and b/assets/WiSe_24_25.db differ diff --git a/assets/convert.py b/assets/convert.py index 7c31ae1..c32af74 100644 --- a/assets/convert.py +++ b/assets/convert.py @@ -1,8 +1,9 @@ import pandas as pd import pprint import sys -sys.path.append('..') +sys.path.append('../grapher/dbmodel') from model import * +from utils import * df = pd.read_csv("Student_list.csv") df = df.dropna() @@ -31,10 +32,8 @@ groups = { } print(df) +init_db('WiSe_24_25.db') -db.init("WiSe_24_25.db") -db.connect() -db.create_tables([Class, Student, Lecture, Submission, Group]) # Create Class clas = Class.create(name='WiSe 24/25') @@ -55,7 +54,8 @@ for index, row in df.iterrows(): sex=row["Sex"], class_id=clas.id, group_id=Group.select().where(Group.name == row["Group"]), - grader=row["Grader"] + grader=row["Grader"], + residence_id=-1 ) for title, points in list(row.to_dict().items())[5:]: diff --git a/grader/tests/base_grader.py b/grader/tests/base_grader.py deleted file mode 100644 index 94e8d85..0000000 --- a/grader/tests/base_grader.py +++ /dev/null @@ -1,76 +0,0 @@ -import sys -sys.path.append("..") - -from valuation import BaseGrading - -# Testing -import unittest -from unittest.mock import patch -class TestBaseGrading(unittest.TestCase): - test_schema = {"Grade1": 0.1, "Grade2": 0.3} - - @patch.multiple(BaseGrading, __abstractmethods__=set()) - def get_base_grader(self): - return BaseGrading(self.test_schema, "TestGrader") - - def test_getter(self): - grader = self.get_base_grader() - self.assertEqual(grader.get("Grade1"), self.test_schema["Grade1"]) - self.assertEqual(grader.get("grade1"), self.test_schema["Grade1"]) - - def test_len(self): - grader = self.get_base_grader() - self.assertEqual(len(grader), len(self.test_schema)) - - def test_contains(self): - grader = self.get_base_grader() - self.assertTrue(0.1 in grader) - self.assertTrue(0.9 in grader) - self.assertFalse(100 in grader) - self.assertFalse(None in grader) - self.assertTrue("Grade1" in grader) - self.assertTrue("gRADE2" in grader) - - def test_iter(self): - grader = self.get_base_grader() - for grade, test in zip(grader, self.test_schema): - self.assertEqual(grade, test) - - def test_reversed(self): - grader = self.get_base_grader() - for grade, test in zip(reversed(grader), reversed(self.test_schema)): - self.assertEqual(grade, test) - - def test_str(self): - grader = self.get_base_grader() - self.assertEqual(str(grader), "TestGrader") - - def test_repr(self): - grader = self.get_base_grader() - self.assertEqual(repr(grader), f"") - - def test_eq(self): - grader = self.get_base_grader() - self.assertTrue(grader == grader) - self.assertTrue(grader != grader) - - def test_keys(self): - grader = self.get_base_grader() - for k1, t1 in zip(grader.keys(), self.test_schema.keys()): - self.assertEqual(k1, t1) - - def test_items(self): - grader = self.get_base_grader() - for v1, t1 in zip(grader.values(), self.test_schema.values()): - self.assertEqual(v1, t1) - - def test_items(self): - grader = self.get_base_grader() - for g1, t1 in zip(grader.items(), self.test_schema.items()): - k, v = g1 - tk, tv = t1 - self.assertEqual(k, tk) - self.assertEqual(v, tv) - -if __name__ == "__main__": - unittest.main() diff --git a/grapher/dbmodel/__init__.py b/grapher/dbmodel/__init__.py new file mode 100644 index 0000000..b7ff6ee --- /dev/null +++ b/grapher/dbmodel/__init__.py @@ -0,0 +1,15 @@ +from .utils import ( + tables, + table_labels, + init_db, + save_as_json, + create_from_json +) + +from .model import db +from .view import Cache + +# Export tables & corresponding View +for table in tables: + globals()[table.__name__] = table + globals()[f"{table.__name__}Cache"] = Cache(table) diff --git a/grapher/dbmodel/model.dbml b/grapher/dbmodel/model.dbml new file mode 100644 index 0000000..f8a12d6 --- /dev/null +++ b/grapher/dbmodel/model.dbml @@ -0,0 +1,61 @@ +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 + residence integer [ref: - Residence.id] + 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] + points float + created_at timestamp +} + +Table Group { + id integer [primary key] + name varchar + project varchar + class_id integer [ref: < Class.id] + created_at timestamp +} + +Table Town { + plz integer [primary key] + name varchar + created_at timestamp +} + +Table Address { + id integer [primary key] + street varchar + number integer + created_at timestamp +} + +Table Residence { + id integer [primary key] + town_plz integer [ref: > Town.plz] + address_id integer [ref: > Address.id] + created_at timestamp +} + diff --git a/grapher/dbmodel/model.py b/grapher/dbmodel/model.py new file mode 100644 index 0000000..a40a902 --- /dev/null +++ b/grapher/dbmodel/model.py @@ -0,0 +1,117 @@ +''' +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 + +import json +from typing import TextIO +from pathlib import Path + +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 Town(BaseModel): + ''' + Table for Storing Town Data + ''' + plz = IntegerField(primary_key=True) + name = CharField() + created_at = DateTimeField(default=datetime.now) + + +class Address(BaseModel): + ''' + Table for Storing Address Data + ''' + street = CharField() + number = IntegerField() + created_at = DateTimeField(default=datetime.now) + + +class Residence(BaseModel): + ''' + Table for Storing a unique Postal Adress + by combining Towntable Data with Addresstable Data + ''' + town_plz = ForeignKeyField(Town, backref='plz') + address_id = ForeignKeyField(Address, backref='address') + created_at = DateTimeField(default=datetime.now) + + +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() + 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() + residence = ForeignKeyField(Residence, backref='residence', null=True) + 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') + points = FloatField() + created_at = DateTimeField(default=datetime.now) + diff --git a/grapher/dbmodel/utils.py b/grapher/dbmodel/utils.py new file mode 100644 index 0000000..04e9ad9 --- /dev/null +++ b/grapher/dbmodel/utils.py @@ -0,0 +1,172 @@ +''' +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/grapher/dbmodel/view.py b/grapher/dbmodel/view.py new file mode 100644 index 0000000..de97b1c --- /dev/null +++ b/grapher/dbmodel/view.py @@ -0,0 +1,122 @@ +''' +Module providing Database Views +''' + +from collections.abc import Sequence +from typing import Any + +from playhouse.shortcuts import model_to_dict + +from .model import * +from .utils import tables + +class Cache(Sequence): + ''' + Sequence Class caching Data from a BaseModel Table + ''' + def __init__(self, table: BaseModel) -> None: + assert any(table.__name__ == t.__name__ for t in tables), f"Must provide a BaseModel Object; not {type(table)}" + + self.table = table + self.data = None + + def update(self, value: Any, update_param: str = 'id') -> None: + ''' + Updates Cache by selecting data given by self.table. == value + + ''' + assert update_param in self.table._meta.sorted_field_names, f"Update Parameter must exist on {self.table.__name__}" + + param = getattr(self.table, update_param) + data = self.table.select().where(param == value) + self.data = list(data) if data else None + + def is_None(self) -> bool: + ''' + Check if Cache is Empty + ''' + return self.data is None + + def __getitem__(self, id: int) -> BaseModel | None: + ''' + Get item from Cache based on provided id + ''' + assert type(id) is int, "ID must be an Integer" + for el in self.data: + if el.get_id() == id: + return el + return None + + def __len__(self) -> int: + ''' + returns Number of Elements Stored in Cache + ''' + return len(self.data) + + def __contains__(self, data: BaseModel | int) -> bool: + ''' + Check if provided BaseModel object is stored in Cache + ''' + assert isinstance(data, (self.table, int)), f"Check could only be made if Object of type {self.table.__name__} or integer id" + + if isinstance(data, BaseModel): + return data in self.data + if isinstance(data, int): + for el in self.data: + if el.get_id() == data: + return True + return False + + def __iter__(self) -> BaseModel: + ''' + Generator returning BaseModel Objects stored in Cache + Note: Like SQL there is no real Order to the Data + ''' + yield from self.data + + def __reversed__(self) -> BaseModel: + ''' + Same as __iter__ but reversed + ''' + yield from reversed(self.data) + + def index(self, value, start=0, stop=None): + ''' + Not Implemented do to SQL Order Issue + ''' + return NotImplemented + + def count(self, value): + ''' + Not Implemented do to assumed Uniqness of Data + ''' + return NotImplemented + + def to_dict(self, recurse=False) -> list[dict]: + ''' + Returns a list containing the resolved BaseModel data as dict + + If recurse == True Database trys to recurse all Relationships + Note: This Operation isn't Cache performed! + + Format: + [ + {: , ...}, + ... + ] + ''' + assert type(recurse) is bool, "recurse must be bool" + return [model_to_dict(data, recurse=recurse) for data in self.data] + + def filter(self): + ''' + WIP + ''' + pass + +if __name__ == "__main__": + from utils import init_db + init_db('test.db') + from pprint import pprint + + help(TownCache) diff --git a/grapher/grader/__init__.py b/grapher/grader/__init__.py new file mode 100644 index 0000000..44fbccd --- /dev/null +++ b/grapher/grader/__init__.py @@ -0,0 +1,8 @@ +from .valuation import ( + get_gradings, + get_grader, + Std30PercentRule, + Std50PercentRule, + StdGermanGradingMiddleSchool, + StdGermanGradingHighSchool +) diff --git a/grader/valuation.py b/grapher/grader/valuation.py similarity index 97% rename from grader/valuation.py rename to grapher/grader/valuation.py index ef42f74..d02c55f 100644 --- a/grader/valuation.py +++ b/grapher/grader/valuation.py @@ -158,12 +158,12 @@ class StdGermanGrading(BaseGrading): Std30PercentRule = StdPercentRule({ "pAssed": 0.3, "Failed": 0.0 -}, "Std30PercentRule", "30 Percent") +}, "Std30PercentRule", "30%") Std50PercentRule = StdPercentRule({ "Passed": 0.5, "Failed": 0.0 -}, "Std50PercentRule", "50 Percent") +}, "Std50PercentRule", "50%") StdGermanGradingMiddleSchool = StdGermanGrading({ 1: 0.96, @@ -172,7 +172,7 @@ StdGermanGradingMiddleSchool = StdGermanGrading({ 4: 0.45, 5: 0.16, 6: 0.00 -}, "StdGermanGradingMiddleSchool", "Secondary School") +}, "StdGermanGradingMiddleSchool", "Mittelstufe") StdGermanGradingHighSchool = StdGermanGrading({ 15: 0.95, diff --git a/grapher/gui/__init__.py b/grapher/gui/__init__.py new file mode 100644 index 0000000..1dba19a --- /dev/null +++ b/grapher/gui/__init__.py @@ -0,0 +1,4 @@ +from .appstate import AppState +from .analyzer import analyzer_layout +from .database import database_editor_layout +from .gui import menu_bar, status_bar diff --git a/grapher/gui/analyzer/__init__.py b/grapher/gui/analyzer/__init__.py new file mode 100644 index 0000000..c68f99e --- /dev/null +++ b/grapher/gui/analyzer/__init__.py @@ -0,0 +1,63 @@ +from imgui_bundle import hello_imgui, imgui +#from app_state import AppState +from typing import List + +from .analyzer import * + +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(app_state) -> List[hello_imgui.DockableWindow]: + student_selector = hello_imgui.DockableWindow() + student_selector.label = "Students" + student_selector.dock_space_name = "CommandSpace" + student_selector.gui_function = lambda: student_list(app_state) + + student_info = hello_imgui.DockableWindow() + student_info.label = "Student Analyzer" + student_info.dock_space_name = "MainDockSpace" + student_info.gui_function = lambda: student_graph(app_state) + + sex_info = hello_imgui.DockableWindow() + sex_info.label = "Analyze by Gender" + sex_info.dock_space_name = "MainDockSpace" + sex_info.gui_function = lambda: sex_graph(app_state) + + student_ranking = hello_imgui.DockableWindow() + student_ranking.label = "Ranking" + student_ranking.dock_space_name = "MainDockSpace" + student_ranking.gui_function = lambda: ranking(app_state) + + return [ + student_selector, + student_info, sex_info, + student_ranking, + ] + +def analyzer_layout(app_state) -> 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(app_state) + return docking_params diff --git a/analyzer.py b/grapher/gui/analyzer/analyzer.py similarity index 83% rename from analyzer.py rename to grapher/gui/analyzer/analyzer.py index badbdc8..15f6a3e 100644 --- a/analyzer.py +++ b/grapher/gui/analyzer/analyzer.py @@ -1,6 +1,6 @@ # Custom -from model import * -from appstate import AppState +from dbmodel import * +from gui import AppState from grader.valuation import * # External @@ -140,8 +140,8 @@ def student_graph(app_state: AppState) -> None: 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.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)] @@ -316,60 +316,4 @@ def ranking(app_state: AppState) -> None: if imgui.button("Change"): statics.state = not statics.state -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(app_state: AppState) -> List[hello_imgui.DockableWindow]: - student_selector = hello_imgui.DockableWindow() - student_selector.label = "Students" - student_selector.dock_space_name = "CommandSpace" - student_selector.gui_function = lambda: student_list(app_state) - - student_info = hello_imgui.DockableWindow() - student_info.label = "Student Analyzer" - student_info.dock_space_name = "MainDockSpace" - student_info.gui_function = lambda: student_graph(app_state) - - sex_info = hello_imgui.DockableWindow() - sex_info.label = "Analyze by Gender" - sex_info.dock_space_name = "MainDockSpace" - sex_info.gui_function = lambda: sex_graph(app_state) - - student_ranking = hello_imgui.DockableWindow() - student_ranking.label = "Ranking" - student_ranking.dock_space_name = "MainDockSpace" - student_ranking.gui_function = lambda: ranking(app_state) - - return [ - student_selector, - student_info, sex_info, - student_ranking, - ] - -def analyzer_layout(app_state: AppState) -> 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(app_state) - return docking_params diff --git a/appstate.py b/grapher/gui/appstate.py similarity index 70% rename from appstate.py rename to grapher/gui/appstate.py index 31979ca..134130d 100644 --- a/appstate.py +++ b/grapher/gui/appstate.py @@ -1,6 +1,4 @@ -from imgui_bundle import hello_imgui -from model import * -from datetime import datetime +from dbmodel import * class AppState: current_class_id: int @@ -28,8 +26,6 @@ class AppState: submissions = Submission.select().where(Submission.lecture_id == self.current_lecture_id and Submission.student_id == self.current_student_id) self.current_submission_id = submissions[0].id if submissions else None - LOG_DEBUG(f"Updated App State {repr(self)}") - def __repr__(self): return f''' Class ID: {self.current_class_id} @@ -38,12 +34,5 @@ class AppState: Submission ID: {self.current_submission_id} ''' -def log(log_level: hello_imgui.LogLevel, msg: str) -> None: - time = datetime.now().strftime("%X") - hello_imgui.log(log_level, f"[{time}] {msg}") -LOG_DEBUG = lambda msg: log(hello_imgui.LogLevel.debug, msg) -LOG_INFO = lambda msg: log(hello_imgui.LogLevel.info, msg) -LOG_WARNING = lambda msg: log(hello_imgui.LogLevel.warning, msg) -LOG_ERROR = lambda msg: log(hello_imgui.LogLevel.error, msg) diff --git a/grapher/gui/database/__init__.py b/grapher/gui/database/__init__.py new file mode 100644 index 0000000..b5b56cf --- /dev/null +++ b/grapher/gui/database/__init__.py @@ -0,0 +1,80 @@ +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(app_state) -> 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(app_state) + + 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(app_state) + + eeditor = hello_imgui.DockableWindow() + eeditor.label = "Editor" + eeditor.dock_space_name = "CommandSpace" + eeditor.gui_function = lambda: editor(app_state) + + return [file_dialog, log, table_view, eeditor] + +def database_editor_layout(app_state) -> 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(app_state) + return docking_params diff --git a/database.py b/grapher/gui/database/database.py similarity index 71% rename from database.py rename to grapher/gui/database/database.py index c5863b9..c49a27e 100644 --- a/database.py +++ b/grapher/gui/database/database.py @@ -7,8 +7,9 @@ to set up the database editing environment. """ # Custom -from model import * -from appstate import * +from dbmodel import * +from gui import * +from grader import get_gradings # External from imgui_bundle import ( @@ -20,11 +21,18 @@ from imgui_bundle import ( hello_imgui ) +import peewee + # Built In from typing import List import shelve from pathlib import Path -from datetime import datetime +from datetime import datetime, timezone +import pytz +from tzlocal import get_localzone + +LOG_INFO = lambda x: x +LOG_DEBUG = lambda x: x def file_info(path: Path) -> None: """ @@ -86,7 +94,7 @@ def select_file(app_state: AppState): # Retrieve the last used database file from persistent storage with shelve.open("state") as state: - statics.current = Path(state["DB"]) + statics.current = Path(state.get("DB") or "") statics.inited = True # Render UI title and display file information @@ -125,18 +133,14 @@ def select_file(app_state: AppState): # 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 - db.init(file) - db.connect(reuse_if_open=True) - db.create_tables([Class, Student, Lecture, Submission, Group]) + init_db(file) load_from_json(str(info)) # Convert and load JSON data into the database LOG_INFO(f"Successfully created {file}") # Handle SQLite database files directly if statics.res.extension() == '.db': file = str(statics.res.path()) - db.init(file) - db.connect(reuse_if_open=True) - db.create_tables([Class, Student, Lecture, Submission, Group]) + init_db(file) LOG_INFO(f"Successfully loaded {filename}") # Save the selected database path to persistent storage @@ -150,6 +154,11 @@ def select_file(app_state: AppState): @immapp.static(inited=False) def table(app_state: AppState) -> None: statics = table + + if db.is_closed(): + imgui.text("DB") + return + if not statics.inited: statics.table_flags = ( imgui.TableFlags_.row_bg.value @@ -215,14 +224,17 @@ def table(app_state: AppState) -> None: imgui.end_table() @immapp.static(inited=False) -def class_editor() -> None: +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 + statics = class_editor + if db.is_closed(): + imgui.text("DB") + return statics.classes = Class.select() if not statics.inited: statics.selected = 0 @@ -260,86 +272,91 @@ def class_editor() -> None: LOG_INFO(f"Deleted: {clas.name}") -def database_editor(app_state: AppState) -> None: - """ - Database Editor UI Function. +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() - Calls the class editor function to render its UI component. - - :param app_state: The application state containing relevant database information. - """ - class_editor() + 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 -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 + 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() - 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 + if imgui.button(action): + match action: + case "Create": + print("Create") + case "Update": + print("Update") + case "Delete": + print("Delete") + case _: + print("Unknown Case") - 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 + # Clear & Close Popup + selectors.clear() + imgui.close_current_popup() - return [split_main_misc, split_main_command, split_main_command2] -def set_database_editor_layout(app_state: AppState) -> 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(app_state) - - 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(app_state) - - editor = hello_imgui.DockableWindow() - editor.label = "Editor" - editor.dock_space_name = "CommandSpace" - editor.gui_function = lambda: database_editor(app_state) - - return [file_dialog, log, table_view, editor] - -def database_editor_layout(app_state: AppState) -> 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(app_state) - return docking_params diff --git a/grapher/gui/database/editor.py b/grapher/gui/database/editor.py new file mode 100644 index 0000000..b8dac38 --- /dev/null +++ b/grapher/gui/database/editor.py @@ -0,0 +1,142 @@ +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(app_state) -> None: + """ + Database Editor UI Function. + + Calls the class editor function to render its UI component. + + :param app_state: The application state containing relevant database information. + """ + 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/grapher/gui/gui.py b/grapher/gui/gui.py new file mode 100644 index 0000000..99a46af --- /dev/null +++ b/grapher/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 +from .appstate import AppState + +# 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(app_state: AppState) -> None: + """Displays the status bar information.""" + imgui.text("Student Analyzer by @DerGrumpf") + diff --git a/grapher/gui/logger.py b/grapher/gui/logger.py new file mode 100644 index 0000000..1444e2f --- /dev/null +++ b/grapher/gui/logger.py @@ -0,0 +1,24 @@ +''' +WIP +''' + +from datetime import datetime +from imgui_bundle import hello_imgui +import logging + +FORMAT = '[%(asctime)s] ' + +def log(log_level: hello_imgui.LogLevel, msg: str) -> None: + time = datetime.now().strftime("%X") + hello_imgui.log(log_level, f"[{time}] {msg}") + +LOG_DEBUG = lambda msg: log(hello_imgui.LogLevel.debug, msg) +LOG_INFO = lambda msg: log(hello_imgui.LogLevel.info, msg) +LOG_WARNING = lambda msg: log(hello_imgui.LogLevel.warning, msg) +LOG_ERROR = lambda msg: log(hello_imgui.LogLevel.error, msg) + +logging.basicConfig() +logger = logging.getLogger('AppState') + + +logger.info("Hello") diff --git a/grapher/gui/state b/grapher/gui/state new file mode 100644 index 0000000..f03fe98 Binary files /dev/null and b/grapher/gui/state differ diff --git a/gui.py b/grapher/main.py similarity index 52% rename from gui.py rename to grapher/main.py index 2b1ba3a..788294e 100644 --- a/gui.py +++ b/grapher/main.py @@ -1,54 +1,21 @@ -""" -Student Analyzer Application +import shelve -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. +from imgui_bundle import ( + hello_imgui, + immapp +) -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. -""" +from gui.logger import LOG_ERROR -# Custom -from model import * # Importing database models like Class, Student, Lecture, and Submission -from appstate import AppState, LOG_ERROR # Application state management - -# Layouts -from analyzer import analyzer_layout # Main layout for the analyzer -from database import database_editor_layout # Alternative layout for database editing - -# External -from imgui_bundle import imgui, immapp, hello_imgui, ImVec2 # ImGui-based UI framework - -# Built-in -import shelve # Persistent key-value storage -from typing import List # Type hinting - -def menu_bar(runner_params: hello_imgui.RunnerParams) -> None: - """Defines the application's menu bar.""" - try: - hello_imgui.show_app_menu(runner_params) - hello_imgui.show_view_menu(runner_params) - - if imgui.begin_menu("File"): - clicked, _ = imgui.menu_item("Open", "", False) - if clicked: - pass # TODO: Implement file opening logic - imgui.end_menu() - except Exception as e: - LOG_ERROR(f"menu_bar {e}") - - -def status_bar(app_state: AppState) -> None: - """Displays the status bar information.""" - try: - imgui.text("Student Analyzer by @DerGrumpf") - except Exception as e: - LOG_ERROR(f"status_bar {e}") +from gui import ( + AppState, + 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.""" @@ -58,13 +25,12 @@ def main() -> None: try: with shelve.open("state") as state: v = state.get("DB") # Retrieve stored database connection info - if v: - db.init(v) - db.connect() - db.create_tables([Class, Student, Lecture, Submission, Group]) # Ensure tables exist - app_state.update() + print(v) + init_db(v) + app_state.update() except Exception as e: LOG_ERROR(f"Database Initialization {e}") + print(e) # Set Window Parameters runner_params = hello_imgui.RunnerParams() @@ -110,3 +76,4 @@ def main() -> None: if __name__ == "__main__": main() + diff --git a/grapher/state b/grapher/state new file mode 100644 index 0000000..7135d88 Binary files /dev/null and b/grapher/state differ diff --git a/main.py b/main.py deleted file mode 100644 index 818b799..0000000 --- a/main.py +++ /dev/null @@ -1,21 +0,0 @@ -import imgui -import numpy - -phil = Student( - "Phil Keier", "772fb04b24caa68fd38a05ec2a22e62b", "Geomapping", - [Lecture("1. Tutorial 1", 28.5, 31), Lecture("2. Tutorial 2", 4.5, 15), Lecture("3. Extended Application", 18, 18)] - ) -nova = Student( - "Nova Eib", "772fb04b24caa68fd38a05ec2a22e62b", "Mapping Maps", - [Lecture("1. Tutorial 1", 28.5, 31), Lecture("2. Tutorial 2", 4.5, 15), Lecture("3. Extended Application", 18, 18)] - ) -kathi = Student( - "Katharina Walz", "772fb04b24caa68fd38a05ec2a22e62b", "Geomapping", - [Lecture("1. Tutorial 1", 28.5, 31), Lecture("2. Tutorial 2", 4.5, 15), Lecture("3. Extended Application", 18, 18), Lecture("4. Numpy & MatPlotLib", 3, 30)] - ) - -students = [phil, nova, kathi] - -if __name__ == "__main__": - gui = GUI() - diff --git a/model.py b/model.py deleted file mode 100644 index e99b546..0000000 --- a/model.py +++ /dev/null @@ -1,176 +0,0 @@ -from peewee import * -from datetime import datetime - -import json -from typing import TextIO -from pathlib import Path - -db = SqliteDatabase(None, autoconnect=False) - -class BaseModel(Model): - class Meta: - database = db - -class Class(BaseModel): - name = CharField() - created_at = DateTimeField(default=datetime.now) - -class Group(BaseModel): - name = CharField() - project = CharField() - class_id = ForeignKeyField(Class, backref='class') - created_at = DateTimeField(default=datetime.now) - -class Student(BaseModel): - 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): - title = CharField() - points = IntegerField() - class_id = ForeignKeyField(Class, backref='class') - created_at = DateTimeField(default=datetime.now) - -class Submission(BaseModel): - student_id = ForeignKeyField(Student, backref='student') - lecture_id = ForeignKeyField(Lecture, backref='lecture') - points = FloatField() - created_at = DateTimeField(default=datetime.now) - - -def load_from_json(fp: Path) -> None: - ''' - Rebuilding Database from a given json - ''' - with open(fp, "r") as file: - data = json.load(file) - - for c_k, c_v in data.items(): - Class.create( - name=c_k, - id=c_v["DB ID"], - created_at=c_v["Date"] - ) - #print(f"KLASSE = {c.id} {c.name} ({c.created_at})") - - for student in c_v["Students"]: - Student.create( - id=student["DB ID"], - created_at=student["Date"], - prename=student["First Name"], - surname=student["Last Name"], - sex=student["Sex"], - class_id=c_v["DB ID"] - ) - #print(f"STUDENT = {s.id}. {s.prename} {s.surname} {s.sex} ({s.created_at}) Klasse: {s.class_id}") - - for submission in student["Submissions"]: - Submission.create( - id=submission["DB ID"], - created_at=submission["Date"], - points=submission["Points"], - lecture_id=submission["Lecture ID"], - student_id=student["DB ID"] - ) - #print(f"SUBMISSION = {sub.id}. {sub.points} Lecture: {sub.lecture_id} Student: {sub.student_id} ({sub.created_at})") - - for lecture in c_v["Lectures"]: - Lecture.create( - id=lecture["DB ID"], - created_at=lecture["Date"], - title=lecture["Title"], - points=lecture["Points"], - class_id=c_v["DB ID"] - ) - #print(f"LECTURE = {l.id}. {l.title} {l.points} ({l.created_at}) Klasse: {l.class_id}") - -def dump_to_json(fp: Path, indent=None) -> None: - ''' - Dump existing Database to Json - ''' - classes = Class.select() - d = {c.name: { - "DB ID": int(c.id), - "Date": c.created_at.isoformat(), - "Students": [ - { - "DB ID": s.id, - "Date": s.created_at.isoformat(), - "First Name": s.prename, - "Last Name": s.surname, - "Sex": s.sex, - "Submissions": [ - { - "DB ID": sub.id, - "Date": sub.created_at.isoformat(), - "Points": sub.points, - "Lecture ID": sub.lecture_id.id - } - for sub in Submission.select().where(Submission.student_id == s.id) - ] - } - for s in Student.select().where(Student.class_id == c.id) - ], - "Lectures": [ - { - "DB ID": l.id, - "Date": l.created_at.isoformat(), - "Title": l.title, - "Points": l.points - } - for l in Lecture.select().where(Lecture.class_id == c.id) - ], - } - for c in classes - } - - with open(fp, "w") as file: - json.dump(d, file, indent=indent) - -def main(): - import random - # Generate Test Data - class1 = Class.create(name="WiSe 22/23") - class2 = Class.create(name="WiSe 23/24") - class3 = Class.create(name="WiSe 24/25") - - phil = Student.create(prename="Phil", surname="Keier", sex="Male", class_id=class1.id) - calvin = Student.create(prename="Calvin", surname="Brandt", sex="Male", class_id=class2.id) - nova = Student.create(prename="Nova", surname="Eib", sex="Female", class_id=class1.id) - kathi = Student.create(prename="Katharina", surname="Walz", sex="Female", class_id=class3.id) - victoria = Student.create(prename="Victoria", surname="Möller", sex="Female", class_id=class3.id) - - lec1 = Lecture.create(title="Tutorial 1", points=30, class_id=class1.id) - lec2 = Lecture.create(title="Tutorial 1", points=30, class_id=class3.id) - lec3 = Lecture.create(title="Tutorial 2", points=20, class_id=class1.id) - lec4 = Lecture.create(title="Tutorial 2", points=20, class_id=class2.id) - lec5 = Lecture.create(title="Extended Applications", points=44, class_id=class1.id) - - sub1_phil = Submission.create(student_id=phil.id, lecture_id=lec1.id, points=random.randint(0, lec1.points)) - sub2_phil = Submission.create(student_id=phil.id, lecture_id=lec3.id, points=random.randint(0, lec3.points)) - sub3_phil = Submission.create(student_id=phil.id, lecture_id=lec5.id, points=random.randint(0, lec5.points)) - sub1_nova = Submission.create(student_id=nova.id, lecture_id=lec1.id, points=random.randint(0, lec1.points)) - sub2_nova = Submission.create(student_id=nova.id, lecture_id=lec3.id, points=random.randint(0, lec3.points)) - sub1_kathi = Submission.create(student_id=kathi.id, lecture_id=lec3.id, points=random.randint(0, lec3.points)) - sub1_vici = Submission.create(student_id=victoria.id, lecture_id=lec2.id, points=random.randint(0, lec2.points)) - - return - fp = Path().cwd()/"Test.json" - dump_to_json(fp) - db.close() - db.init("Test.db") - db.connect() - db.create_tables([Class, Student, Lecture, Submission]) - load_from_json(fp) - - -if __name__ == "__main__": - # main() - db.init('wise_24_25.db') - db.connect() - dump_to_json(Path().cwd()/"TEST.json") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d6553f0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[tool.poetry] +name = "grapher" +version = "0.1.0" +description = "A Quick & Dirty Student Analyzer written in Python & DearImGUI" +authors = ["DerGrumpf (Phil Keier) "] +readme = "README.md" +license = "MIT" +package-mode = false + +[project] +dependencies = [ + "annotated-types==0.7.0", + "glfw==2.8.0", + "imgui-bundle==1.6.2", + "munch==4.0.0", + "numpy==2.2.1", + "opencv-python==4.10.0.84", + "pandas==2.2.3", + "peewee==3.17.8", + "pillow==11.1.0", + "py-spy==0.4.0", + "pydantic==2.10.4", + "pydantic_core==2.27.2", + "PyGLM==2.7.3", + "PyOpenGL==3.1.7", + "python-dateutil==2.9.0.post0", + "pytz==2024.2", + "six==1.17.0", + "snakeviz==2.2.2", + "tornado==6.4.2", + "typing_extensions==4.12.2", + "tzdata==2024.2", +] + +[project.urls] +repository = "https://git.cyperpunk.de/DerGrumpf/grapher" + +[tool.poetry.dependencies] +python = "^3.12" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api"