diff --git a/RocketScience-Bewertungen.csv b/RocketScience-Bewertungen.csv new file mode 100644 index 0000000..8682476 --- /dev/null +++ b/RocketScience-Bewertungen.csv @@ -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" diff --git a/backend/helpers.py b/backend/helpers.py new file mode 100644 index 0000000..c12f5f5 --- /dev/null +++ b/backend/helpers.py @@ -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 \ No newline at end of file diff --git a/backend/moodle_io.py b/backend/moodle_io.py new file mode 100644 index 0000000..c1328d8 --- /dev/null +++ b/backend/moodle_io.py @@ -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 \ No newline at end of file diff --git a/session_ui.py b/session_ui.py index 554adc0..53c65b0 100644 --- a/session_ui.py +++ b/session_ui.py @@ -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() diff --git a/streamlit_app.py b/streamlit_app.py index 7613988..c20085e 100644 --- a/streamlit_app.py +++ b/streamlit_app.py @@ -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) diff --git a/tests/test_moodleio.py b/tests/test_moodleio.py new file mode 100644 index 0000000..4da29ea --- /dev/null +++ b/tests/test_moodleio.py @@ -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() \ No newline at end of file