Files
shcool/college/models/exam.py
T
2026-08-21 19:30:28 +08:00

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',
}