375 lines
16 KiB
Python
375 lines
16 KiB
Python
"""题库批量导入向导 —— 按题型下载 CSV 模板,上传后批量创建题库题目。
|
||
|
||
每种题型对应一份专属模板:
|
||
- 单选 / 多选:题干 + 选项 A~E + 正确答案(单选填 A;多选填 A,B)
|
||
- 判断:题干 + 正确答案(正确 / 错误)
|
||
- 填空:题干 + 正确答案(标准答案,多空用 | 分隔)
|
||
- 简答:题干 + 参考答案
|
||
|
||
上传后按「所属课程 / 所属章节」定位,自动创建 yuthon.school.exam.question 与对应选项,
|
||
出题人默认为当前用户,可见范围默认私有(可在模板中覆盖)。
|
||
"""
|
||
|
||
import base64
|
||
import csv
|
||
import io
|
||
import logging
|
||
|
||
from odoo import _, api, fields, models
|
||
from odoo.exceptions import UserError
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
# 题型 → 模板列与示例行
|
||
_TEMPLATE_SPECS = {
|
||
'single_choice': {
|
||
'label': '单选题',
|
||
'cols': [
|
||
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
|
||
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
|
||
'答案说明(可选)',
|
||
'选项A', '选项B', '选项C', '选项D', '选项E(可选)',
|
||
'正确答案(填A/B/C/D/E)',
|
||
],
|
||
'sample': [
|
||
'第一章练习-单选', '下列选项中正确的是?', 'Odoo 18 框架入门', '第1章 基础',
|
||
'中等', '2', '私有', '',
|
||
'选项一', '选项二', '选项三', '选项四', '',
|
||
'A',
|
||
],
|
||
},
|
||
'multiple_choice': {
|
||
'label': '多选题',
|
||
'cols': [
|
||
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
|
||
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
|
||
'答案说明(可选)',
|
||
'选项A', '选项B', '选项C', '选项D', '选项E(可选)',
|
||
'正确答案(填 A,B 逗号分隔)',
|
||
],
|
||
'sample': [
|
||
'第一章练习-多选', '以下属于可迭代对象的是?', 'Odoo 18 框架入门', '第1章 基础',
|
||
'中等', '3', '私有', '',
|
||
'列表', '字典', '字符串', '整数', '',
|
||
'A,B,C',
|
||
],
|
||
},
|
||
'true_false': {
|
||
'label': '判断题',
|
||
'cols': [
|
||
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
|
||
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
|
||
'答案说明(可选)',
|
||
'正确答案(正确/错误)',
|
||
],
|
||
'sample': [
|
||
'第一章练习-判断', 'Odoo 是一个开源 ERP 框架。', 'Odoo 18 框架入门', '第1章 基础',
|
||
'简单', '1', '私有', '',
|
||
'正确',
|
||
],
|
||
},
|
||
'fill_blank': {
|
||
'label': '填空题',
|
||
'cols': [
|
||
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
|
||
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
|
||
'答案说明(可选)',
|
||
'正确答案(标准答案,多个空用 | 分隔)',
|
||
],
|
||
'sample': [
|
||
'第一章练习-填空', 'Odoo 的 ORM 全称是 ____。', 'Odoo 18 框架入门', '第1章 基础',
|
||
'简单', '1', '私有', '',
|
||
'Object Relational Mapping',
|
||
],
|
||
},
|
||
'essay': {
|
||
'label': '简答题',
|
||
'cols': [
|
||
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
|
||
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
|
||
'答案说明(可选)',
|
||
'参考答案(可选)',
|
||
],
|
||
'sample': [
|
||
'第一章练习-简答', '简述 Odoo 中 Model 与 Recordset 的关系。', 'Odoo 18 框架入门', '第1章 基础',
|
||
'困难', '10', '私有', '',
|
||
'Model 定义数据结构与行为;Recordset 是某模型的记录集合,支持批量操作。',
|
||
],
|
||
},
|
||
}
|
||
|
||
_DIFFICULTY_MAP = {
|
||
'简单': 'easy', '中等': 'medium', '困难': 'hard',
|
||
'easy': 'easy', 'medium': 'medium', 'hard': 'hard',
|
||
}
|
||
|
||
_VISIBILITY_MAP = {
|
||
'公开': 'public', '私有': 'private',
|
||
'public': 'public', 'private': 'private',
|
||
}
|
||
|
||
|
||
class QuestionImportWizard(models.TransientModel):
|
||
"""题库批量导入向导。"""
|
||
|
||
_name = 'yuthon.school.question.import.wizard'
|
||
_description = '题库批量导入'
|
||
|
||
question_type = fields.Selection([
|
||
('single_choice', '单选题'),
|
||
('multiple_choice', '多选题'),
|
||
('true_false', '判断题'),
|
||
('fill_blank', '填空题'),
|
||
('essay', '简答题'),
|
||
], string='导入题型', required=True,
|
||
help='选择要导入的题目类型,系统会给出对应的模板。')
|
||
data_file = fields.Binary(string='上传文件')
|
||
file_name = fields.Char(string='文件名')
|
||
note = fields.Html(
|
||
string='操作说明', readonly=True,
|
||
default=lambda self: _(
|
||
'<p><b>使用步骤:</b></p>'
|
||
'<ol>'
|
||
'<li>先在上方选择「导入题型」;</li>'
|
||
'<li>点击「下载模板」获取该题型专属的 CSV 模板(含一列示例数据);</li>'
|
||
'<li>用 Excel / WPS 打开,按列填写:<b>题干</b>、<b>所属课程</b> 为必填;'
|
||
'选择题填选项 A~E 并在「正确答案」标出正确项(多选用逗号分隔);'
|
||
'判断填 正确/错误;填空填标准答案;简答填参考答案;</li>'
|
||
'<li>保存为 CSV(建议 UTF-8)后上传,点击「开始导入」;</li>'
|
||
'<li>题目会直接写入题库,出题人默认为当前登录老师,可见范围默认私有。</li>'
|
||
'</ol>'
|
||
),
|
||
)
|
||
|
||
# ─────────────────── 下载模板 ───────────────────
|
||
|
||
def action_download_template(self):
|
||
self.ensure_one()
|
||
if not self.question_type:
|
||
raise UserError(_('请先选择要导入的题型。'))
|
||
spec = _TEMPLATE_SPECS[self.question_type]
|
||
buf = io.StringIO()
|
||
writer = csv.writer(buf)
|
||
writer.writerow(spec['cols'])
|
||
writer.writerow(spec['sample'])
|
||
|
||
attachment = self.env['ir.attachment'].sudo().create({
|
||
'name': '题库导入模板_%s.csv' % spec['label'],
|
||
'datas': base64.b64encode(buf.getvalue().encode('utf-8-sig')),
|
||
'res_model': self._name,
|
||
'res_id': self.id,
|
||
'type': 'binary',
|
||
'mimetype': 'text/csv',
|
||
})
|
||
return {
|
||
'type': 'ir.actions.act_url',
|
||
'url': '/web/content/%s?download=true' % attachment.id,
|
||
'target': 'self',
|
||
}
|
||
|
||
# ─────────────────── 导入 ───────────────────
|
||
|
||
def action_import(self):
|
||
self.ensure_one()
|
||
if not self.question_type:
|
||
raise UserError(_('请先选择要导入的题型。'))
|
||
if not self.data_file:
|
||
raise UserError(_('请先上传填写好的文件。'))
|
||
|
||
content = base64.b64decode(self.data_file)
|
||
for encoding in ('utf-8-sig', 'utf-8', 'gbk', 'gb2312'):
|
||
try:
|
||
text = content.decode(encoding)
|
||
break
|
||
except UnicodeDecodeError:
|
||
continue
|
||
else:
|
||
raise UserError(_('无法识别文件编码,请使用 UTF-8 或 GBK 编码的 CSV。'))
|
||
|
||
reader = csv.DictReader(io.StringIO(text))
|
||
if not reader.fieldnames:
|
||
raise UserError(_('文件为空或格式不正确。'))
|
||
|
||
col_map = self._normalize_columns(reader.fieldnames)
|
||
qtype = self.question_type
|
||
|
||
created = 0
|
||
skipped = 0
|
||
errors = []
|
||
Question = self.env['yuthon.school.exam.question']
|
||
|
||
for row_idx, raw in enumerate(reader, start=2):
|
||
row = {col_map.get(k, k): (v.strip() if v else '') for k, v in raw.items()}
|
||
qtext = row.get('question_text', '').strip()
|
||
if not qtext:
|
||
skipped += 1
|
||
errors.append(_('第 %d 行:题干为必填,跳过。') % row_idx)
|
||
continue
|
||
|
||
course_name = row.get('course', '').strip()
|
||
if not course_name:
|
||
skipped += 1
|
||
errors.append(_('第 %d 行:所属课程为必填,跳过。') % row_idx)
|
||
continue
|
||
course = self.env['yuthon.school.course'].sudo().search(
|
||
[('name', '=', course_name)], limit=1)
|
||
if not course:
|
||
skipped += 1
|
||
errors.append(_('第 %d 行:找不到课程「%s」,跳过。') % (row_idx, course_name))
|
||
continue
|
||
|
||
vals = {
|
||
'question_text': qtext,
|
||
'question_type': qtype,
|
||
'course_id': course.id,
|
||
'teacher_id': self.env.user.id,
|
||
'score': self._parse_float(row.get('score', ''), 1.0),
|
||
'difficulty': _DIFFICULTY_MAP.get(row.get('difficulty', '').strip(), 'medium'),
|
||
'visibility': _VISIBILITY_MAP.get(row.get('visibility', '').strip(), 'private'),
|
||
}
|
||
title = row.get('title', '').strip()
|
||
if title:
|
||
vals['name'] = title
|
||
chapter_name = row.get('chapter', '').strip()
|
||
if chapter_name:
|
||
chapter = self.env['yuthon.school.course.chapter'].sudo().search([
|
||
('course_id', '=', course.id),
|
||
('name', '=', chapter_name),
|
||
], limit=1)
|
||
if chapter:
|
||
vals['chapter_id'] = chapter.id
|
||
explanation = row.get('explanation', '').strip()
|
||
if explanation:
|
||
vals['explanation'] = explanation
|
||
|
||
try:
|
||
if qtype in ('single_choice', 'multiple_choice'):
|
||
option_cmds, correct_set = self._build_options(row, qtype)
|
||
if not option_cmds:
|
||
skipped += 1
|
||
errors.append(_('第 %d 行:未填写任何选项,跳过。') % row_idx)
|
||
continue
|
||
vals['option_ids'] = option_cmds
|
||
elif qtype == 'true_false':
|
||
ans = row.get('correct', '').strip()
|
||
mapped = self._map_bool(ans)
|
||
if mapped is None:
|
||
skipped += 1
|
||
errors.append(_('第 %d 行:正确答案请填 正确/错误,跳过。') % row_idx)
|
||
continue
|
||
vals['correct_answer'] = mapped
|
||
elif qtype == 'fill_blank':
|
||
ans = row.get('correct', '').strip()
|
||
if not ans:
|
||
skipped += 1
|
||
errors.append(_('第 %d 行:正确答案(标准答案)为必填,跳过。') % row_idx)
|
||
continue
|
||
vals['correct_answer'] = ans
|
||
elif qtype == 'essay':
|
||
ref = row.get('reference', '').strip()
|
||
if ref:
|
||
vals['reference_answer'] = ref
|
||
|
||
Question.create(vals)
|
||
created += 1
|
||
except Exception as exc:
|
||
skipped += 1
|
||
errors.append(_('第 %d 行导入失败:%s') % (row_idx, str(exc)))
|
||
|
||
summary = _('导入完成!新建题目:%d,跳过:%d') % (created, skipped)
|
||
if errors:
|
||
summary += '\n' + _('注意事项:\n') + '\n'.join(errors[:10])
|
||
if len(errors) > 10:
|
||
summary += _('\n... 还有 %d 条,详见日志。') % (len(errors) - 10)
|
||
|
||
return {
|
||
'type': 'ir.actions.client',
|
||
'tag': 'display_notification',
|
||
'params': {
|
||
'title': _('导入结果'),
|
||
'message': summary,
|
||
'type': 'success' if not errors else 'warning',
|
||
'sticky': True,
|
||
'next': {'type': 'ir.actions.act_window_close'},
|
||
},
|
||
}
|
||
|
||
# ─────────────────── 辅助 ───────────────────
|
||
|
||
def _build_options(self, row, qtype):
|
||
"""根据选项列构造 option_ids 命令,并返回正确选项标签集合。"""
|
||
correct_raw = row.get('correct', '').strip().upper()
|
||
if qtype == 'single_choice':
|
||
# 单选只取第一个正确项
|
||
first = next((c for c in correct_raw.replace(',', ',').split(',') if c.strip()), None)
|
||
correct_set = {first} if first else set()
|
||
else:
|
||
correct_set = {c.strip() for c in correct_raw.replace(',', ',').split(',') if c.strip()}
|
||
|
||
cmds = []
|
||
for idx in range(5):
|
||
content = row.get('option_%s' % chr(97 + idx), '').strip()
|
||
if not content:
|
||
continue
|
||
label = chr(65 + idx) # A,B,C,D,E
|
||
cmds.append((0, 0, {
|
||
'label': label,
|
||
'content': content,
|
||
'is_correct': label in correct_set,
|
||
}))
|
||
return cmds, correct_set
|
||
|
||
def _parse_float(self, value, default):
|
||
try:
|
||
return float(str(value).strip() or default)
|
||
except (ValueError, TypeError):
|
||
return default
|
||
|
||
def _map_bool(self, value):
|
||
v = value.strip().lower()
|
||
if v in ('正确', 'true', 't', '1', '是', '对'):
|
||
return 'True'
|
||
if v in ('错误', 'false', 'f', '0', '否', '错'):
|
||
return 'False'
|
||
return None
|
||
|
||
def _normalize_columns(self, fieldnames):
|
||
"""列名 → 内部 key。模板表头常带括号说明(如「正确答案(填A/B/C/D/E)」),
|
||
故用「包含」匹配而非全等,兼容中英文与 Excel 自定义列名。"""
|
||
mapping = {}
|
||
for f in fieldnames:
|
||
fl = f.strip().lower()
|
||
if '题目标题' in fl or fl in ('title', 'name', '标题'):
|
||
mapping[f] = 'title'
|
||
elif '题干' in fl or fl in ('question', 'text', '题目'):
|
||
mapping[f] = 'question_text'
|
||
elif '所属课程' in fl or fl in ('course', '课程'):
|
||
mapping[f] = 'course'
|
||
elif '所属章节' in fl or fl in ('chapter', '章节'):
|
||
mapping[f] = 'chapter'
|
||
elif '难度' in fl or fl == 'difficulty':
|
||
mapping[f] = 'difficulty'
|
||
elif '分值' in fl or '分数' in fl or fl == 'score':
|
||
mapping[f] = 'score'
|
||
elif '可见范围' in fl or fl == 'visibility':
|
||
mapping[f] = 'visibility'
|
||
elif '参考答案' in fl or fl == 'reference':
|
||
mapping[f] = 'reference'
|
||
elif '正确答案' in fl or fl in ('correct', 'correct_answer', '答案'):
|
||
mapping[f] = 'correct'
|
||
elif '答案说明' in fl or '解析' in fl or fl == 'explanation':
|
||
mapping[f] = 'explanation'
|
||
elif '选项a' in fl or fl in ('a', 'option_a'):
|
||
mapping[f] = 'option_a'
|
||
elif '选项b' in fl or fl in ('b', 'option_b'):
|
||
mapping[f] = 'option_b'
|
||
elif '选项c' in fl or fl in ('c', 'option_c'):
|
||
mapping[f] = 'option_c'
|
||
elif '选项d' in fl or fl in ('d', 'option_d'):
|
||
mapping[f] = 'option_d'
|
||
elif '选项e' in fl or fl in ('e', 'option_e'):
|
||
mapping[f] = 'option_e'
|
||
else:
|
||
mapping[f] = f
|
||
return mapping
|