Add support for importing moodle result csv files.

This commit is contained in:
Enrico Schroeder 2026-02-13 21:09:44 +01:00
parent 569b52159f
commit c4f3b64138
6 changed files with 168 additions and 17 deletions

View File

@ -0,0 +1,9 @@
Nachname,Vorname,E-Mail-Adresse,Status,Begonnen,Beendet,Dauer,"Bewertung/73,0","F 1 /4,0","F 2 /6,0","F 3 /4,0","F 4 /4,0","F 5 /4,0","F 6 /7,0","F 7 /10,0","F 8 /5,0","F 9 /4,0","F 10 /9,0","F 11 /8,0","F 12 /8,0"
Hendrix,Jimi,bla@th-nuernberg.de,Beendet,"5. Februar 2026 16:30","5. Februar 2026 18:00","1 Stunde 30 Minuten","39,5","3,0","6,0","2,0","4,0","2,5","4,0","1,0","4,0","0,0","4,0","7,0","2,0"
Lukather,Steve,blub@th-nuernberg.de,Beendet,"5. Februar 2026 16:30","5. Februar 2026 18:00","1 Stunde 30 Minuten","26,5","3,0","6,0","2,0","2,0","1,5","2,0","3,0","1,0",-,"2,0","2,0","2,0"
Mayer,John,abc@th-nuernberg.de,Beendet,"5. Februar 2026 16:30","5. Februar 2026 18:11","1 Stunde 40 Minuten","62,0","3,0","6,0","3,0","4,0","3,0","5,0","6,0","5,0","4,0","7,0","8,0","8,0"
Page,Jimmy,def@th-nuernberg.de,Beendet,"5. Februar 2026 16:30","5. Februar 2026 18:00","1 Stunde 30 Minuten","30,5","3,0","4,0","1,0","1,0","2,0","0,5","0,0","5,0","0,0","8,0","4,0","2,0"
Townshend,Pete,hij@th-nuernberg.de,Beendet,"5. Februar 2026 16:30","5. Februar 2026 18:00","1 Stunde 30 Minuten","24,0","2,0","5,0","4,0","2,5","2,0","1,5","1,0","0,5","0,0","2,5","2,0","1,0"
Van Halen,Eddie,xyz@th-nuernberg.de,Beendet,"5. Februar 2026 16:30","5. Februar 2026 18:00","1 Stunde 30 Minuten","24,0","2,0","5,0","4,0","2,5","2,0","1,5","1,0","0,5","0,0","2,5","2,0","1,0"
Gruppendurchschnitt,,,,,,,"31,91","3,65","5,88","3,09","3,35","1,82","0,47","1,18","1,35","4,59","2,91","2,41","1,21"
Gesamtdurchschnitt,,,,,,,"31,32","3,07","5,59","3,04","3,02","1,68","0,51","1,15","1,17","4,55","3,30","2,83","1,41"
1 Nachname Vorname E-Mail-Adresse Status Begonnen Beendet Dauer Bewertung/73,0 F 1 /4,0 F 2 /6,0 F 3 /4,0 F 4 /4,0 F 5 /4,0 F 6 /7,0 F 7 /10,0 F 8 /5,0 F 9 /4,0 F 10 /9,0 F 11 /8,0 F 12 /8,0
2 Hendrix Jimi bla@th-nuernberg.de Beendet 5. Februar 2026 16:30 5. Februar 2026 18:00 1 Stunde 30 Minuten 39,5 3,0 6,0 2,0 4,0 2,5 4,0 1,0 4,0 0,0 4,0 7,0 2,0
3 Lukather Steve blub@th-nuernberg.de Beendet 5. Februar 2026 16:30 5. Februar 2026 18:00 1 Stunde 30 Minuten 26,5 3,0 6,0 2,0 2,0 1,5 2,0 3,0 1,0 - 2,0 2,0 2,0
4 Mayer John abc@th-nuernberg.de Beendet 5. Februar 2026 16:30 5. Februar 2026 18:11 1 Stunde 40 Minuten 62,0 3,0 6,0 3,0 4,0 3,0 5,0 6,0 5,0 4,0 7,0 8,0 8,0
5 Page Jimmy def@th-nuernberg.de Beendet 5. Februar 2026 16:30 5. Februar 2026 18:00 1 Stunde 30 Minuten 30,5 3,0 4,0 1,0 1,0 2,0 0,5 0,0 5,0 0,0 8,0 4,0 2,0
6 Townshend Pete hij@th-nuernberg.de Beendet 5. Februar 2026 16:30 5. Februar 2026 18:00 1 Stunde 30 Minuten 24,0 2,0 5,0 4,0 2,5 2,0 1,5 1,0 0,5 0,0 2,5 2,0 1,0
7 Van Halen Eddie xyz@th-nuernberg.de Beendet 5. Februar 2026 16:30 5. Februar 2026 18:00 1 Stunde 30 Minuten 24,0 2,0 5,0 4,0 2,5 2,0 1,5 1,0 0,5 0,0 2,5 2,0 1,0
8 Gruppendurchschnitt 31,91 3,65 5,88 3,09 3,35 1,82 0,47 1,18 1,35 4,59 2,91 2,41 1,21
9 Gesamtdurchschnitt 31,32 3,07 5,59 3,04 3,02 1,68 0,51 1,15 1,17 4,55 3,30 2,83 1,41

