44from datetime import datetime
55
66def read_excel_data (filepath ):
7- """Read the Excel file and return structured data"""
8- df = pd .read_excel (filepath , sheet_name = 'AggrigatedLabs' )
7+ """Read the Excel file and return structured data from individual lab sheets"""
8+ xl_file = pd .ExcelFile (filepath )
9+
10+ # Sheets to ignore
11+ ignore_sheets = ['CS8903' , 'CS6999' , 'Sheet1' , 'AggrigatedLabs' ]
12+
13+ # Get all lab sheets (sheet name = lab name)
14+ lab_sheets = [s for s in xl_file .sheet_names if s not in ignore_sheets ]
915
10- # Group by Lab and Project
1116 structure = {}
12- for _ , row in df .iterrows ():
13- lab = row ['Lab' ]
14- project = row ['Project' ]
15- student = row ['Student' ]
17+
18+ for lab_name in lab_sheets :
19+ df = pd .read_excel (filepath , sheet_name = lab_name )
20+
21+ # Find the Student column (might be 'Student' or 'Student ' with trailing space)
22+ student_col = None
23+ for col in df .columns :
24+ if 'Student' in str (col ):
25+ student_col = col
26+ break
27+
28+ if student_col is None :
29+ print (f"Warning: No Student column found in sheet '{ lab_name } ', skipping" )
30+ continue
31+
32+ # Find the Project column
33+ project_col = None
34+ for col in df .columns :
35+ if 'Project' in str (col ):
36+ project_col = col
37+ break
1638
17- if pd .isna (lab ) or pd .isna (project ) or pd .isna (student ):
39+ if project_col is None :
40+ print (f"Warning: No Project column found in sheet '{ lab_name } ', skipping" )
1841 continue
42+
43+ # Forward-fill the Project column (project name is usually only in first row of group)
44+ df [project_col ] = df [project_col ].ffill ()
45+
46+ # Filter out rows with missing students
47+ df = df [df [student_col ].notna ()]
48+
49+ # Initialize lab in structure
50+ if lab_name not in structure :
51+ structure [lab_name ] = {}
52+
53+ # Group by Project and collect students
54+ for _ , row in df .iterrows ():
55+ project = row [project_col ]
56+ student = row [student_col ]
57+
58+ # Skip rows with missing project or student
59+ if pd .isna (project ) or pd .isna (student ):
60+ continue
61+
62+ # Convert to string and strip whitespace
63+ project = str (project ).strip ()
64+ student = str (student ).strip ()
65+
66+ # Skip empty strings
67+ if not project or not student :
68+ continue
69+
70+ # Initialize project in lab structure
71+ if project not in structure [lab_name ]:
72+ structure [lab_name ][project ] = []
1973
20- if lab not in structure :
21- structure [lab ] = {}
22- if project not in structure [lab ]:
23- structure [lab ][project ] = []
24- structure [lab ][project ].append (student )
74+ # Only add if student name is not already in the list (avoid duplicates)
75+ if student not in structure [lab_name ][project ]:
76+ structure [lab_name ][project ].append (student )
2577
2678 return structure
2779
@@ -33,10 +85,13 @@ def generate_qualtrics_qsf(data):
3385 response_set_id = "RS_" + datetime .now ().strftime ("%Y%m%d%H%M%S" )
3486
3587 # Base survey structure
88+ # Extract semester from data if available
89+ semester = data .get ('_semester' , 'Fall 2025' )
90+
3691 survey = {
3792 "SurveyEntry" : {
3893 "SurveyID" : survey_id ,
39- "SurveyName" : "HAAG Fall 2025 - Weekly Research Team Progress Check" ,
94+ "SurveyName" : f "HAAG { semester } - Weekly Research Team Progress Check" ,
4095 "SurveyDescription" : None ,
4196 "SurveyOwnerID" : "UR_XXXXXXXXXXXXX" ,
4297 "SurveyBrandID" : "gatech" ,
@@ -118,7 +173,9 @@ def generate_qualtrics_qsf(data):
118173
119174 # Create choices for labs
120175 lab_choices = {}
121- lab_names = sorted (data .keys ())
176+ # Filter out non-lab keys (like _semester)
177+ lab_data = {k : v for k , v in data .items () if isinstance (v , dict )}
178+ lab_names = sorted (lab_data .keys ())
122179 for idx , lab in enumerate (lab_names , 1 ):
123180 lab_choices [str (idx )] = {
124181 "Display" : lab
@@ -163,7 +220,7 @@ def generate_qualtrics_qsf(data):
163220 block_counter = 2 # Start from BL_2 since BL_1 is the lab selection block
164221
165222 for lab_idx , lab_name in enumerate (lab_names , 1 ):
166- projects = data [lab_name ]
223+ projects = lab_data [lab_name ]
167224
168225 # Create a new block for this lab
169226 lab_block_id = f"BL_{ block_counter } "
@@ -320,6 +377,107 @@ def generate_qualtrics_qsf(data):
320377 blocks [str (block_counter - 1 )] = lab_block
321378 block_counter += 1
322379
380+ # Create "Overall Check" block with final questions
381+ # Calculate block ID: BL_1 is initial, BL_2 through BL_(len+1) are labs, so overall is BL_(len+2)
382+ overall_block_id = f"BL_{ len (lab_names ) + 2 } "
383+ overall_block = {
384+ "Type" : "Standard" ,
385+ "Description" : "Overall Check" ,
386+ "ID" : overall_block_id ,
387+ "BlockElements" : [],
388+ "Options" : {
389+ "BlockLocking" : "false" ,
390+ "RandomizeQuestions" : "false" ,
391+ "BlockVisibility" : "Expanded"
392+ }
393+ }
394+
395+ # Q: Overall progress toward publication
396+ q_overall_id = f"QID{ qid_counter } "
397+ qid_counter += 1
398+ overall_block ["BlockElements" ].append ({"Type" : "Question" , "QuestionID" : q_overall_id })
399+
400+ questions .append ({
401+ "SurveyID" : survey_id ,
402+ "Element" : "SQ" ,
403+ "PrimaryAttribute" : q_overall_id ,
404+ "SecondaryAttribute" : "How do you evaluate the team's overall progress toward publication?" ,
405+ "TertiaryAttribute" : None ,
406+ "Payload" : {
407+ "QuestionText" : "How do you evaluate the team's overall progress toward publication?" ,
408+ "DataExportTag" : "Q_Overall_Progress" ,
409+ "QuestionID" : q_overall_id ,
410+ "QuestionType" : "MC" ,
411+ "Selector" : "SAHR" ,
412+ "SubSelector" : "TX" ,
413+ "Configuration" : {
414+ "QuestionDescriptionOption" : "SpecifyLabel" ,
415+ "TextPosition" : "inline" ,
416+ "LabelPosition" : "BELOW"
417+ },
418+ "QuestionDescription" : "How do you evaluate the team's overall progress toward publication?" ,
419+ "Choices" : {
420+ "1" : {"Display" : "On Track" },
421+ "2" : {"Display" : "Needs Improvement" },
422+ "3" : {"Display" : "Blocked" }
423+ },
424+ "ChoiceOrder" : [1 , "2" , "3" ],
425+ "Validation" : {
426+ "Settings" : {
427+ "ForceResponse" : "ON" ,
428+ "ForceResponseType" : "ON" ,
429+ "Type" : "None"
430+ }
431+ },
432+ "GradingData" : [],
433+ "Language" : [],
434+ "NextChoiceId" : 4 ,
435+ "NextAnswerId" : 4
436+ }
437+ })
438+
439+ # Q: Anything else you'd like to share
440+ q_comments_id = f"QID{ qid_counter } "
441+ qid_counter += 1
442+ overall_block ["BlockElements" ].append ({"Type" : "Question" , "QuestionID" : q_comments_id })
443+
444+ questions .append ({
445+ "SurveyID" : survey_id ,
446+ "Element" : "SQ" ,
447+ "PrimaryAttribute" : q_comments_id ,
448+ "SecondaryAttribute" : "Anything else you'd like to share about this team's performance and progress?" ,
449+ "TertiaryAttribute" : None ,
450+ "Payload" : {
451+ "QuestionText" : "Anything else you'd like to share about this team's performance and progress?<i> (blockers, concerns, or positive notes)</i><br>" ,
452+ "DataExportTag" : "Q_Comments" ,
453+ "QuestionID" : q_comments_id ,
454+ "QuestionType" : "TE" ,
455+ "Selector" : "ML" ,
456+ "Configuration" : {
457+ "QuestionDescriptionOption" : "UseText"
458+ },
459+ "QuestionDescription" : "Anything else you'd like to share about this team's performance and progress? (blockers, concerns...)" ,
460+ "Validation" : {
461+ "Settings" : {
462+ "ForceResponse" : "OFF" ,
463+ "Type" : "None"
464+ }
465+ },
466+ "GradingData" : [],
467+ "Language" : [],
468+ "NextChoiceId" : 4 ,
469+ "NextAnswerId" : 1 ,
470+ "SearchSource" : {
471+ "AllowFreeResponse" : "false"
472+ }
473+ }
474+ })
475+
476+ # Store the overall check block
477+ # After n labs, blocks["0"] through blocks[str(n-1)] are used, and block_counter = n+1
478+ # So we use blocks[str(block_counter - 1)] which is blocks[str(n)]
479+ blocks [str (block_counter - 1 )] = overall_block
480+
323481 # Build SurveyElements in the correct order
324482 # 1. Blocks element (BL) - Payload is a dict, not array
325483 survey ["SurveyElements" ].append ({
@@ -349,6 +507,15 @@ def generate_qualtrics_qsf(data):
349507 "FlowID" : f"FL_{ lab_idx + 10 } " ,
350508 "Autofill" : []
351509 })
510+
511+ # Add the Overall Check block at the end
512+ overall_block_id = f"BL_{ len (lab_names ) + 2 } "
513+ flow_elements .append ({
514+ "Type" : "Standard" ,
515+ "ID" : overall_block_id ,
516+ "FlowID" : f"FL_{ len (lab_names ) + 100 } " ,
517+ "Autofill" : []
518+ })
352519
353520 survey_flow = {
354521 "Type" : "Root" ,
@@ -472,21 +639,29 @@ def generate_qualtrics_qsf(data):
472639def main ():
473640 """Main function to convert Excel to QSF"""
474641
642+ # Prompt user for semester
643+ semester = input ("Enter the semester (e.g., 'Fall 2025', 'Spring 2026'): " ).strip ()
644+ if not semester :
645+ semester = "Fall 2025" # Default
646+ print (f"Using default: { semester } " )
647+
475648 # Read Excel file
476649 print ("Reading Excel file..." )
477650 excel_file = "HAAG_Fall_Enrollment_Students.xlsx"
478651 data = read_excel_data (excel_file )
479652
480653 print (f"Found { len (data )} labs" )
481654 for lab , projects in data .items ():
482- print (f" { lab } : { len (projects )} projects" )
655+ if isinstance (projects , dict ): # Skip non-dict entries like _semester
656+ print (f" { lab } : { len (projects )} projects" )
483657
484658 # Generate QSF
485659 print ("\n Generating Qualtrics QSF file..." )
660+ data ['_semester' ] = semester # Pass semester to generation function
486661 survey = generate_qualtrics_qsf (data )
487662
488663 # Save to file
489- output_file = "HAAG_Fall_2025_Survey .qsf"
664+ output_file = f"HAAG_ { semester . replace ( ' ' , '_' ) } _Survey .qsf"
490665 with open (output_file , 'w' , encoding = 'utf-8' ) as f :
491666 json .dump (survey , f , indent = 2 , ensure_ascii = False )
492667
0 commit comments