MartinLipsdorf commited on
Commit
af1de9a
·
verified ·
1 Parent(s): 390909a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +245 -76
app.py CHANGED
@@ -1,6 +1,7 @@
1
  import os
2
  import random
3
  import hashlib
 
4
  import threading
5
  from typing import Dict, List, Tuple, Any, Optional
6
 
@@ -378,7 +379,15 @@ class PrivacyMemoryStore:
378
  def __init__(self):
379
  self._lock = threading.Lock()
380
  self._records: List[Dict[str, Any]] = []
381
- self._admin_pin = os.environ.get("ADMIN_PIN", "2026")
 
 
 
 
 
 
 
 
382
 
383
  def upsert_entry(self, code: str, scores: Dict[str, Any], previous_code: Optional[str] = None) -> str:
384
  with self._lock:
@@ -704,11 +713,30 @@ def switch_language(lang_choice: str):
704
  )
705
  )
706
 
 
 
 
 
 
707
  updates.append(gr.update(value=t["submit_btn"]))
 
708
  updates.append(t["sources_footer"])
709
  return updates
710
 
711
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712
  def process_student_submission(lang_code: str, custom_code: str, current_token: Optional[str], *answers):
713
  t = TRANSLATIONS.get(lang_code, TRANSLATIONS["DE"])
714
  missing_nccs = [i + 1 for i in range(16) if answers[i] is None]
