Initial commit.
This commit is contained in:
parent
0d38b5d5ca
commit
8f66e48cea
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
.idea/
|
||||
.venv/
|
||||
.ipynb_checkpoints/
|
||||
*.pkl
|
||||
*.xlsx
|
||||
*.json
|
||||
|
||||
*.pyc
|
||||
|
||||
.DS_Store
|
52
README.md
52
README.md
@ -1,3 +1,53 @@
|
||||
# noteneingabetool
|
||||
# Noteneingabetool
|
||||
|
||||
Noteneingabetool mit Import u. Export aus HISInOne.
|
||||
|
||||
Autor: enrico.schroeder@th-nuernberg.de
|
||||
|
||||

|
||||
|
||||
# Features
|
||||
* **Import** von Studierendendaten aus einer HISInOne Prüfung.
|
||||
* Speichern der Daten in einer lokalen **Datenbank** (.json).
|
||||
* Mehrere **Sessions**.
|
||||
* Eingabe von **Punkten** der einzelnen Aufgaben.
|
||||
* Einstellen von **Notenparametern** (Punktzahl, Bestehens- und 1,0-Grenzen)
|
||||
* Berechnung der **Gesamtpunkte** und **Endnoten**.
|
||||
* **Statistiken**
|
||||
* **Export** der Daten im HISInOne-Format.
|
||||
|
||||
## Ideen (noch nicht implementiert)
|
||||
* Authentifizierung via AD, OpenID o.ä. (kompatibel mit HS-Infrastruktur)
|
||||
* Bearbeiten derselben Prüfung durch mehrere Prüfer(innen)
|
||||
* Deployment auf HS-Infrastruktur
|
||||
|
||||
**Pull requests welcome!**
|
||||
|
||||
# Installation
|
||||
Installation der Requirements durch `pip install requirements.txt`
|
||||
|
||||
# Verwendung
|
||||
|
||||
Starten des Programms durch `streamlit run streamlit_app.py`.
|
||||
|
||||
## Neue Prüfung anlegen
|
||||
1) Prüfung als Exceltabelle aus HISInOne exportieren.
|
||||
2) In der linken Leiste *Neue Session* wählen und die Exceltabelle ins Fenster ziehen.
|
||||

|
||||
3) Session-Token notieren (aktuell werden die Sessions zusätzlich als Dropdown in der Seitenleiste angezeigt, die Idee ist aber, dass die Tokens versteckt sind).
|
||||
|
||||
## Prüfung bearbeiten
|
||||
1) Im Hauptfenster oben die Namen der Aufgaben als kommaseparierte Liste hinzufügen, z.B. *1,2,3,4,5,6*. Format ist String, es sind also auch *1a,1b,1c* möglich. **ACHTUNG:** Wird nachträglich eine Aufgabe entfernt, sind die eingegebenen Punkte dieser Aufgabe weg. Ein Ändern der Aufgabenreihenfolge durch umsortieren ist aber möglich.
|
||||

|
||||
2) Anzahl Punkte und Bestehens- sowie 1,0-Grenze einstellen (ist jederzeit änderbar)
|
||||
3) In der Punktetabelle einen Zeile markieren, in der Tabelle unten die Punkte eintragen und auf **Submit** klicken. Die Punkte werden dann in die Haupttabelle übertragen und die Gesamtpunkte und Note aktualisiert.
|
||||

|
||||
4) (Optional) Statistken anschauen
|
||||

