540 lines
23 KiB
Python
540 lines
23 KiB
Python
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
_QUESTION_TYPES = [
|
|
('single_choice', '单选题'),
|
|
('multiple_choice', '多选题'),
|
|
('true_false', '判断题'),
|
|
('fill_blank', '填空题'),
|
|
('essay', '简答题'),
|
|
]
|
|
|
|
_DIFFICULTY_LEVELS = [
|
|
('easy', '简单'),
|
|
('medium', '中等'),
|
|
('hard', '困难'),
|
|
]
|
|
|
|
|
|
class ExamQuestion(models.Model):
|
|
"""题库——老师出题,支持单选/多选/判断/填空/简答。
|
|
|
|
每道题可挂正确答案与答案说明(解析),并关联到课程/章节,
|
|
后续组卷时按课程或章节快速筛选。
|
|
"""
|
|
|
|
_name = 'yuthon.school.exam.question'
|
|
_description = '题库'
|
|
_order = 'course_id, chapter_id, sequence, id'
|
|
_rec_name = 'question_text_short'
|
|
|
|
sequence = fields.Integer(string='排序', default=10)
|
|
name = fields.Char(string='题目标题', help='内部管理用,不展示给学生')
|
|
question_text = fields.Html(string='题干', sanitize=True, required=True)
|
|
question_text_short = fields.Char(
|
|
string='题干摘要', compute='_compute_question_text_short', store=True)
|
|
|
|
question_type = fields.Selection(
|
|
_QUESTION_TYPES, string='题型', required=True, default='single_choice', index=True)
|
|
difficulty = fields.Selection(
|
|
_DIFFICULTY_LEVELS, string='难度', default='medium')
|
|
|
|
course_id = fields.Many2one(
|
|
'yuthon.school.course', string='所属课程', ondelete='restrict', index=True)
|
|
chapter_id = fields.Many2one(
|
|
'yuthon.school.course.chapter', string='所属章节', ondelete='restrict', index=True,
|
|
domain="[('course_id', '=', course_id)]")
|
|
teacher_id = fields.Many2one(
|
|
'res.users', string='出题人', default=lambda self: self.env.user, required=True)
|
|
|
|
option_ids = fields.One2many(
|
|
'yuthon.school.exam.question.option', 'question_id', string='选项')
|
|
correct_answer = fields.Char(
|
|
string='正确答案',
|
|
help='判断题填 True/False;填空题填标准答案(多个空用 | 分隔)。选择题由「选项」的「正确答案」标记决定。')
|
|
reference_answer = fields.Html(
|
|
string='参考答案', sanitize=True,
|
|
help='简答题的参考答案,供老师评阅与学生查看解析时参考。')
|
|
explanation = fields.Html(
|
|
string='答案说明', sanitize=True,
|
|
help='答案解析:学生交卷后可查看本题解析。')
|
|
|
|
score = fields.Float(string='默认分值', default=1.0, required=True)
|
|
active = fields.Boolean(string='启用', default=True)
|
|
|
|
@api.depends('question_text')
|
|
def _compute_question_text_short(self):
|
|
import re
|
|
for q in self:
|
|
text = re.sub(r'<[^>]+>', ' ', q.question_text or '')
|
|
text = re.sub(r'\s+', ' ', text).strip()
|
|
q.question_text_short = (text[:40] + '…') if len(text) > 40 else text
|
|
|
|
@api.onchange('question_type')
|
|
def _onchange_question_type(self):
|
|
"""切换题型时,对判断题给出默认正确答案提示。"""
|
|
if self.question_type == 'true_false':
|
|
self.correct_answer = 'True'
|
|
|
|
def _get_correct_options(self):
|
|
"""返回本题的正确选项记录(仅选择题有效)。"""
|
|
self.ensure_one()
|
|
return self.option_ids.filtered(lambda o: o.is_correct)
|
|
|
|
|
|
class ExamQuestionOption(models.Model):
|
|
"""题目选项——用于单选题/多选题。"""
|
|
|
|
_name = 'yuthon.school.exam.question.option'
|
|
_description = '题目选项'
|
|
_order = 'sequence, id'
|
|
_rec_name = 'name'
|
|
|
|
question_id = fields.Many2one(
|
|
'yuthon.school.exam.question', string='题目', required=True, ondelete='cascade')
|
|
sequence = fields.Integer(string='排序', default=10)
|
|
label = fields.Char(string='选项标签', default='A', required=True)
|
|
content = fields.Html(string='选项内容', sanitize=True, required=True)
|
|
name = fields.Char(string='选项', compute='_compute_name', store=True)
|
|
is_correct = fields.Boolean(string='正确答案', default=False)
|
|
|
|
@api.depends('label', 'content')
|
|
def _compute_name(self):
|
|
for opt in self:
|
|
text = opt._strip_html(opt.content or '')
|
|
opt.name = f"{opt.label}. {text[:60]}"
|
|
|
|
def _strip_html(self, html):
|
|
import re
|
|
text = re.sub(r'<[^>]+>', ' ', html)
|
|
return re.sub(r'\s+', ' ', text).strip()
|
|
|
|
|
|
class Exam(models.Model):
|
|
"""考试发布——老师从题库组卷并发布给课程学员。"""
|
|
|
|
_name = 'yuthon.school.exam'
|
|
_description = '考试'
|
|
_order = 'start_time desc, id desc'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
|
|
name = fields.Char(string='考试名称', required=True, index=True)
|
|
course_id = fields.Many2one(
|
|
'yuthon.school.course', string='所属课程', required=True, ondelete='cascade', index=True,
|
|
domain="[('state', '=', 'published'), ('teacher_id', '=', uid)]")
|
|
chapter_ids = fields.Many2many(
|
|
'yuthon.school.course.chapter', 'yuthon_exam_chapter_rel',
|
|
string='关联章节',
|
|
domain="[('course_id', '=', course_id)]",
|
|
help='可选:指定本次考试覆盖的章节,用于知识点定位。')
|
|
teacher_id = fields.Many2one(
|
|
'res.users', string='发布教师', default=lambda self: self.env.user, required=True)
|
|
|
|
description = fields.Html(string='考试说明', sanitize=True)
|
|
start_time = fields.Datetime(string='开始时间')
|
|
end_time = fields.Datetime(string='结束时间')
|
|
duration_minutes = fields.Integer(
|
|
string='考试时长(分钟)', default=60,
|
|
help='0 表示不限制时长。')
|
|
pass_score = fields.Float(string='及格分', default=60.0)
|
|
|
|
state = fields.Selection(
|
|
[('draft', '草稿'), ('published', '已发布'), ('closed', '已截止')],
|
|
string='状态', default='draft', index=True)
|
|
active = fields.Boolean(string='启用', default=True)
|
|
|
|
line_ids = fields.One2many(
|
|
'yuthon.school.exam.line', 'exam_id', string='试卷题目')
|
|
total_score = fields.Float(
|
|
string='总分', compute='_compute_total_score', store=True)
|
|
|
|
paper_ids = fields.One2many(
|
|
'yuthon.school.exam.paper', 'exam_id', string='答卷')
|
|
paper_count = fields.Integer(
|
|
string='答卷数', compute='_compute_paper_count', store=True)
|
|
submitted_count = fields.Integer(
|
|
string='已交卷数', compute='_compute_paper_count', store=True)
|
|
graded_count = fields.Integer(
|
|
string='已评阅数', compute='_compute_paper_count', store=True)
|
|
|
|
@api.depends('line_ids.score')
|
|
def _compute_total_score(self):
|
|
for exam in self:
|
|
exam.total_score = sum(exam.line_ids.mapped('score'))
|
|
|
|
@api.depends('paper_ids.state')
|
|
def _compute_paper_count(self):
|
|
for exam in self:
|
|
papers = exam.paper_ids
|
|
exam.paper_count = len(papers)
|
|
exam.submitted_count = len(papers.filtered(lambda p: p.state in ('submitted', 'graded')))
|
|
exam.graded_count = len(papers.filtered(lambda p: p.state == 'graded'))
|
|
|
|
def action_publish(self):
|
|
"""发布考试:草稿 → 已发布,并为课程选课学员生成答卷。"""
|
|
for exam in self:
|
|
if exam.state != 'draft':
|
|
raise UserError(_('考试「%s」当前状态为 %s,请勿重复发布。') % (exam.name or '', exam.state))
|
|
if not exam.line_ids:
|
|
raise UserError(_('考试「%s」尚未添加题目,不能发布。') % (exam.name or ''))
|
|
self.write({'state': 'published'})
|
|
self._create_papers()
|
|
return True
|
|
|
|
def action_close(self):
|
|
"""手动截止考试。"""
|
|
return self.write({'state': 'closed'})
|
|
|
|
def action_reopen(self):
|
|
"""重新开放:已截止 → 已发布(已有答卷保留)。"""
|
|
return self.write({'state': 'published'})
|
|
|
|
def action_draft(self):
|
|
"""撤回草稿:已发布但未产生答卷时可撤回;有答卷则禁止。"""
|
|
for exam in self:
|
|
if exam.paper_ids.filtered(lambda p: p.state != 'pending'):
|
|
raise UserError(_('考试已有学员开始作答或提交,不能撤回草稿。'))
|
|
self.mapped('paper_ids').unlink()
|
|
return self.write({'state': 'draft'})
|
|
|
|
def _create_papers(self):
|
|
"""为课程的每个选课学员创建一张答卷(去重)。"""
|
|
Paper = self.env['yuthon.school.exam.paper'].sudo()
|
|
for exam in self:
|
|
course = exam.course_id
|
|
if not course or not course.student_ids:
|
|
continue
|
|
existing_students = set(Paper.search([
|
|
('exam_id', '=', exam.id),
|
|
]).mapped('student_id').ids)
|
|
for student in course.student_ids:
|
|
if student.id in existing_students:
|
|
continue
|
|
Paper.create({
|
|
'exam_id': exam.id,
|
|
'student_id': student.id,
|
|
'state': 'pending',
|
|
})
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
exams = super().create(vals_list)
|
|
exams._check_time_window()
|
|
return exams
|
|
|
|
def write(self, vals):
|
|
res = super().write(vals)
|
|
if any(k in vals for k in ('start_time', 'end_time', 'state')):
|
|
self._check_time_window()
|
|
return res
|
|
|
|
def _check_time_window(self):
|
|
"""根据时间窗口自动切换已发布考试的状态(仅对 published/closed 生效)。"""
|
|
now = fields.Datetime.now()
|
|
for exam in self:
|
|
if exam.state == 'published' and exam.end_time and exam.end_time < now:
|
|
exam.write({'state': 'closed'})
|
|
elif exam.state == 'closed' and exam.end_time and exam.end_time >= now:
|
|
exam.write({'state': 'published'})
|
|
|
|
|
|
class ExamLine(models.Model):
|
|
"""考试-题目关联(组卷明细)。"""
|
|
|
|
_name = 'yuthon.school.exam.line'
|
|
_description = '考试题目'
|
|
_order = 'sequence, id'
|
|
|
|
exam_id = fields.Many2one(
|
|
'yuthon.school.exam', string='考试', required=True, ondelete='cascade')
|
|
sequence = fields.Integer(string='排序', default=10)
|
|
question_id = fields.Many2one(
|
|
'yuthon.school.exam.question', string='题目', required=True, ondelete='restrict')
|
|
question_type = fields.Selection(
|
|
related='question_id.question_type', string='题型', store=True, readonly=True)
|
|
question_text = fields.Html(
|
|
related='question_id.question_text', string='题干', readonly=True)
|
|
score = fields.Float(string='本题分值', required=True, default=1.0)
|
|
|
|
@api.onchange('question_id')
|
|
def _onchange_question_id(self):
|
|
if self.question_id:
|
|
self.score = self.question_id.score
|
|
|
|
|
|
class ExamPaper(models.Model):
|
|
"""学生答卷——一次考试对应一名学员的一份答卷。"""
|
|
|
|
_name = 'yuthon.school.exam.paper'
|
|
_description = '答卷'
|
|
_order = 'submit_time desc, id desc'
|
|
_rec_name = 'name'
|
|
|
|
name = fields.Char(string='答卷标题', compute='_compute_name', store=True)
|
|
exam_id = fields.Many2one(
|
|
'yuthon.school.exam', string='考试', required=True, ondelete='cascade', index=True)
|
|
course_id = fields.Many2one(
|
|
'yuthon.school.course', string='所属课程', related='exam_id.course_id', store=True, readonly=True)
|
|
chapter_ids = fields.Many2many(
|
|
'yuthon.school.course.chapter', string='关联章节', related='exam_id.chapter_ids', readonly=True)
|
|
description = fields.Html(string='考试说明', related='exam_id.description', readonly=True)
|
|
student_id = fields.Many2one(
|
|
'yuthon.school.student', string='学员', required=True, ondelete='cascade', index=True)
|
|
user_id = fields.Many2one(
|
|
'res.users', string='账号', related='student_id.user_id', store=True)
|
|
|
|
state = fields.Selection(
|
|
[('pending', '待考试'), ('in_progress', '考试中'), ('submitted', '待评阅'), ('graded', '已评阅')],
|
|
string='状态', default='pending', index=True)
|
|
|
|
start_time = fields.Datetime(string='开始考试时间')
|
|
submit_time = fields.Datetime(string='交卷时间')
|
|
score = fields.Float(string='得分', compute='_compute_score', store=True)
|
|
total_score = fields.Float(string='总分', related='exam_id.total_score', store=True)
|
|
pass_score = fields.Float(string='及格分', related='exam_id.pass_score', store=True)
|
|
is_passed = fields.Boolean(string='是否及格', compute='_compute_score', store=True)
|
|
|
|
answer_ids = fields.One2many(
|
|
'yuthon.school.exam.answer', 'paper_id', string='答题记录')
|
|
|
|
@api.depends('exam_id', 'student_id')
|
|
def _compute_name(self):
|
|
for paper in self:
|
|
exam_name = paper.exam_id.name or '考试'
|
|
student_name = paper.student_id.name or '学员'
|
|
paper.name = f'{exam_name} - {student_name}'
|
|
|
|
@api.depends('answer_ids.score')
|
|
def _compute_score(self):
|
|
for paper in self:
|
|
total = sum(paper.answer_ids.mapped('score'))
|
|
paper.score = total
|
|
paper.is_passed = total >= (paper.pass_score or 0)
|
|
|
|
def action_start(self):
|
|
"""学生开始考试:生成答题记录(尚未答题)并记录开始时间。"""
|
|
Answer = self.env['yuthon.school.exam.answer'].sudo()
|
|
for paper in self:
|
|
if paper.state not in ('pending', 'in_progress'):
|
|
raise UserError(_('当前答卷状态为 %s,不能开始考试。') % paper.state)
|
|
# 检查时间窗口
|
|
exam = paper.exam_id
|
|
now = fields.Datetime.now()
|
|
if exam.start_time and now < exam.start_time:
|
|
raise UserError(_('考试尚未开始。'))
|
|
if exam.end_time and now > exam.end_time:
|
|
raise UserError(_('考试已结束。'))
|
|
if not paper.start_time:
|
|
paper.start_time = now
|
|
if paper.state == 'pending':
|
|
paper.write({'state': 'in_progress'})
|
|
# 为每道题目创建空答题记录
|
|
existing_qids = set(paper.answer_ids.mapped('question_id').ids)
|
|
for line in exam.line_ids:
|
|
if line.question_id.id in existing_qids:
|
|
continue
|
|
Answer.create({
|
|
'paper_id': paper.id,
|
|
'line_id': line.id,
|
|
'question_id': line.question_id.id,
|
|
})
|
|
return True
|
|
|
|
def action_submit(self):
|
|
"""学生交卷。"""
|
|
for paper in self:
|
|
if paper.state not in ('in_progress', 'pending'):
|
|
raise UserError(_('只有进行中的考试可以交卷。'))
|
|
self.write({
|
|
'state': 'submitted',
|
|
'submit_time': fields.Datetime.now(),
|
|
})
|
|
self._auto_grade()
|
|
# 纯客观题(无简答题)交卷即自动判分完毕,直接标记已评阅;
|
|
# 含简答题的卷子保留「待评阅」,等老师评阅后标记。
|
|
for paper in self:
|
|
if not any(a.question_type == 'essay' for a in paper.answer_ids):
|
|
paper.write({'state': 'graded'})
|
|
return True
|
|
|
|
def action_grade(self):
|
|
"""老师评阅:客观题自动判分,简答题由老师打分后标记已评阅。"""
|
|
self._auto_grade()
|
|
# 所有题目(含简答题)均标记已评阅后,整张卷子标记为「已评阅」
|
|
for paper in self:
|
|
for ans in paper.answer_ids:
|
|
if ans.question_type == 'essay':
|
|
ans.is_graded = True
|
|
if all(a.is_graded for a in paper.answer_ids):
|
|
paper.write({'state': 'graded'})
|
|
return True
|
|
|
|
def action_open_take_page(self):
|
|
"""学生端:打开答题卡式考试页面。"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_url',
|
|
'name': '开始考试',
|
|
'url': '/exam/start/%s' % self.id,
|
|
'target': 'self',
|
|
}
|
|
|
|
def action_view_answers(self):
|
|
"""老师/学生打开答卷详情。"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': '答卷详情',
|
|
'res_model': 'yuthon.school.exam.paper',
|
|
'res_id': self.id,
|
|
'view_mode': 'form',
|
|
'view_id': self.env.ref('college.yuthon_school_exam_paper_form').id,
|
|
'views': [(self.env.ref('college.yuthon_school_exam_paper_form').id, 'form')],
|
|
'target': 'current',
|
|
}
|
|
|
|
def _auto_grade(self):
|
|
"""客观题自动判分。"""
|
|
for paper in self:
|
|
for answer in paper.answer_ids:
|
|
answer._auto_grade()
|
|
|
|
|
|
class ExamAnswer(models.Model):
|
|
"""学生对一道题的作答与评分结果。"""
|
|
|
|
_name = 'yuthon.school.exam.answer'
|
|
_description = '答题记录'
|
|
_order = 'line_id, id'
|
|
|
|
paper_id = fields.Many2one(
|
|
'yuthon.school.exam.paper', string='答卷', required=True, ondelete='cascade')
|
|
line_id = fields.Many2one(
|
|
'yuthon.school.exam.line', string='考试题目', required=True, ondelete='cascade')
|
|
question_id = fields.Many2one(
|
|
'yuthon.school.exam.question', string='题目', required=True, ondelete='cascade')
|
|
|
|
sequence = fields.Integer(string='排序', related='line_id.sequence', store=True)
|
|
question_type = fields.Selection(
|
|
related='question_id.question_type', string='题型', store=True, readonly=True)
|
|
question_text = fields.Html(
|
|
related='question_id.question_text', string='题干', readonly=True)
|
|
question_text_short = fields.Char(
|
|
related='question_id.question_text_short', string='题干摘要', store=True, readonly=True)
|
|
max_score = fields.Float(string='本题满分', related='line_id.score', readonly=True)
|
|
explanation = fields.Html(
|
|
string='答案说明', related='question_id.explanation', readonly=True)
|
|
reference_answer = fields.Html(
|
|
string='参考答案', related='question_id.reference_answer', readonly=True)
|
|
|
|
# 选择题作答:单选用 Many2one,多选用 Many2many
|
|
selected_option_id = fields.Many2one(
|
|
'yuthon.school.exam.question.option', string='所选选项(单选)',
|
|
domain="[('question_id', '=', question_id)]")
|
|
selected_option_ids = fields.Many2many(
|
|
'yuthon.school.exam.question.option', 'yuthon_exam_answer_option_rel',
|
|
string='所选选项(多选)',
|
|
domain="[('question_id', '=', question_id)]")
|
|
answer_choice = fields.Selection(
|
|
[('True', '正确'), ('False', '错误')],
|
|
string='判断作答')
|
|
answer_text = fields.Html(string='作答内容', sanitize=True)
|
|
|
|
is_correct = fields.Boolean(string='是否正确', compute='_compute_is_correct', store=True)
|
|
score = fields.Float(string='得分')
|
|
is_graded = fields.Boolean(string='是否已评阅本题', default=False, readonly=True,
|
|
help='客观题自动判分后置 True;简答题需老师评阅后置 True。'
|
|
'整张卷子仅当所有题目均 is_graded 才标记「已评阅」。')
|
|
teacher_comment = fields.Text(string='评语')
|
|
answer_display = fields.Char(
|
|
string='作答摘要', compute='_compute_answer_display', store=True)
|
|
|
|
@api.depends('selected_option_id', 'selected_option_ids', 'answer_choice', 'answer_text')
|
|
def _compute_answer_display(self):
|
|
for ans in self:
|
|
qtype = ans.question_type
|
|
if qtype == 'single_choice':
|
|
opt = ans.selected_option_id
|
|
ans.answer_display = f"{opt.label}. {opt.content}" if opt else ''
|
|
elif qtype == 'multiple_choice':
|
|
labels = sorted(ans.selected_option_ids.mapped('label'))
|
|
ans.answer_display = ', '.join(labels) if labels else ''
|
|
elif qtype == 'true_false':
|
|
ans.answer_display = dict(self._fields['answer_choice'].selection).get(ans.answer_choice, '')
|
|
else:
|
|
text = ans._strip_html(ans.answer_text or '')
|
|
ans.answer_display = (text[:60] + '…') if len(text) > 60 else text
|
|
|
|
@api.depends('selected_option_id', 'selected_option_ids', 'answer_choice', 'answer_text', 'question_id')
|
|
def _compute_is_correct(self):
|
|
for ans in self:
|
|
ans.is_correct = ans._check_correct()
|
|
|
|
@api.onchange('selected_option_id')
|
|
def _onchange_selected_option_id(self):
|
|
"""单选变更时同步到多选字段,方便统一显示/评分。"""
|
|
if self.selected_option_id:
|
|
self.selected_option_ids = [(6, 0, [self.selected_option_id.id])]
|
|
else:
|
|
self.selected_option_ids = [(5,)]
|
|
|
|
def _check_correct(self):
|
|
"""检查当前作答是否正确(仅客观题)。"""
|
|
self.ensure_one()
|
|
q = self.question_id
|
|
qtype = q.question_type
|
|
if qtype == 'single_choice':
|
|
correct = q._get_correct_options()
|
|
if not correct or not self.selected_option_id:
|
|
return False
|
|
return self.selected_option_id in correct
|
|
if qtype == 'multiple_choice':
|
|
correct = q._get_correct_options()
|
|
chosen = self.selected_option_ids
|
|
if not chosen:
|
|
return False
|
|
return set(chosen.ids) == set(correct.ids)
|
|
if qtype == 'true_false':
|
|
return (self.answer_choice or '').strip().lower() == (q.correct_answer or '').strip().lower()
|
|
if qtype == 'fill_blank':
|
|
return self._strip_html(self.answer_text or '').lower() == (q.correct_answer or '').strip().lower()
|
|
# 简答题默认不正确,需老师打分
|
|
return False
|
|
|
|
def _strip_html(self, html):
|
|
import re
|
|
text = re.sub(r'<[^>]+>', ' ', html)
|
|
return re.sub(r'\s+', ' ', text).strip()
|
|
|
|
def _auto_grade(self):
|
|
"""客观题自动给分;简答题留给老师手动评分。"""
|
|
self.ensure_one()
|
|
if self.question_type == 'essay':
|
|
return
|
|
self.is_graded = True
|
|
if self._check_correct():
|
|
self.score = self.max_score
|
|
else:
|
|
self.score = 0.0
|
|
|
|
@api.onchange('score')
|
|
def _onchange_score(self):
|
|
"""老师打分后,若得分大于等于满分则标记为正确。"""
|
|
if self.score is not False and self.max_score and self.score >= self.max_score:
|
|
self.is_correct = True
|
|
|
|
def action_view_answer_form(self):
|
|
"""打开答题记录表单(学生作答 / 老师评阅共用)。"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': '答题详情',
|
|
'res_model': 'yuthon.school.exam.answer',
|
|
'res_id': self.id,
|
|
'view_mode': 'form',
|
|
'view_id': self.env.ref('college.yuthon_school_exam_answer_view_form').id,
|
|
'views': [(self.env.ref('college.yuthon_school_exam_answer_view_form').id, 'form')],
|
|
'target': 'new',
|
|
}
|