@@ -723,6 +751,7 @@ def process_student_submission(lang_code: str, custom_code: str, current_token:
723
  return (
724
  "\n".join(msg_parts),
725
  gr.update(visible=False, value=None),
 
726
  current_token
727
  )
728
 
@@ -752,9 +781,17 @@ def process_student_submission(lang_code: str, custom_code: str, current_token:
752
  title=f"Psychometrisches 2x2-Profil ({final_code})"
753
  )
754
 
 
 
 
 
 
 
 
755
  return (
756
  code_banner,
757
  gr.update(visible=True, value=fig),
 
758
  final_code
759
  )
760
 
@@ -810,16 +847,75 @@ def refresh_lecturer_dashboard(
810
  return status_md, fig
811
 
812
 
813
- def handle_admin_reset(pin: str, expected_count: int, y_mode_label: str, x_cutoff_label: str, show_labels: bool, beamer_mode: bool):
814
- _, msg = SESSION_STORE.clear_all(pin)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
815
  status_md, fig = refresh_lecturer_dashboard(expected_count, y_mode_label, x_cutoff_label, show_labels, beamer_mode)
816
- return "", msg, status_md, fig
817
 
818
 
819
- def handle_admin_demo(pin: str, expected_count: int, y_mode_label: str, x_cutoff_label: str, show_labels: bool, beamer_mode: bool):
820
- _, msg = SESSION_STORE.seed_demo_data(pin, count=int(expected_count))
 
 
 
 
 
 
 
 
 
821
  status_md, fig = refresh_lecturer_dashboard(expected_count, y_mode_label, x_cutoff_label, show_labels, beamer_mode)
822
- return "", msg, status_md, fig
823
 
824
 
825
  # ==============================================================================
@@ -992,6 +1088,12 @@ with gr.Blocks(
992
 
993
  feedback_output = gr.Markdown()
994
  personal_plot_output = gr.Plot(visible=False, label="Psychometrisches 2x2-Profil")
 
 
 
 
 
 
995
 
996
  sources_footer_md = gr.Markdown(
997
  TRANSLATIONS["DE"]["sources_footer"],
@@ -1011,6 +1113,7 @@ with gr.Blocks(
1011
  part_b_header_md,
1012
  *ie4_inputs,
1013
  submit_btn,
 
1014
  sources_footer_md,
1015
  ],
1016
  api_name=False
@@ -1019,81 +1122,107 @@ with gr.Blocks(
1019
  submit_btn.click(
1020
  fn=process_student_submission,
1021
  inputs=[lang_state, custom_code_input, student_token_state] + nccs_inputs + ie4_inputs,
1022
- outputs=[feedback_output, personal_plot_output, student_token_state],
1023
  api_name=False
1024
  )
1025
 
1026
  # ----------------------------------------------------------------------
1027
- # TAB 2: LIVE-AUSWERTUNG HÖRSAAL (DOZENT)
1028
  # ----------------------------------------------------------------------
1029
  with gr.TabItem("🖥️ 2. Live-Auswertung Hörsaal"):
1030
- with gr.Row():
1031
- with gr.Column(scale=4):
1032
- lecturer_status_md = gr.Markdown(
1033
- "<div class='beamer-stats'><span>👥 Eingegangene Antworten: <b>0 / 14</b></span></div>"
 
 
 
 
 
 
 
 
 
 
 
1034
  )
1035
- with gr.Column(scale=1):
1036
- refresh_btn = gr.Button(
1037
- "🔄 Aktualisieren",
1038
  variant="primary",
1039
- size="lg"
 
1040
  )
 
1041
 
1042
- lecturer_plot_output = gr.Plot(
1043
- value=build_compass_plot([], beamer_mode=True, title="Live-Auswertung Hörsaal (n = 0)"),
1044
- label="Psychometrisches 2x2-Profil"
1045
- )
1046
-
1047
- with gr.Accordion("⚙️ Anzeige- & Admin-Optionen", open=False):
1048
  with gr.Row():
1049
- expected_course_size = gr.Number(
1050
- value=14,
1051
- label="Kursgröße",
1052
- precision=0,
1053
- minimum=1,
1054
- maximum=200
1055
- )
1056
- show_labels_checkbox = gr.Checkbox(
1057
- value=True,
1058
- label="Teilnehmer-Codes anzeigen"
1059
- )
1060
- beamer_mode_checkbox = gr.Checkbox(
1061
- value=True,
1062
- label="Beamer-Modus (Maximaler Kontrast & große Schrift)"
1063
- )
1064
- with gr.Row():
1065
- y_mode_dropdown = gr.Dropdown(
1066
- choices=[
1067
- "EKÜ: Externale Kontrollüberzeugung (1.0 bis 5.0)",
1068
- "IKÜ: Internale Kontrollüberzeugung (1.0 bis 5.0)",
1069
- "Netto-Internalität: IKÜ minus EKÜ (-4.0 bis +4.0)",
1070
- ],
1071
- value="EKÜ: Externale Kontrollüberzeugung (1.0 bis 5.0)",
1072
- label="Y-Achse"
1073
- )
1074
- x_cutoff_radio = gr.Radio(
1075
- choices=[
1076
- "Empirischer Referenzwert (M = 3.30)",
1077
- "Theoretische Skalenmitte (3.50)",
1078
- ],
1079
- value="Empirischer Referenzwert (M = 3.30)",
1080
- label="Trennlinie X-Achse"
1081
- )
1082
- with gr.Row():
1083
- admin_pin_input = gr.Textbox(
1084
- label="PIN",
1085
- type="password",
1086
- placeholder="PIN",
1087
- scale=2
1088
- )
1089
- reset_btn = gr.Button("🗑️ Reset", variant="stop", scale=1)
1090
- demo_btn = gr.Button("🧪 Demo-Daten", variant="secondary", scale=1)
1091
- admin_msg_output = gr.Markdown()
1092
 
1093
- gr.Markdown(
1094
- TRANSLATIONS["DE"]["sources_footer"],
1095
- elem_classes=["sources-box"]
1096
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1097
 
1098
  plot_controls = [
1099
  expected_course_size,
@@ -1103,10 +1232,50 @@ with gr.Blocks(
1103
  beamer_mode_checkbox
1104
  ]
1105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1106
  refresh_btn.click(
1107
  fn=refresh_lecturer_dashboard,
1108
  inputs=plot_controls,
1109
- outputs=[lecturer_status_md, lecturer_plot_output],
1110
  api_name=False
1111
  )
1112
 
@@ -1114,21 +1283,21 @@ with gr.Blocks(
1114
  ctrl.change(
1115
  fn=refresh_lecturer_dashboard,
1116
  inputs=plot_controls,
1117
- outputs=[lecturer_status_md, lecturer_plot_output],
1118
  api_name=False
1119
  )
1120
 
1121
  reset_btn.click(
1122
  fn=handle_admin_reset,
1123
- inputs=[admin_pin_input] + plot_controls,
1124
- outputs=[admin_pin_input, admin_msg_output, lecturer_status_md, lecturer_plot_output],
1125
  api_name=False
1126
  )
1127
 
1128
  demo_btn.click(
1129
  fn=handle_admin_demo,
1130
- inputs=[admin_pin_input] + plot_controls,
1131
- outputs=[admin_pin_input, admin_msg_output, lecturer_status_md, lecturer_plot_output],
1132
  api_name=False
1133
  )
1134
 
 
1
  import os
2
  import random
3
  import hashlib
4
+ import tempfile
5
  import threading
6
  from typing import Dict, List, Tuple, Any, Optional
7
 
 
379
  def __init__(self):
380
  self._lock = threading.Lock()
381
  self._records: List[Dict[str, Any]] = []
382
+ self._admin_pin = (
383
+ os.environ.get("ADMIN_PIN")
384
+ or os.environ.get("PIN")
385
+ or os.environ.get("LECTURER_PIN")
386
+ or "2026"
387
+ )
388
+
389
+ def verify_pin(self, pin: str) -> bool:
390
+ return bool(pin) and pin.strip() == self._admin_pin
391
 
392
  def upsert_entry(self, code: str, scores: Dict[str, Any], previous_code: Optional[str] = None) -> str:
393
  with self._lock:
 
713
  )
714
  )
715
 
716
+ dl_labels = {
717
+ "DE": "📥 Persönliche Ergebnisse herunterladen (.md)",
718
+ "EN": "📥 Download Personal Results (.md)",
719
+ "ZH": "📥 下载个人评估结果 (.md)",
720
+ }
721
  updates.append(gr.update(value=t["submit_btn"]))
722
+ updates.append(gr.update(label=dl_labels.get(lang_code, dl_labels["DE"])))
723
  updates.append(t["sources_footer"])
724
  return updates
725
 
726
 
727
+ def create_student_download_file(code: str, scores: Dict[str, Any], lang: str = "DE") -> str:
728
+ report_md = (
729
+ f"# 🧭 Umgang mit Ungewissheit & Kontrollüberzeugung – Ergebnisprofil ({code})\n\n"
730
+ + generate_profile_feedback(scores, lang=lang)
731
+ )
732
+ tmp_dir = tempfile.mkdtemp()
733
+ safe_code = "".join(c for c in code if c.isalnum() or c in ("-", "_")) or "Profil"
734
+ file_path = os.path.join(tmp_dir, f"Ergebnis_{safe_code}.md")
735
+ with open(file_path, "w", encoding="utf-8") as f:
736
+ f.write(report_md)
737
+ return file_path
738
+
739
+
740
  def process_student_submission(lang_code: str, custom_code: str, current_token: Optional[str], *answers):
741
  t = TRANSLATIONS.get(lang_code, TRANSLATIONS["DE"])
742
  missing_nccs = [i + 1 for i in range(16) if answers[i] is None]
 
751
  return (
752
  "\n".join(msg_parts),
753
  gr.update(visible=False, value=None),
754
+ gr.update(visible=False, value=None),
755
  current_token
756
  )
757
 
 
781
  title=f"Psychometrisches 2x2-Profil ({final_code})"
782
  )
783
 
784
+ download_path = create_student_download_file(final_code, scores, lang=lang_code)
785
+ dl_labels = {
786
+ "DE": "📥 Persönliche Ergebnisse herunterladen (.md)",
787
+ "EN": "📥 Download Personal Results (.md)",
788
+ "ZH": "📥 下载个人评估结果 (.md)",
789
+ }
790
+
791
  return (
792
  code_banner,
793
  gr.update(visible=True, value=fig),
794
+ gr.update(visible=True, value=download_path, label=dl_labels.get(lang_code, dl_labels["DE"])),
795
  final_code
796
  )
797
 
 
847
  return status_md, fig
848
 
849
 
850
+ def unlock_lecturer_view(
851
+ pin: str,
852
+ expected_count: int,
853
+ y_mode_label: str,
854
+ x_cutoff_label: str,
855
+ show_labels: bool,
856
+ beamer_mode: bool
857
+ ):
858
+ if not SESSION_STORE.verify_pin(pin):
859
+ return (
860
+ False,
861
+ "",
862
+ "❌ **PIN ungültig.** Bitte prüfen Sie das im Space hinterlegte Secret.",
863
+ gr.update(visible=True),
864
+ gr.update(visible=False),
865
+ gr.skip(),
866
+ gr.skip(),
867
+ )
868
+ status_md, fig = refresh_lecturer_dashboard(
869
+ expected_count, y_mode_label, x_cutoff_label, show_labels, beamer_mode
870
+ )
871
+ return (
872
+ True,
873
+ "",
874
+ "",
875
+ gr.update(visible=False),
876
+ gr.update(visible=True),
877
+ status_md,
878
+ fig,
879
+ )
880
+
881
+
882
+ def lock_lecturer_view():
883
+ return (
884
+ False,
885
+ "",
886
+ gr.update(visible=True),
887
+ gr.update(visible=False),
888
+ )
889
+
890
+
891
+ def handle_admin_reset(
892
+ is_unlocked: bool,
893
+ expected_count: int,
894
+ y_mode_label: str,
895
+ x_cutoff_label: str,
896
+ show_labels: bool,
897
+ beamer_mode: bool
898
+ ):
899
+ if not is_unlocked:
900
+ return "❌ Nicht autorisiert.", gr.skip(), gr.skip()
901
+ _, msg = SESSION_STORE.clear_all(SESSION_STORE._admin_pin)
902
  status_md, fig = refresh_lecturer_dashboard(expected_count, y_mode_label, x_cutoff_label, show_labels, beamer_mode)
903
+ return msg, status_md, fig
904
 
905
 
906
+ def handle_admin_demo(
907
+ is_unlocked: bool,
908
+ expected_count: int,
909
+ y_mode_label: str,
910
+ x_cutoff_label: str,
911
+ show_labels: bool,
912
+ beamer_mode: bool
913
+ ):
914
+ if not is_unlocked:
915
+ return "❌ Nicht autorisiert.", gr.skip(), gr.skip()
916
+ _, msg = SESSION_STORE.seed_demo_data(SESSION_STORE._admin_pin, count=int(expected_count))
917
  status_md, fig = refresh_lecturer_dashboard(expected_count, y_mode_label, x_cutoff_label, show_labels, beamer_mode)
918
+ return msg, status_md, fig
919
 
920
 
921
  # ==============================================================================
 
1088
 
1089
  feedback_output = gr.Markdown()
1090
  personal_plot_output = gr.Plot(visible=False, label="Psychometrisches 2x2-Profil")
1091
+ student_download_btn = gr.DownloadButton(
1092
+ label="📥 Persönliche Ergebnisse herunterladen (.md)",
1093
+ visible=False,
1094
+ variant="secondary",
1095
+ size="lg",
1096
+ )
1097
 
1098
  sources_footer_md = gr.Markdown(
1099
  TRANSLATIONS["DE"]["sources_footer"],
 
1113
  part_b_header_md,
1114
  *ie4_inputs,
1115
  submit_btn,
1116
+ student_download_btn,
1117
  sources_footer_md,
1118
  ],
1119
  api_name=False
 
1122
  submit_btn.click(
1123
  fn=process_student_submission,
1124
  inputs=[lang_state, custom_code_input, student_token_state] + nccs_inputs + ie4_inputs,
1125
+ outputs=[feedback_output, personal_plot_output, student_download_btn, student_token_state],
1126
  api_name=False
1127
  )
1128
 
1129
  # ----------------------------------------------------------------------
1130
+ # TAB 2: LIVE-AUSWERTUNG HÖRSAAL (DOZENT MIT PIN-SCHUTZ)
1131
  # ----------------------------------------------------------------------
1132
  with gr.TabItem("🖥️ 2. Live-Auswertung Hörsaal"):
1133
+ lecturer_unlocked_state = gr.State(value=False)
1134
+
1135
+ # PIN-Sperrbildschirm (standardmäßig sichtbar)
1136
+ with gr.Column(visible=True) as lecturer_login_group:
1137
+ gr.Markdown(
1138
+ "### 🔒 Geschützter Dozenten-Bereich (Beamer-Ansicht)\n"
1139
+ "Bitte geben Sie die Dozenten-PIN ein (hinterlegt als Secret `ADMIN_PIN` bzw. `PIN` im Space), "
1140
+ "um die Live-Auswertung für den Hörsaal freizuschalten."
1141
+ )
1142
+ with gr.Row():
1143
+ lecturer_pin_input = gr.Textbox(
1144
+ label="Dozenten-PIN",
1145
+ type="password",
1146
+ placeholder="PIN eingeben und mit Enter oder Button bestätigen...",
1147
+ scale=3,
1148
  )
1149
+ unlock_btn = gr.Button(
1150
+ "🔓 Beamer-Ansicht freischalten",
 
1151
  variant="primary",
1152
+ size="lg",
1153
+ scale=1,
1154
  )
1155
+ login_msg_output = gr.Markdown()
1156
 
1157
+ # Eigentliche Beamer-Ansicht (erst nach erfolgreicher PIN-Eingabe sichtbar)
1158
+ with gr.Column(visible=False) as lecturer_content_group:
 
 
 
 
1159
  with gr.Row():
1160
+ with gr.Column(scale=3):
1161
+ lecturer_status_md = gr.Markdown(
1162
+ "<div class='beamer-stats'><span>👥 Eingegangene Antworten: <b>0 / 14</b></span></div>"
1163
+ )
1164
+ with gr.Column(scale=1):
1165
+ refresh_btn = gr.Button(
1166
+ "🔄 Aktualisieren",
1167
+ variant="primary",
1168
+ size="lg"
1169
+ )
1170
+ with gr.Column(scale=1):
1171
+ lock_btn = gr.Button(
1172
+ "🔒 Ansicht sperren",
1173
+ variant="secondary",
1174
+ size="lg"
1175
+ )
1176
+
1177
+ lecturer_plot_output = gr.Plot(
1178
+ value=build_compass_plot([], beamer_mode=True, title="Live-Auswertung Hörsaal (n = 0)"),
1179
+ label="Psychometrisches 2x2-Profil"
1180
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1181
 
1182
+ with gr.Accordion("⚙️ Anzeige- & Admin-Optionen", open=False):
1183
+ with gr.Row():
1184
+ expected_course_size = gr.Number(
1185
+ value=14,
1186
+ label="Kursgröße",
1187
+ precision=0,
1188
+ minimum=1,
1189
+ maximum=200
1190
+ )
1191
+ show_labels_checkbox = gr.Checkbox(
1192
+ value=True,
1193
+ label="Teilnehmer-Codes anzeigen"
1194
+ )
1195
+ beamer_mode_checkbox = gr.Checkbox(
1196
+ value=True,
1197
+ label="Beamer-Modus (Maximaler Kontrast & große Schrift)"
1198
+ )
1199
+ with gr.Row():
1200
+ y_mode_dropdown = gr.Dropdown(
1201
+ choices=[
1202
+ "EKÜ: Externale Kontrollüberzeugung (1.0 bis 5.0)",
1203
+ "IKÜ: Internale Kontrollüberzeugung (1.0 bis 5.0)",
1204
+ "Netto-Internalität: IKÜ minus EKÜ (-4.0 bis +4.0)",
1205
+ ],
1206
+ value="EKÜ: Externale Kontrollüberzeugung (1.0 bis 5.0)",
1207
+ label="Y-Achse"
1208
+ )
1209
+ x_cutoff_radio = gr.Radio(
1210
+ choices=[
1211
+ "Empirischer Referenzwert (M = 3.30)",
1212
+ "Theoretische Skalenmitte (3.50)",
1213
+ ],
1214
+ value="Empirischer Referenzwert (M = 3.30)",
1215
+ label="Trennlinie X-Achse"
1216
+ )
1217
+ with gr.Row():
1218
+ reset_btn = gr.Button("🗑️ Reset (Arbeitsspeicher leeren)", variant="stop", scale=1)
1219
+ demo_btn = gr.Button("🧪 Demo-Daten laden", variant="secondary", scale=1)
1220
+ admin_msg_output = gr.Markdown()
1221
+
1222
+ gr.Markdown(
1223
+ TRANSLATIONS["DE"]["sources_footer"],
1224
+ elem_classes=["sources-box"]
1225
+ )
1226
 
1227
  plot_controls = [
1228
  expected_course_size,
 
1232
  beamer_mode_checkbox
1233
  ]
1234
 
1235
+ dashboard_outputs = [
1236
+ lecturer_status_md,
1237
+ lecturer_plot_output,
1238
+ ]
1239
+
1240
+ unlock_outputs = [
1241
+ lecturer_unlocked_state,
1242
+ lecturer_pin_input,
1243
+ login_msg_output,
1244
+ lecturer_login_group,
1245
+ lecturer_content_group,
1246
+ *dashboard_outputs,
1247
+ ]
1248
+
1249
+ unlock_btn.click(
1250
+ fn=unlock_lecturer_view,
1251
+ inputs=[lecturer_pin_input] + plot_controls,
1252
+ outputs=unlock_outputs,
1253
+ api_name=False
1254
+ )
1255
+
1256
+ lecturer_pin_input.submit(
1257
+ fn=unlock_lecturer_view,
1258
+ inputs=[lecturer_pin_input] + plot_controls,
1259
+ outputs=unlock_outputs,
1260
+ api_name=False
1261
+ )
1262
+
1263
+ lock_btn.click(
1264
+ fn=lock_lecturer_view,
1265
+ inputs=[],
1266
+ outputs=[
1267
+ lecturer_unlocked_state,
1268
+ login_msg_output,
1269
+ lecturer_login_group,
1270
+ lecturer_content_group,
1271
+ ],
1272
+ api_name=False
1273
+ )
1274
+
1275
  refresh_btn.click(
1276
  fn=refresh_lecturer_dashboard,
1277
  inputs=plot_controls,
1278
+ outputs=dashboard_outputs,
1279
  api_name=False
1280
  )
1281
 
 
1283
  ctrl.change(
1284
  fn=refresh_lecturer_dashboard,
1285
  inputs=plot_controls,
1286
+ outputs=dashboard_outputs,
1287
  api_name=False
1288
  )
1289
 
1290
  reset_btn.click(
1291
  fn=handle_admin_reset,
1292
+ inputs=[lecturer_unlocked_state] + plot_controls,
1293
+ outputs=[admin_msg_output] + dashboard_outputs,
1294
  api_name=False
1295
  )
1296
 
1297
  demo_btn.click(
1298
  fn=handle_admin_demo,
1299
+ inputs=[lecturer_unlocked_state] + plot_controls,
1300
+ outputs=[admin_msg_output] + dashboard_outputs,
1301
  api_name=False
1302
  )
1303