21
backend/helpers.py Normal file
View File

@ -0,0 +1,21 @@
import numpy as np
import pandas as pd
import streamlit as st
reserved_columns = ['Nachname', 'Vorname', 'Punkte', 'Note']
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

47
backend/moodle_io.py Normal file
View File

@ -0,0 +1,47 @@
import pandas as pd
import numpy as np
from io import StringIO
pandas_json_format = 'table'
def read_csv(table_str: StringIO) -> pd.DataFrame:
try:
df = pd.read_csv(table_str, engine='python', skipfooter=2)
except:
return pd.DataFrame()
df = df.set_index(['Nachname', 'Vorname'])
# Rename columns to replace commas.
# Replace with _ as AggGrid does not seem to support columns with decimals in name
df = df.rename(columns=lambda x: x.replace(',','_'))
exercises = [col for col in list(df.columns) if 'F' in col]
df.update(df[exercises].map(lambda v: v.replace(',','.'))) # German floats -> international floats
df.update(df[exercises].map(lambda v: v.replace('-','0.0'))) # '-' -> 0
df = df.astype({k: 'float64' for k in exercises})
return df
def parse_header(df: pd.DataFrame) -> tuple[int, list[str]]:
max_points = [col for col in list(df.columns) if 'Bewertung' in col]
if len(max_points) != 1:
raise ValueError("Could not find column 'Bewertung' in table!")
max_points = float(max_points[0].split('/', 1)[1].replace('_','.'))
exercises = [col for col in list(df.columns) if 'F' in col]
if len(exercises) < 1:
raise ValueError("Table does not seem to contain exercise points!")
return max_points, exercises
def merge_points(points_df_hisio: pd.DataFrame, points_df_moodle: pd.DataFrame) -> tuple[pd.DataFrame, int]:
points_df_hisio = points_df_hisio.reset_index().set_index(['Nachname', 'Vorname'])
common_keys = points_df_hisio.index.intersection(points_df_moodle.index)
update_count = len(common_keys)
points_df_hisio.update(points_df_moodle)
points_df_hisio = points_df_hisio.reset_index().set_index('Matrikelnummer')
return points_df_hisio, update_count

View File

@ -1,6 +1,8 @@
import streamlit as st
import time
from backend import moodle_io
from backend.db import DB, Session
from backend.helpers import update_columns
from backend.his_io import prepare_table, read_excel
@ -37,6 +39,8 @@ def session_ui(db: DB, session: Session | None) -> Session | None:
new_session_dialog(db)
if st.button("Lösche Session", disabled=session is None):
delete_session_dialog(db, session)
if st.button("Moodle Ergebnisse importieren", disabled=session is None):
upload_moodle_results(db, session)
return session
@ -62,6 +66,26 @@ def delete_session_dialog(db: DB, session: Session):
st.success("Session gelöscht")
st.rerun()
@st.dialog("Moodle Ergebnisse hochladen")
def upload_moodle_results(db: DB, session: Session):
moodle_file = st.file_uploader("Moodle Ergebnis CSV hochladen", type=['csv'], key='moodle_file_uploader')
if moodle_file:
moodle_points_df = moodle_io.read_csv(moodle_file)
max_points, exercise_columns = moodle_io.parse_header(moodle_points_df)
st.write(f'Moodle CSV enhält :blue[{len(moodle_points_df.index)}] Einträge')
st.write(f'Aufgaben: ```{", ".join(exercise_columns)}```')
st.write(f'Punktzahl: :blue[{max_points}]')
st.warning("Sind Sie sicher, dass Sie die Ergebnisse importieren möchten? Aktuelle Ergebnisse werden gelöscht!")
if st.button("Importieren :question:"):
session.points_df = update_columns(exercise_columns, session.points_df)
session.points_df, update_count = moodle_io.merge_points(session.points_df, moodle_points_df)
st.session_state.max_points = max_points
st.success(f"{update_count} Ergebnisse importiert")
db.save_session(session)
time.sleep(1)
st.rerun()
def create_new_session(db, upload_file) -> Session:
new_session = db.create_session()

