Files
shcool/college/models/question_import_wizard.py
T
2026-08-27 19:53:56 +08:00

375 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""题库批量导入向导 —— 按题型下载 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