737 lines
31 KiB
Python
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',
|
|
}
|