|
||||
5) Der Button unten exportiert die Tabelle im HISInOne-Format (Studis ohne Eintrag werden mit `NT` bewertet). Die Tabelle hat außerdem noch zusätzliche Tabellenblätter (Tabelle mit den einzelnen Punkten, Notenschlüssel), lässt sich aber so wie sie ist in HISInOne hochladen.
|
||||
|
||||
Jede Änderung wird automatisch in der .json-Datenbank lokal gespeichert.
|
||||
|
||||
## Prüfung laden
|
||||
1) In der Seitenleiste Sessiontoken ins Eingabefeld kopieren und Enter drücken, oder...
|
||||
2) Sessiontoken aus der Dropdown-Liste wählen (ist eigentlich eine Debugfunktion, wird vermutlich irgendwann entfernt)
|
||||
|
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
93
backend/db.py
Normal file
93
backend/db.py
Normal file
@ -0,0 +1,93 @@
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from tinydb import TinyDB, Query
|
||||
import secrets
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel, field_serializer
|
||||
|
||||
from .his_io import dtypes
|
||||
|
||||
|
||||
pandas_json_format = 'split'
|
||||
|
||||
|
||||
class Session(BaseModel):
|
||||
token: str
|
||||
table_name: str = ''
|
||||
students_df: pd.DataFrame
|
||||
points_df: pd.DataFrame
|
||||
his_header: pd.DataFrame
|
||||
grade_1_0: float = 0.92
|
||||
grade_4_0: float = 0.4
|
||||
max_points: int = 100
|
||||
created: datetime = datetime.now()
|
||||
edited: datetime = datetime.now()
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, Session) and \
|
||||
self.token == other.token and \
|
||||
self.table_name == other.table_name and \
|
||||
self.students_df.equals(other.students_df) and \
|
||||
self.points_df.equals(other.points_df) and \
|
||||
self.his_header.equals(other.his_header) and \
|
||||
self.grade_1_0 == other.grade_1_0 and \
|
||||
self.grade_4_0 == other.grade_4_0 and \
|
||||
self.max_points == other.max_points and \
|
||||
self.created == other.created and \
|
||||
self.edited == other.edited
|
||||
|
||||
@field_serializer('students_df', 'points_df')
|
||||
def serialize_df(self, df: pd.DataFrame):
|
||||
return df.reset_index().to_json(orient=pandas_json_format)
|
||||
|
||||
@field_serializer('his_header')
|
||||
def serialize_his_header(self, df):
|
||||
return df.to_json(orient=pandas_json_format)
|
||||
|
||||
@field_serializer('created', 'edited')
|
||||
def serialize_datetime(self, dt):
|
||||
return dt.isoformat()
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
class DB:
|
||||
def __init__(self, db_path='db.json'):
|
||||
self.db_path = Path(db_path)
|
||||
self.db = TinyDB(self.db_path, access_mode='r+')
|
||||
|
||||
def create_session(self):
|
||||
token = secrets.token_urlsafe(16)
|
||||
self.db.insert({'token': token})
|
||||
|
||||
return Session(**{
|
||||
'token': token,
|
||||
'students_df': pd.DataFrame(),
|
||||
'points_df': pd.DataFrame(),
|
||||
'his_header': pd.DataFrame(),
|
||||
})
|
||||
|
||||
def get_session(self, token: str) -> Session:
|
||||
results = self.db.search(Query().token == token)
|
||||
|
||||
if len(results) == 0:
|
||||
raise ValueError(f'Session with token {token} not found')
|
||||
|
||||
session = results[0].copy()
|
||||
session['points_df'] = pd.read_json(StringIO(session['points_df']), dtype=dtypes.students_table, orient=pandas_json_format).set_index('Matrikelnummer')
|
||||
session['students_df'] = pd.read_json(StringIO(session['students_df']), dtype=dtypes.students_table, orient=pandas_json_format).set_index('Matrikelnummer')
|
||||
session['his_header'] = pd.read_json(StringIO(session['his_header']), dtype=dtypes.his_header, orient=pandas_json_format)
|
||||
return Session(**session)
|
||||
|
||||
def save_session(self, session: Session):
|
||||
write_session = session.model_dump()
|
||||
|
||||
self.db.upsert(write_session, Query().token == write_session['token'])
|
||||
|
||||
def delete_session(self, session: Session):
|
||||
self.db.remove(Query().token == session.token)
|
||||
|
||||
def list_tokens(self):
|
||||
return [session['token'] for session in self.db.all()]
|
81
backend/his_io.py
Normal file
81
backend/his_io.py
Normal file
@ -0,0 +1,81 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
pandas_json_format = 'table'
|
||||
|
||||
|
||||
class dtypes:
|
||||
students_table = {'Examplan.id': 'int64', 'PrüfungsNr.': 'int64', 'Titel': 'O', 'Nachname': 'O', 'Vorname': 'O', 'Leistung': 'O', 'Versuch': 'int64', 'Status': 'O', 'ECTS': 'O', 'Semester': 'O', 'Jahr': 'int64', 'Prüfungsperiode': 'int64', 'Vermerk': 'O', 'Thema': 'O', 'Beginn': 'O', 'Gepl. Ende': 'O', 'Tatsächl. Ende': 'O', 'Prüfungsart': 'O', 'Prüfungsform': 'O', 'LockVersion': 'O'}
|
||||
his_header = 'O'
|
||||
|
||||
|
||||
def read_excel(bytes: BytesIO) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
try:
|
||||
excelfile = pd.ExcelFile(bytes, engine='openpyxl')
|
||||
except:
|
||||
return pd.DataFrame()
|
||||
|
||||
full_table = pd.read_excel(excelfile, header=None)
|
||||
|
||||
# Finde Indizes der Zeilen mit den Werten "startHISsheet" und "endHISsheet"
|
||||
def find_row_with_value(df, value):
|
||||
mask = full_table.iloc[:, 0] == value
|
||||
if not mask.any():
|
||||
raise ValueError(f'{value} nicht gefunden')
|
||||
return mask.idxmax()
|
||||
|
||||
start_idx = find_row_with_value(full_table, 'startHISsheet')
|
||||
end_idx = find_row_with_value(full_table, 'endHISsheet')
|
||||
|
||||
# Lade den HISinOne Header (die ersten start_idx+1 Zeilen)
|
||||
his_header = pd.read_excel(excelfile, nrows=start_idx+1, header=None)
|
||||
|
||||
# Lade Studentendaten (zwischen start_idx+1 und end_idx)
|
||||
students_table = pd.read_excel(excelfile,
|
||||
header=start_idx + 1,
|
||||
nrows=end_idx - start_idx - 2,
|
||||
index_col='Matrikelnummer',
|
||||
dtype=dtypes.students_table)
|
||||
|
||||
students_table = students_table.replace({np.nan: None})
|
||||
his_header = his_header.replace({np.nan: None})
|
||||
|
||||
return students_table, his_header
|
||||
|
||||
|
||||
def prepare_table(students_table: pd.DataFrame) -> pd.DataFrame:
|
||||
|
||||
# Erzeuge Tabelle zur Punkteeingabe
|
||||
points_table = students_table
|
||||
|
||||
new_columns = {'Punkte': np.nan, 'Note': np.nan}
|
||||
points_table = points_table.assign(**new_columns)
|
||||
|
||||
return points_table
|
||||
|
||||
|
||||
def export(edited_df: pd.DataFrame, his_header: pd.DataFrame, exercise_columns: list[str], grades_table: pd.DataFrame) -> BytesIO:
|
||||
edited_df['Leistung'] = edited_df['Note'].replace({np.nan: 'NT'})
|
||||
export_students_table = edited_df.drop(columns=['Punkte', 'Note']+exercise_columns).reset_index()
|
||||
|
||||
# Examplan.id muss erste Spalte sein (HISinOne prüft Spaltennamen nicht)
|
||||
export_students_table = export_students_table.reindex(columns=['Examplan.id'] + [col for col in export_students_table.columns if col != 'Examplan.id'])
|
||||
|
||||
export_full_table = pd.DataFrame(
|
||||
np.vstack([
|
||||
his_header.values, # HISinOne Header
|
||||
np.array(list(export_students_table)), # Tabellenheader (Matrikelnummer, Nachname, Vorname, ...)
|
||||
export_students_table.values, # Daten
|
||||
np.array(['endHISsheet'] + [''] * (len(list(export_students_table))-1)) # HISinOne Footer
|
||||
]),
|
||||
columns=export_students_table.columns)
|
||||
|
||||
# Exportiere Tabelle
|
||||
bytesio = BytesIO()
|
||||
with pd.ExcelWriter(bytesio, engine='openpyxl') as writer:
|
||||
export_full_table.to_excel(writer, sheet_name='First Sheet', header=False, index=False) # HISinOne Tabelle
|
||||
edited_df[['Nachname', 'Vorname', 'Punkte', 'Note']+exercise_columns].to_excel(writer, sheet_name='Aufgaben') # Punktergebnisse als extra Sheet
|
||||
grades_table.to_excel(writer, sheet_name='Notenschlüssel', header=False, index=False) # Notenberechnung als extra Sheet
|
||||
return bytesio
|
BIN
images/screenshot_hauptfenster.png
Normal file
BIN
images/screenshot_hauptfenster.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 192 KiB |
BIN
images/screenshot_konfiguration.png
Normal file
BIN
images/screenshot_konfiguration.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 364 KiB |
BIN
images/screenshot_noteneingabe.png
Normal file
BIN
images/screenshot_noteneingabe.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 322 KiB |
BIN
images/screenshot_session.png
Normal file
BIN
images/screenshot_session.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 289 KiB |
BIN
images/screenshot_statistik.png
Normal file
BIN
images/screenshot_statistik.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 316 KiB |
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
pandas~=2.2
|
||||
numpy~=2.0
|
||||
openpyxl
|
||||
streamlit~=1.41
|
||||
streamlit-aggrid
|
||||
tinydb~=4.8
|
||||
BetterJSONStorage
|
||||
pydantic
|
75
session_ui.py
Normal file
75
session_ui.py
Normal file
@ -0,0 +1,75 @@
|
||||
import streamlit as st
|
||||
import time
|
||||
from backend.db import DB, Session
|
||||
from backend.his_io import prepare_table, read_excel
|
||||
|
||||
|
||||
def session_ui(db: DB, session: Session | None) -> Session | None:
|
||||
|
||||
with st.sidebar:
|
||||
st.title(":blue[Noteneingabetool]")
|
||||
|
||||
st.header("Session :blue[wiederherstellen]", divider='gray')
|
||||
|
||||
# DEBUG (select session)
|
||||
selected_session = st.selectbox(":red[DEBUG] Session auswählen", db.list_tokens(), index=None, key='session_select',
|
||||
format_func=lambda token: f'{token} - {db.get_session(token).table_name}' )
|
||||
|
||||
if selected_session:
|
||||
token = selected_session
|
||||
else:
|
||||
token = st.text_input("Session Token", value=session.token if session else '', key='token_input')
|
||||
|
||||
if token:
|
||||
if not session or token != session.token:
|
||||
try:
|
||||
session = db.get_session(token)
|
||||
st.success("Session gefunden")
|
||||
except ValueError:
|
||||
st.error("Session nicht gefunden")
|
||||
session = None
|
||||
else:
|
||||
session = None
|
||||
|
||||
|
||||
st.header(":blue Session :blue[verwalten]", divider='gray')
|
||||
if st.button("Neue Session"):
|
||||
new_session_dialog(db)
|
||||
if st.button("Lösche Session", disabled=session is None):
|
||||
delete_session_dialog(db, session)
|
||||
|
||||
return session
|
||||
|
||||
|
||||
@st.dialog("Neue Session anlegen")
|
||||
def new_session_dialog(db: DB):
|
||||
upload_file = st.file_uploader("HISinOne Excel Datei hochladen", type=['xlsx'], key='file_uploader')
|
||||
|
||||
if upload_file:
|
||||
session = create_new_session(db, upload_file)
|
||||
db.save_session(session)
|
||||
st.write(":green[Session erstellt.]")
|
||||
st.write('Nicht vergessen, den :blue[Token] zu notieren:')
|
||||
st.code(session.token)
|
||||
|
||||
|
||||
@st.dialog("Session löschen")
|
||||
def delete_session_dialog(db: DB, session: Session):
|
||||
st.warning("Sind Sie sicher, dass Sie die Session löschen möchten?")
|
||||
if st.button(":exclamation: Session löschen :exclamation:"):
|
||||
db.delete_session(session)
|
||||
del st.session_state.session
|
||||
st.success("Session gelöscht")
|
||||
st.rerun()
|
||||
|
||||
|
||||
def create_new_session(db, upload_file) -> Session:
|
||||
new_session = db.create_session()
|
||||
students_df, his_header = read_excel(upload_file)
|
||||
points_df = prepare_table(students_df)
|
||||
|
||||
new_session.students_df = students_df
|
||||
new_session.his_header = his_header
|
||||
new_session.points_df = points_df
|
||||
new_session.table_name = upload_file.name
|
||||
return new_session
|
59
stats_ui.py
Normal file
59
stats_ui.py
Normal file
@ -0,0 +1,59 @@
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
import altair as alt
|
||||
|
||||
from backend.db import Session
|
||||
|
||||
|
||||
def stats_ui(session: Session, grades_table: pd.DataFrame, grades: list):
|
||||
with st.expander('Statistiken'):
|
||||
number_students = len(session.points_df)
|
||||
number_participants = len(session.points_df[session.points_df['Punkte'].notna()])
|
||||
|
||||
if number_participants > 0:
|
||||
# Convert from categorical strings to numerical values
|
||||
points_table = session.points_df.reset_index()[['Punkte', 'Note']].copy().dropna()
|
||||
points_table['Note'] = points_table['Note'].apply(lambda x: float(f'{x}'.replace(',', '.')))
|
||||
grades_tab = grades_table['Note'].apply(lambda x: float(f'{x}'.replace(',', '.')))
|
||||
|
||||
score_hist = pd.DataFrame({'Anzahl': [0] * len(grades_tab)}, index=grades_tab)
|
||||
counts = pd.DataFrame(points_table['Note'].value_counts().rename('Anzahl'))
|
||||
score_hist.update(counts)
|
||||
score_hist.reset_index(inplace=True)
|
||||
|
||||
hist = alt.Chart(score_hist).mark_bar().encode(
|
||||
x=alt.X('Anzahl:Q', axis=alt.Axis(title='Anzahl', tickCount=score_hist['Anzahl'].max()+1, grid=True)),
|
||||
y=alt.Y('Note:O', bin=alt.Bin(binned=True), axis=alt.Axis(title='Note', grid=True))
|
||||
)
|
||||
|
||||
median = alt.Chart(points_table).mark_rule(color='blue', opacity=0.5).encode(
|
||||
y = 'median(Note):O',
|
||||
size=alt.value(5),
|
||||
)
|
||||
|
||||
mean = alt.Chart(points_table).mark_rule(color='orange', opacity=0.5, strokeDash=[5,5]).encode(
|
||||
y = 'mean(Note):O',
|
||||
size=alt.value(5),
|
||||
)
|
||||
|
||||
chart = (hist + median + mean)
|
||||
|
||||
st.altair_chart(chart, use_container_width=True)
|
||||
|
||||
passed = 1-score_hist.iloc[-1]["Anzahl"]/number_participants
|
||||
1-score_hist.iloc[-1]["Anzahl"]/number_participants
|
||||
left, mid, right, rightmost = st.columns(4)
|
||||
with left:
|
||||
st.write(f'Anzahl Anmeldungen: {number_students}')
|
||||
st.write(f'Anzahl Teilnehmer: {number_participants}')
|
||||
with mid:
|
||||
st.write(f'Bestanden: :green[{passed*100:.2f} %]')
|
||||
st.write(f'Durchgefallen: :red[{100-passed*100:.2f}%]')
|
||||
with right:
|
||||
st.write(f'Punkteuntergrenzen: :gray[(1,0)] {session.grade_1_0:.2f}% / :gray[(4,0)] {session.grade_4_0:.2f}%')
|
||||
st.write(f'Maximalpunktzahl: {session.max_points}')
|
||||
with rightmost:
|
||||
st.write(f':orange[Durchschnitt:] {points_table["Note"].mean():.2f}')
|
||||
st.write(f':blue[Median:] {points_table["Note"].median():.2f}')
|
||||
else:
|
||||
st.write(':orange[Keine Teilnehmer]')
|
232
streamlit_app.py
Normal file
232
streamlit_app.py
Normal file
@ -0,0 +1,232 @@
|
||||
from datetime import datetime
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import streamlit as st
|
||||
from st_aggrid import AgGrid, GridUpdateMode, JsCode
|
||||
|
||||
from backend.db import DB, Session
|
||||
import backend.his_io as his_io
|
||||
from session_ui import session_ui
|
||||
from stats_ui import stats_ui
|
||||
|
||||
|
||||
st.set_page_config(layout='wide')
|
||||
|
||||
|
||||
# Open database
|
||||
try:
|
||||
db = DB()
|
||||
except Exception as e:
|
||||
st.error('Datenbank konnte nicht geladen werden.')
|
||||
st.exception(e)
|
||||
st.stop()
|
||||
|
||||
|
||||
# Init session state
|
||||
if 'session' not in st.session_state:
|
||||
st.session_state.session = None
|
||||
if 'last_session_token' not in st.session_state:
|
||||
st.session_state.last_session_token = None
|
||||
|
||||
|
||||
# Load session
|
||||
def load_session() -> Session:
|
||||
session = session_ui(db, st.session_state.session)
|
||||
return session
|
||||
|
||||
|
||||
def store_session(session: Session):
|
||||
session.edited = datetime.now()
|
||||
db.save_session(session)
|
||||
|
||||
st.session_state.session = load_session()
|
||||
if st.session_state.session is None:
|
||||
st.stop()
|
||||
|
||||
# Save and export
|
||||
@st.cache_data
|
||||
def export(points_df: pd.DataFrame, his_header: pd.DataFrame, exercise_columns: list[str], grades_table: pd.DataFrame):
|
||||
return his_io.export(points_df, his_header, exercise_columns, grades_table)
|
||||
|
||||
# Set some default UI values from the session
|
||||
if st.session_state.session.token != st.session_state.last_session_token:
|
||||
st.session_state.grade_range = (st.session_state.session.grade_4_0, st.session_state.session.grade_1_0)
|
||||
st.session_state.max_points = st.session_state.session.max_points
|
||||
st.session_state.last_session_token = st.session_state.session.token
|
||||
#export.clear()
|
||||
|
||||
|
||||
st.write(f':blue[{st.session_state.session.table_name}]')
|
||||
st.write(f':gray[Erstellt:] {st.session_state.session.created.strftime("%d.%m.%Y %H:%M:%S")} :gray[Zuletzt bearbeitet:] {st.session_state.session.edited.strftime("%d.%m.%Y %H:%M:%S")}')
|
||||
|
||||
reserved_columns = ['Nachname', 'Vorname', 'Punkte', 'Note']
|
||||
exercise_columns = [col for col in st.session_state.session.points_df.columns if col not in reserved_columns+list(st.session_state.session.students_df.columns)]
|
||||
if len(exercise_columns) == 0:
|
||||
exercise_columns = ''
|
||||
exercise_columns = st.text_input('Aufgaben', value=','.join(exercise_columns), placeholder='1,2,3,4,...')
|
||||
exercise_columns = [col.strip() for col in exercise_columns.split(',') if col.strip()]
|
||||
|
||||
def update_columns(new_columns, table):
|
||||
if len(table) > 0:
|
||||
# Drop table columns that are not in the list of columns
|
||||
for column in table.columns:
|
||||
if column not in new_columns and column not in reserved_columns+list(st.session_state.session.students_df.columns):
|
||||
table.drop(column, axis=1, inplace=True)
|
||||
# Add columns that are in the list of columns but not in the table
|
||||
for column in new_columns:
|
||||
if column not in table.columns:
|
||||
table[column] = pd.Series(np.nan, index=table.index, dtype='float64')
|
||||
# Reorder columns
|
||||
before_reorder = table.columns
|
||||
original_columns = [col for col in before_reorder if col not in new_columns]
|
||||
table = table[original_columns + new_columns]
|
||||
return table
|
||||
|
||||
st.session_state.session.points_df = update_columns(exercise_columns, st.session_state.session.points_df)
|
||||
|
||||
|
||||
# Grade calculation
|
||||
with st.expander('Notenberechnung'):
|
||||
grades = ['1,0', '1,3', '1,7', '2,0', '2,3', '2,7', '3,0', '3,3', '3,7', '4,0', '5,0']
|
||||
left, right = st.columns(2, gap='large')
|
||||
with left:
|
||||
grade_4_0, grade_1_0 = st.slider('Notenbereich (4,0-Grenze / 1,0-Grenze)', min_value=0., max_value=1., key='grade_range')
|
||||
max_points = st.slider('Maximalpunktzahl', min_value=1, max_value=100, key='max_points')
|
||||
|
||||
if st.session_state.session.grade_4_0 != grade_4_0 or st.session_state.session.grade_1_0 != grade_1_0 or st.session_state.session.max_points != max_points:
|
||||
st.session_state.session.grade_4_0 = grade_4_0
|
||||
st.session_state.session.grade_1_0 = grade_1_0
|
||||
st.session_state.session.max_points = max_points
|
||||
|
||||
|
||||
def calculate_grades_table(grade_1_0, grade_4_0):
|
||||
grade_interval = (grade_1_0 - grade_4_0) / 9
|
||||
grade_intervals = [grade_1_0 - i * grade_interval for i in range(10)]
|
||||
grade_intervals.append(0)
|
||||
lower = np.array([i * st.session_state.session.max_points for i in grade_intervals])
|
||||
lower = np.floor(lower * 2) / 2
|
||||
upper = np.roll(lower, 1)-0.5
|
||||
upper[0] = st.session_state.session.max_points
|
||||
return pd.DataFrame({'Note': grades, 'Prozent': grade_intervals, 'Untergrenze': lower, 'Obergrenze': upper})
|
||||
|
||||
with right:
|
||||
grades_table = calculate_grades_table(st.session_state.session.grade_1_0, st.session_state.session.grade_4_0)
|
||||
st.dataframe(grades_table,
|
||||
hide_index=True,
|
||||
column_config={
|
||||
'Note': st.column_config.TextColumn(),
|
||||
'Prozent': st.column_config.NumberColumn(format='%.2f'),
|
||||
'Untergrenze': st.column_config.NumberColumn(format='%.1f'),
|
||||
'Obergrenze': st.column_config.NumberColumn(format='%.1f')})
|
||||
|
||||
def calculate_points(session: Session):
|
||||
points = session.points_df[exercise_columns].sum(axis=1, skipna=False, min_count=1)
|
||||
session.points_df['Punkte'] = points
|
||||
session.points_df['Note'] = session.points_df['Punkte'].apply(lambda x: np.nan if np.isnan(x) else grades_table.iloc[np.digitize(x, grades_table['Untergrenze'])]['Note'])
|
||||
|
||||
calculate_points(st.session_state.session)
|
||||
|
||||
# Editable points table
|
||||
def color_try_column(col):
|
||||
return ['color: red' if val > 2 else 'color: orange' if val > 1 else '' for val in col]
|
||||
|
||||
def color_grade_column(col):
|
||||
return ['color: red' if val in ['5,0'] else '' for val in col]
|
||||
|
||||
|
||||
attemptCellStyle = JsCode(
|
||||
r"""
|
||||
function(params) {
|
||||
if (params.value == 2)
|
||||
return {'color': 'gold'};
|
||||
else if (params.value > 2)
|
||||
return {'color': 'red'};
|
||||
return {};
|
||||
}
|
||||
""")
|
||||
|
||||
gradeCellStyle = JsCode(
|
||||
r"""
|
||||
function(params) {
|
||||
if (params.value === '5,0')
|
||||
return {'color': 'red'};
|
||||
return {};
|
||||
}
|
||||
""")
|
||||
|
||||
def points_table(session: Session, exercise_columns: list[str]):
|
||||
|
||||
filterText = st.text_input('Filter', placeholder='Filter...', label_visibility='hidden')
|
||||
grid_options = {
|
||||
"columnDefs":[
|
||||
{"headerName":"Matrikelnummer","field":"Matrikelnummer","checkboxSelection": True, "cellDataType": "Number"},
|
||||
{"headerName":"Nachname","field":"Nachname", "cellDataType": "String"},
|
||||
{"headerName":"Vorname","field":"Vorname", "cellDataType": "String"},
|
||||
{"headerName":"Versuch","field":"Versuch", "cellDataType": "Number", "cellStyle": attemptCellStyle},
|
||||
{"headerName":"Punkte","field":"Punkte", "cellDataType": "Number"},
|
||||
{"headerName":"Note","field":"Note", "cellDataType": "Number", "cellStyle": gradeCellStyle},
|
||||
*[{ "headerName": col, "field": col, "cellDataType": "Number", "width": 40} for col in exercise_columns]
|
||||
],
|
||||
"autoSizeStrategy": {"type":"fitCellContents"},
|
||||
"rowSelection":"single",
|
||||
"rowMultiSelectWithClick": True,
|
||||
"suppressRowDeselection": True,
|
||||
"suppressRowClickSelection": True,
|
||||
"quickFilterText": filterText
|
||||
}
|
||||
|
||||
event = AgGrid(data=session.points_df.reset_index(), gridOptions=grid_options, key='grid1', update_mode=GridUpdateMode.SELECTION_CHANGED, allow_unsafe_jscode=True)
|
||||
return event
|
||||
|
||||
def points_editor(row_df: pd.DataFrame, exercise_columns: list[str]):
|
||||
grid_options = {
|
||||
"columnDefs":[
|
||||
{"headerName":"Matrikelnummer","field":"Matrikelnummer", "cellDataType": "Number"},
|
||||
{"headerName":"Nachname","field":"Nachname","cellDataType": "String"},
|
||||
{"headerName":"Vorname","field":"Vorname", "cellDataType": "String"},
|
||||
*[{ "headerName": col, "field": col, "cellDataType": "Number", "width": 40, "editable": True} for col in exercise_columns]
|
||||
],
|
||||
"autoSizeStrategy": {"type":"fitCellContents"},
|
||||
}
|
||||
editor_event = AgGrid(data=row_df, gridOptions=grid_options, height=100, key='grid2'+str(row_df['Matrikelnummer']), update_mode=GridUpdateMode.VALUE_CHANGED)
|
||||
return editor_event
|
||||
|
||||
|
||||
|
||||
with st.expander('Punkteeingabe', expanded=True):
|
||||
|
||||
event = points_table(st.session_state.session, exercise_columns)
|
||||
|
||||
if event.selected_rows is not None:
|
||||
selected_row = event.selected_rows.astype(st.session_state.session.points_df.dtypes)
|
||||
selected_row_idx = int(event.selected_rows_id[0])
|
||||
with st.form('edit_points'):
|
||||
editor_event = points_editor(selected_row, exercise_columns)
|
||||
submit = st.form_submit_button('Submit')
|
||||
if submit:
|
||||
# Replace all commas with decimal points in editor_event.data
|
||||
data = editor_event.data
|
||||
data[exercise_columns] = data[exercise_columns].replace('\\,', '.', regex=True)
|
||||
# Submit updated row
|
||||
updated_row = data.astype(st.session_state.session.points_df.dtypes).set_index('Matrikelnummer')
|
||||
st.session_state.session.points_df.update(updated_row)
|
||||
st.session_state.request_rerun = True
|
||||
|
||||
|
||||
|
||||
# Statistics
|
||||
stats_ui(st.session_state.session, grades_table, grades)
|
||||
|
||||
|
||||
# Download button
|
||||
st.download_button('Export nach :blue[HISinOne]', data=export(st.session_state.session.points_df, st.session_state.session.his_header, exercise_columns,grades_table), file_name=f'{st.session_state.session.table_name}_Noten.xlsx', mime='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
|
||||
|
||||
# Store session
|
||||
calculate_points(st.session_state.session)
|
||||
store_session(st.session_state.session)
|
||||
|
||||
# Rerun if requested
|
||||
if 'request_rerun' in st.session_state:
|
||||
del st.session_state['request_rerun']
|
||||
st.rerun()
|
74
tests/test_db.py
Normal file
74
tests/test_db.py
Normal file
@ -0,0 +1,74 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from backend.db import DB
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from backend.his_io import dtypes
|
||||
|
||||
|
||||
class TestDb(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.temp_file = os.path.join(self.temp_dir.name, 'test_db.json')
|
||||
self.db = DB(self.temp_file)
|
||||
|
||||
|
||||
def test_db(self):
|
||||
session = None
|
||||
|
||||
with self.subTest('create_session'):
|
||||
session = self.db.create_session()
|
||||
self.assertIsNotNone(session)
|
||||
self.assertGreater(len(session.token), 16)
|
||||
self.assertTrue(session.token in self.db.list_tokens())
|
||||
|
||||
with self.subTest('store_session'):
|
||||
session.table_name = 'Test Table'
|
||||
session.students_df = pd.DataFrame(columns=['Examplan.id', 'Matrikelnummer', 'PrüfungsNr.', 'Titel', 'Nachname', 'Vorname', 'Leistung', 'Versuch',
|
||||
'Status', 'ECTS', 'Semester', 'Jahr', 'Prüfungsperiode', 'Vermerk', 'Thema', 'Beginn',
|
||||
'Gepl. Ende', 'Tatsächl. Ende', 'Prüfungsart', 'Prüfungsform', 'LockVersion'])
|
||||
session.students_df = session.students_df.astype(dtypes.students_table)
|
||||
|
||||
session.students_df = pd.concat([session.students_df, pd.DataFrame({
|
||||
'Examplan.id': 1, 'PrüfungsNr.': 'P001', 'Titel': 'Math', 'Matrikelnummer': 123456, 'Nachname': 'Doe', 'Vorname': 'John', 'Leistung': 'A', 'Versuch': 1,
|
||||
'Status': 'Passed', 'ECTS': 5, 'Semester': 'WS2021', 'Jahr': 2021, 'Prüfungsperiode': 'Winter', 'Vermerk': '', 'Thema': '', 'Beginn': '', 'Gepl. Ende': '',
|
||||
'Tatsächl. Ende': '', 'Prüfungsart': '', 'Prüfungsform': '', 'LockVersion': '1e851fa6-6f69-4fe4-a1a8-510ab8e5c6ea=0,eeecd36a-ee4a-4897-b7db-464542618fb3=0,3fda53aa-2106-4f8b-a832-03aa57917cb2=0,'
|
||||
}, index=[0])])
|
||||
|
||||
session.students_df = pd.concat([session.students_df, pd.DataFrame({
|
||||
'Examplan.id': 2, 'PrüfungsNr.': 'P002', 'Titel': 'Physics', 'Matrikelnummer': 654321, 'Nachname': 'Smith', 'Vorname': 'Jane', 'Leistung': 'B', 'Versuch': 1,
|
||||
'Status': 'Passed', 'ECTS': 5, 'Semester': 'WS2021', 'Jahr': 2021, 'Prüfungsperiode': 'Winter', 'Vermerk': '', 'Thema': '', 'Beginn': '', 'Gepl. Ende': '',
|
||||
'Tatsächl. Ende': '', 'Prüfungsart': '', 'Prüfungsform': '', 'LockVersion': 'fd952c3a-c789-4af2-88fd-0dbe8ea0e68b=0,5a639ec2-eec4-4d53-bd92-5ed368f164c3=0,26270d08-a83b-4c2a-94e3-fc732553ef99=0,'
|
||||
}, index=[1])])
|
||||
|
||||
session.students_df = pd.concat([session.students_df, pd.DataFrame({
|
||||
'Examplan.id': 3, 'PrüfungsNr.': 'P003', 'Titel': 'Chemistry', 'Matrikelnummer': 789012, 'Nachname': 'Brown', 'Vorname': 'Alice', 'Leistung': 'C', 'Versuch': 1,
|
||||
'Status': 'Passed', 'ECTS': 5, 'Semester': 'WS2021', 'Jahr': 2021, 'Prüfungsperiode': 'Winter', 'Vermerk': '', 'Thema': '', 'Beginn': '', 'Gepl. Ende': '',
|
||||
'Tatsächl. Ende': '', 'Prüfungsart': '', 'Prüfungsform': '', 'LockVersion': 'ee3f3068-dad7-489c-a5e7-f2eccaedacf7=0,92e970b1-d447-4009-8dde-676d6a31a337=0,8c29a40c-8aa1-4585-b77b-78b410e3d12d=0,'
|
||||
}, index=[2])])
|
||||
session.students_df.set_index('Matrikelnummer', inplace=True)
|
||||
|
||||
session.points_df = session.students_df.copy()
|
||||
session.points_df['Punkte'] = [1, 2, 3]
|
||||
session.points_df['Note'] = ['1,0', '2,0', '3,0']
|
||||
session.points_df['1'] = [1, 2, 3]
|
||||
session.points_df['2'] = [1, 2, 3]
|
||||
session.his_header = pd.DataFrame(columns=['5160 - Rocket Science (Prüfung) | Prüfer/-in: Schröder, Enrico | Wintersemester 2023/24 | Prüfungsperiode 1', None, 'EXAM_CHECK_TOKEN', 'startHISsheet'])
|
||||
session.his_header = session.his_header.astype(dtypes.his_header)
|
||||
session.max_points = 123
|
||||
session.grade_1_0 = 0.50
|
||||
session.grade_4_0 = 0.20
|
||||
|
||||
self.db.save_session(session)
|
||||
self.assertTrue(session.token in self.db.list_tokens())
|
||||
|
||||
with self.subTest('get_session'):
|
||||
session2 = self.db.get_session(session.token)
|
||||
self.assertIsNotNone(session2)
|
||||
self.assertEqual(session, session2)
|
||||
|
||||
|
||||
return session
|
98
tests/test_hisio.py
Normal file
98
tests/test_hisio.py
Normal file
@ -0,0 +1,98 @@
|
||||
from io import BytesIO
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from backend import his_io
|
||||
|
||||
|
||||
class TestHisio(unittest.TestCase):
|
||||
|
||||
def check_students_table(self, table):
|
||||
self.assertEqual(table.shape, (6, 20))
|
||||
self.assertEqual(list(table.columns),
|
||||
['Examplan.id', 'PrüfungsNr.', 'Titel', 'Nachname', 'Vorname', 'Leistung', 'Versuch',
|
||||
'Status', 'ECTS', 'Semester', 'Jahr', 'Prüfungsperiode', 'Vermerk', 'Thema', 'Beginn',
|
||||
'Gepl. Ende', 'Tatsächl. Ende', 'Prüfungsart', 'Prüfungsform', 'LockVersion'])
|
||||
|
||||
self.assertEqual(table.index.name, 'Matrikelnummer')
|
||||
self.assertEqual(list(table.index), [3434343, 3535353, 3131313, 2323232, 3737373, 3939393])
|
||||
self.assertEqual(list(table['Examplan.id']), [1414141, 1515151, 1111111, 1313131, 1717171, 1919191])
|
||||
self.assertEqual(list(table['Nachname']), ['Hendrix', 'Lukather', 'Mayer', 'Page', 'Townshend', 'Van Halen'])
|
||||
self.assertEqual(list(table['LockVersion']), [
|
||||
'ea56ef45-1b42-4a9f-8dc2-e9f3b8e6c9a1=0,109aefbb-988c-44a8-a72d-82ad0025dcb0=0,6aed40c3-8ca7-4cf8-8004-534c11121a43=0,',
|
||||
'1e851fa6-6f69-4fe4-a1a8-510ab8e5c6ea=0,eeecd36a-ee4a-4897-b7db-464542618fb3=0,3fda53aa-2106-4f8b-a832-03aa57917cb2=0,',
|
||||
'fd952c3a-c789-4af2-88fd-0dbe8ea0e68b=0,5a639ec2-eec4-4d53-bd92-5ed368f164c3=0,26270d08-a83b-4c2a-94e3-fc732553ef99=0,',
|
||||
'ee3f3068-dad7-489c-a5e7-f2eccaedacf7=0,92e970b1-d447-4009-8dde-676d6a31a337=0,8c29a40c-8aa1-4585-b77b-78b410e3d12d=0,',
|
||||
'1832c736-1d8c-4d49-bdcd-4295fc5bf3b4=0,8f5bf01d-9cc3-4bb9-86c1-c6d5d0cbc45a=0,9c9cce83-7a96-47fb-acdd-8fc0fd5fddfa=0,',
|
||||
'14d1b781-a879-4115-a444-c87b725bbfe2=0,98e734b2-3253-43c2-a173-2fe7e686bc93=0,cb6d9ca0-b3e5-40c9-b7f9-e6e3e4511e50=0,'])
|
||||
self.assertEqual(dict(table.dtypes), his_io.dtypes.students_table)
|
||||
|
||||
def check_header(self, header):
|
||||
self.assertEqual(header.shape, (4, 21))
|
||||
self.assertEqual(list(header[0]), ['5160 - Rocket Science (Prüfung) | Prüfer/-in: Schröder, Enrico | Wintersemester 2023/24 | Prüfungsperiode 1', None, 'EXAM_CHECK_TOKEN', 'startHISsheet'])
|
||||
self.assertEqual(header.iloc[2, 1], '6711e6e8b09fe054d0216a4745b5c56d')
|
||||
|
||||
def test(self):
|
||||
with open('5160-RocketScience-WiSe_2023.xlsx', 'rb') as f:
|
||||
self.table_bytes = BytesIO(f.read())
|
||||
|
||||
students_table, his_header = None, None
|
||||
with self.subTest('read'):
|
||||
students_table, his_header = his_io.read_excel(self.table_bytes)
|
||||
self.assertIsNotNone(students_table)
|
||||
self.assertIsNotNone(his_header)
|
||||
|
||||
|
||||
with self.subTest('students_table'):
|
||||
self.check_students_table(students_table)
|
||||
self.assertTrue(students_table['Leistung'].isna().all())
|
||||
|
||||
with self.subTest('his_header'):
|
||||
self.check_header(his_header)
|
||||
|
||||
with self.subTest('prepare_table'):
|
||||
points_table = his_io.prepare_table(students_table)
|
||||
self.assertEqual(points_table.shape, (6, 22))
|
||||
self.assertEqual(list(points_table.columns),
|
||||
['Examplan.id', 'PrüfungsNr.', 'Titel', 'Nachname', 'Vorname', 'Leistung', 'Versuch',
|
||||
'Status', 'ECTS', 'Semester', 'Jahr', 'Prüfungsperiode', 'Vermerk', 'Thema', 'Beginn',
|
||||
'Gepl. Ende', 'Tatsächl. Ende', 'Prüfungsart', 'Prüfungsform', 'LockVersion', 'Punkte', 'Note'])
|
||||
self.assertTrue(points_table['Punkte'].isna().all())
|
||||
self.assertTrue(points_table['Note'].isna().all())
|
||||
|
||||
with self.subTest('export'):
|
||||
points_table['1'] = [1, 2, 3, 4, 5, 0]
|
||||
points_table['2'] = [0, 1, 2, 3, 4, 5]
|
||||
points_table['Punkte'] = [1, 2, 3, 4, 5, 6]
|
||||
points_table['Note'] = ['1,0', '2,0', '3,0', '4,0', '5,0', 'NT']
|
||||
grades_table = pd.DataFrame({'Note': ['1,0', '2,0', '3,0', '4,0', '5,0', 'NT'],
|
||||
'Prozent': [0.9, 0.8, 0.6, 0.4, 0.2, 0.1],
|
||||
'Untergrenze': [100, 80, 60, 40, 20, 0],
|
||||
'Obergrenze': [100, 80, 60, 40, 20, 0]})
|
||||
|
||||
export_bytes = his_io.export(points_table, his_header, ['1', '2'], grades_table)
|
||||
self.assertIsNotNone(export_bytes)
|
||||
|
||||
exported_students_table, exported_his_header = his_io.read_excel(export_bytes)
|
||||
self.check_students_table(exported_students_table)
|
||||
self.assertEqual(list(exported_students_table['Leistung']), ['1,0', '2,0', '3,0', '4,0', '5,0', 'NT'])
|
||||
self.check_header(exported_his_header)
|
||||
|
||||
with self.subTest('exported_summary'):
|
||||
summary_sheet = pd.read_excel(export_bytes, header=None, sheet_name='Aufgaben')
|
||||
self.assertIsNotNone(summary_sheet)
|
||||
self.assertEqual(summary_sheet.shape, (7, 7))
|
||||
self.assertEqual(list(summary_sheet.iloc[0]), ['Matrikelnummer', 'Nachname', 'Vorname', 'Punkte', 'Note', 1, 2])
|
||||
self.assertEqual(list(summary_sheet.iloc[1]), [3434343, 'Hendrix', 'Jimi', 1, '1,0', 1, 0])
|
||||
self.assertEqual(list(summary_sheet.iloc[6]), [3939393, 'Van Halen', 'Eddie', 6, 'NT', 0, 5])
|
||||
|
||||
with self.subTest('exported_grades_table'):
|
||||
grades_table = pd.read_excel(export_bytes, header=None, sheet_name='Notenschlüssel')
|
||||
self.assertIsNotNone(grades_table)
|
||||
self.assertEqual(grades_table.shape, (6, 4))
|
||||
self.assertEqual(list(grades_table.iloc[2]), ['3,0', 0.6, 60, 60])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
Loading…
x
Reference in New Issue
Block a user