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='内部管理用,不展示给学生') 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) visibility = fields.Selection( [('public', '公开'), ('private', '私有')], string='可见范围', default='private', required=True, index=True, help='公开:同校其他老师均可查看并在组卷时选用;' '私有:仅出题人本人可见,其他老师看不到也无法选用。') 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) 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', }