View File

@ -6,6 +6,7 @@ from st_aggrid import AgGrid, GridUpdateMode, JsCode
from backend.db import DB, Session
import backend.his_io as his_io
from backend.helpers import reserved_columns, update_columns
from session_ui import session_ui
from stats_ui import stats_ui
@ -59,29 +60,12 @@ if st.session_state.session.token != st.session_state.last_session_token:
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)

66
tests/test_moodleio.py Normal file
View File

@ -0,0 +1,66 @@
from io import StringIO, BytesIO
import unittest
import numpy as np
import pandas as pd
from backend import his_io, moodle_io
class TestMoodleio(unittest.TestCase):
def check_csv_table(self, table):
pass
def test(self):
exercise_columns = ['F 1 /4_0', 'F 2 /6_0', 'F 3 /4_0', 'F 4 /4_0',
'F 5 /4_0', 'F 6 /7_0', 'F 7 /10_0', 'F 8 /5_0', 'F 9 /4_0',
'F 10 /9_0', 'F 11 /8_0', 'F 12 /8_0']
with open('RocketScience-Bewertungen.csv', 'r') as f:
self.table_str = StringIO(f.read())
with self.subTest('read'):
points_df_moodle = moodle_io.read_csv(self.table_str)
self.assertIsNotNone(points_df_moodle)
with self.subTest('check_table'):
self.assertEqual(list(points_df_moodle.columns),
['E-Mail-Adresse', 'Status', 'Begonnen', 'Beendet', 'Dauer', 'Bewertung/73_0'] + exercise_columns)
self.assertEqual(points_df_moodle.shape[0], 6)
self.assertEqual(points_df_moodle.index.names, ['Nachname', 'Vorname'])
self.assertEqual(list(points_df_moodle.index), [('Hendrix','Jimi'), ('Lukather','Steve') , ('Mayer','John'), ('Page','Jimmy'), ('Townshend','Pete'), ('Van Halen','Eddie')])
self.assertEqual(list(points_df_moodle[exercise_columns].dtypes), ['float64'] * len(list(points_df_moodle[exercise_columns].dtypes)))
with self.subTest('parse_header'):
max_points, exercises = moodle_io.parse_header(points_df_moodle)
self.assertEqual(max_points, 73)
self.assertEqual(exercises, exercise_columns)
with self.subTest('merge_tables'):
table_bytes = None
with open('5160-RocketScience-WiSe_2023.xlsx', 'rb') as f:
table_bytes = BytesIO(f.read())
students_table = None
students_table, _ = his_io.read_excel(table_bytes)
self.assertIsNotNone(students_table)
for col in exercise_columns:
students_table[col] = pd.Series(np.nan, index=students_table.index, dtype='float64')
students_table, merged_count = moodle_io.merge_points(students_table, points_df_moodle)
self.assertEqual(merged_count, 6)
self.assertEqual(students_table.index.name, 'Matrikelnummer')
self.assertEqual(list(students_table.loc[3434343][exercise_columns]), [3.0, 6.0, 2.0, 4.0, 2.5, 4.0, 1.0, 4.0, 0.0, 4.0, 7.0, 2.0])
self.assertEqual(list(students_table.loc[3535353][exercise_columns]), [3.0, 6.0, 2.0, 2.0, 1.5, 2.0, 3.0, 1.0, 0.0, 2.0, 2.0, 2.0])
self.assertEqual(list(students_table.loc[3131313][exercise_columns]), [3.0, 6.0, 3.0, 4.0, 3.0, 5.0, 6.0, 5.0, 4.0, 7.0, 8.0, 8.0])
self.assertEqual(list(students_table.loc[2323232][exercise_columns]), [3.0, 4.0, 1.0, 1.0, 2.0, 0.5, 0.0, 5.0, 0.0, 8.0, 4.0, 2.0])
self.assertEqual(list(students_table.loc[3737373][exercise_columns]), [2.0, 5.0, 4.0, 2.5, 2.0, 1.5, 1.0, 0.5, 0.0, 2.5, 2.0, 1.0])
self.assertEqual(list(students_table.loc[3939393][exercise_columns]), [2.0, 5.0, 4.0, 2.5, 2.0, 1.5, 1.0, 0.5, 0.0, 2.5, 2.0, 1.0])
if __name__ == '__main__':
unittest.main()