Coverage for rfpy/api/io/excel_import.py: 97%
78 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-24 10:52 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-24 10:52 +0000
1# Description: Import questions from an Excel file into a project
2from openpyxl import load_workbook
3from openpyxl.worksheet.worksheet import Worksheet
4from sqlalchemy.orm import object_session
6from rfpy.model import Project
7from rfpy.model.questionnaire import (
8 Section,
9 RadioChoices,
10 TextInput,
11 Label,
12 QuestionInstance,
13 QuestionDefinition,
14)
17class XLCell:
18 def __init__(self, col_idx: int):
19 self.col_idx = col_idx
21 def __get__(self, obj: "QRow", type=None) -> object:
22 return obj.row[self.col_idx].value
25class QRow:
26 section_title = XLCell(0)
27 question_title = XLCell(1)
28 question_text = XLCell(2)
29 multi_choices = XLCell(3)
30 comments_title = XLCell(4)
32 def __init__(self, row: tuple):
33 self.row = row
35 def is_root_question(self):
36 return not self.section_title
38 def choices_list(self):
39 cd = []
40 for choice_str in self.multi_choices.split(";"):
41 choice, autoscore = RadioChoices.split_choice_string(choice_str)
42 cd.append({"label": choice, "autoscore": autoscore})
43 if len(cd) < 2:
44 raise ValueError(
45 "At least two values are required for multiple choice options"
46 )
47 return cd
50def row_to_question(qrow) -> QuestionDefinition:
51 qdef = QuestionDefinition(title=qrow.question_title)
52 elems = qdef.elements
53 elems.append(Label(label=qrow.question_text, row=1, col=1))
54 if qrow.multi_choices:
55 choices = RadioChoices(choices=qrow.choices_list(), row=2, col=1)
56 elems.append(choices)
57 if qrow.comments_title:
58 elems.append(Label(label=qrow.comments_title, row=3, col=1))
59 elems.append(TextInput(height=5, width=60, row=4, col=1))
60 else:
61 if qrow.comments_title:
62 elems.append(Label(label=qrow.comments_title, row=2, col=1))
63 elems.append(TextInput(height=5, width=60, row=3, col=1))
64 else:
65 elems.append(TextInput(height=5, width=60, row=2, col=1))
66 return qdef
69class ExcelQImporter:
70 """
71 Import questions from an Excel file into a project.
73 The Excel file must have the following columns:
74 1. Section Title
75 2. Question Title
76 3. Question Text
77 4. Multiple Choices
78 5. Comments Title
80 The Multiple Choices column is a semicolon-separated list of choices.
81 Each choice can have an optional autoscore value separated by a colon.
82 For example:
83 "Yes:1;No:0;Don't know:0"
85 """
87 def __init__(self, parent_section: Section) -> None:
88 self.created_count = 0
89 if parent_section is None:
90 raise ValueError("missing required Section object, parent_section")
91 self.parent_section = parent_section
92 self.project: Project = parent_section.project
93 self.created_sections: dict[object, Section] = {}
94 self.session = object_session(parent_section)
95 if self.session is None:
96 raise ValueError("parent_section must be attached to a session")
98 def read_questions_excel(self, filename):
99 wb = load_workbook(filename=filename, read_only=True)
100 try:
101 self.process_rows(wb["Sheet1"])
102 finally:
103 wb.close()
105 def process_rows(self, ws: Worksheet):
106 # max_row is 501 to allow a maximum of 500 rows since
107 # rows are indexed from 1, not zero, and there is a header row
108 for row in ws.iter_rows(min_row=2, max_row=501):
109 qr = QRow(row)
110 if qr.question_title:
111 self.process_one_row(qr)
112 self.created_count += 1
114 def process_one_row(self, qrow: QRow):
115 qdef = row_to_question(qrow)
116 qi = QuestionInstance(question_def=qdef, project=self.project)
117 sec: Section
118 if qrow.is_root_question():
119 sec = self.parent_section
120 elif qrow.section_title in self.created_sections:
121 sec = self.created_sections[qrow.section_title]
122 else:
123 sec = Section(title=qrow.section_title, project=self.project)
124 assert self.session is not None
125 self.session.add(sec)
126 self.parent_section.subsections.append(sec)
127 self.created_sections[qrow.section_title] = sec
128 sec.questions.append(qi)