Files
shcool/college/models/exam.py
T
2026-09-17 17:20:16 +08:00

737 lines
31 KiB
Python

from odoo import api, fields, models, _
from odoo.exceptions import UserError
from datetime import datetime, timedelta
_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='内部管理用,不展示给学生', required=True)
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='题型', 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)
visibility = fields.Selection(
[('public', '公开'), ('private', '私有')],
string='可见范围', default='private', index=True,
help='公开:同校其他老师均可查看并在组卷时选用;'
'私有:仅出题人本人可见,其他老师看不到也无法选用。')
option_ids = fields.One2many(
'yuthon.school.exam.question.option', 'question_id', string='选项')
correct_answer = fields.Char(
string='正确答案',
help='判断题填 True/False;填空题填标准答案(多个空用 | 分隔)。选择题由「选项」的「正确答案」标记决定。')
# 判断题专用:把 True/False 映射成 对/错 展示与选择,存储仍是 True/False
correct_answer_tf = fields.Selection(
[('True', '对'), ('False', '错')],
string='正确答案',
compute='_compute_correct_answer_tf', inverse='_inverse_correct_answer_tf',
help='判断题的正确答案:对 / 错。')
reference_answer = fields.Html(
string='参考答案', sanitize=True,
help='简答题的参考答案,供老师评阅与学生查看解析时参考。')
explanation = fields.Html(
string='答案说明', sanitize=True,
help='答案解析:学生交卷后可查看本题解析。')
score = fields.Float(string='默认分值', default=1.0,)
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.model
def default_get(self, fields_list):
"""新建题目时,单选/多选题默认带 A/B/C/D 四个选项行。"""
res = super().default_get(fields_list)
if 'option_ids' in fields_list and res.get('question_type') in (
'single_choice', 'multiple_choice'):
res['option_ids'] = self._default_option_lines()
return res
@staticmethod
def _default_option_lines():
return [(0, 0, {'label': lbl, 'sequence': seq})
for lbl, seq in (('A', 10), ('B', 20), ('C', 30), ('D', 40))]
@api.onchange('question_type')
def _onchange_question_type(self):
"""切换题型时:判断题给默认正确答案;单选/多选在无选项时补 A/B/C/D。"""
if self.question_type == 'true_false':
self.correct_answer = 'True'
if self.question_type in ('single_choice', 'multiple_choice') and not self.option_ids:
self.option_ids = self._default_option_lines()
@api.depends('correct_answer')
def _compute_correct_answer_tf(self):
for q in self:
q.correct_answer_tf = q.correct_answer if q.correct_answer in ('True', 'False') else 'True'
def _inverse_correct_answer_tf(self):
for q in self:
if q.correct_answer_tf:
q.correct_answer = q.correct_answer_tf
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)
supervisor_teacher_ids = fields.Many2many(
'res.users', 'yuthon_exam_supervisor_teacher_rel',
'exam_id', 'user_id',
string='监督老师',
domain=lambda self: self._domain_teacher_users(),
help='可协助发布老师批改答卷、查看并下载学生试卷。')
@api.model
def _domain_teacher_users(self):
"""监督老师 / 发布教师只能选择「师生管理-老师」档案关联的登录账号。"""
teacher_user_ids = self.env['yuthon.school.teacher'].search([]).mapped('user_id').ids
return [('id', 'in', teacher_user_ids)]
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_import_from_question_bank(self):
"""根据考试表单的所属课程和关联章节,一键从题库批量导入题目。
- 若未指定关联章节,则导入该课程下的全部可见题目;
- 若指定了关联章节,则只导入这些章节下的题目;
- 自动排除试卷题目行中已存在的题目。
"""
self.ensure_one()
if self.state != 'draft':
raise UserError(_('仅草稿状态的考试可以批量导入题目。'))
if not self.course_id:
raise UserError(_('请先选择「所属课程」。'))
domain = [('course_id', '=', self.course_id.id)]
if self.chapter_ids:
domain.append(('chapter_id', 'in', self.chapter_ids.ids))
existing_question_ids = set(self.line_ids.mapped('question_id').ids)
if existing_question_ids:
domain.append(('id', 'not in', list(existing_question_ids)))
questions = self.env['yuthon.school.exam.question'].search(
domain, order='chapter_id, sequence, id')
if not questions:
raise UserError(_('未找到可导入的新题目(已有题目已自动排除)。'))
new_lines = []
for idx, question in enumerate(questions, start=1):
new_lines.append((0, 0, {
'sequence': idx * 10,
'question_id': question.id,
'score': question.score,
}))
self.write({'line_ids': new_lines})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('批量导入成功'),
'message': _('已为考试「%s」导入 %d 道题目。') % (self.name, len(questions)),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.client', 'tag': 'reload'},
},
}
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)
teacher_comment = fields.Text(
string='整卷评语',
help='老师批改完后给出的整卷评价,会显示在下载的答卷 PDF 中。')
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() + timedelta(hours=8),
})
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):
"""老师评阅:客观题自动判分,简答题未打分则按 0 分计并标记已评阅。"""
for paper in self:
if paper.state != 'submitted':
raise UserError(_('只有「待评阅」状态的答卷可以进行批改。'))
paper._auto_grade()
for ans in paper.answer_ids:
if ans.question_type == 'essay':
# 老师未给分时按 0 分处理;已有分数则保留
if ans.score is False:
ans.score = 0.0
ans.is_graded = True
# 全部题目都已评阅后,整张卷子标记为「已评阅」
if all(a.is_graded for a in paper.answer_ids):
paper.write({'state': 'graded'})
return True
def action_save_grade(self):
"""老师直接在答卷详情页给分后保存:补齐未打分简答题(按0分),标记本题已评阅,
全部题目评阅完毕则整卷标记为「已评阅」。支持重新评阅(graded 状态再改分)。"""
for paper in self:
if paper.state not in ('submitted', 'graded'):
raise UserError(_('只有「待评阅 / 已评阅」的答卷可以保存评分。'))
for ans in paper.answer_ids:
if ans.question_type == 'essay' and ans.score is False:
ans.score = 0.0
ans.is_graded = True
if paper.answer_ids and 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 action_download_report(self):
"""批量下载选中的答卷,合并为一份 PDF。
兼容两种调用方式:
- 列表勾选后由 server action 调用(self 即选中的记录集)
- 通过上下文 active_ids 调用(self 为空时回退读取)
"""
papers = self
if not papers:
papers = self.env['yuthon.school.exam.paper'].browse(
self._context.get('active_ids', self._context.get('active_id')) or [])
if not papers:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': '提示',
'message': '请先勾选要下载的答卷。',
'type': 'warning',
},
}
ids = ','.join(str(p.id) for p in papers)
return {
'type': 'ir.actions.act_url',
'name': '批量下载答卷',
'url': '/exam/review/download/batch?ids=%s' % ids,
'target': 'self',
}
def action_open_review_page(self):
"""老师端:在新页面打开仿学生结果页的评阅页面,给简答题打分。"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'name': '评阅答卷',
'url': '/exam/review/%s' % self.id,
'target': 'self',
}
def action_open_result_view_page(self):
"""在新页面打开评阅结果页,只读查看,不允许改分(老师/学生均可用)。"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'name': '查看评阅结果',
'url': '/exam/review/%s?mode=readonly' % self.id,
'target': 'self',
}
def action_open_review_download(self):
"""老师端:直接下载已批改答卷的 PDF(绕过原生报表弹窗)。"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'name': '下载试卷 PDF',
'url': '/exam/review/download/%s' % self.id,
'target': 'self',
}
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 _report_selected_option_ids(self):
"""返回学生所选选项的记录集(单选/多选统一),供 PDF 报告使用。"""
self.ensure_one()
if self.question_type == 'single_choice':
return self.selected_option_id or self.env['yuthon.school.exam.question.option']
return self.selected_option_ids
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 write(self, vals):
"""简答题老师在答卷详情页直接改分时,自动标记为已评阅本题。"""
res = super().write(vals)
if 'score' in vals:
for rec in self.filtered(
lambda r: r.question_type == 'essay'
and r.score is not False and not r.is_graded
):
rec.is_graded = True
return res
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',
}