diff --git a/college/controllers/__init__.py b/college/controllers/__init__.py new file mode 100644 index 0000000..132b527 --- /dev/null +++ b/college/controllers/__init__.py @@ -0,0 +1 @@ +from . import exam_controller diff --git a/college/controllers/exam_controller.py b/college/controllers/exam_controller.py new file mode 100644 index 0000000..e275fe1 --- /dev/null +++ b/college/controllers/exam_controller.py @@ -0,0 +1,185 @@ +import json +import logging + +from odoo import http, fields +from odoo.http import request +from odoo.exceptions import UserError, AccessError + +_logger = logging.getLogger(__name__) + + +class ExamController(http.Controller): + """学生答题卡式考试页面控制器。""" + + def _check_paper_access(self, paper_id, states=None): + """校验当前登录用户是否为该答卷的学员本人。""" + paper = request.env['yuthon.school.exam.paper'].sudo().browse(paper_id) + if not paper.exists(): + raise http.NotFound() + if paper.user_id.id != request.env.user.id: + raise AccessError('您无权访问该答卷。') + if states and paper.state not in states: + raise UserError('当前答卷状态不允许该操作。') + return paper + + # ------------------------------------------------------------------ + # 1. 开始考试:初始化答题记录并跳转到答题页面 + # ------------------------------------------------------------------ + @http.route('/exam/start/', type='http', auth='user') + def exam_start(self, paper_id, **kw): + paper = self._check_paper_access(paper_id, states=('pending', 'in_progress')) + try: + paper.with_user(request.env.user).action_start() + except UserError as e: + return request.render('college.exam_error_page', {'message': str(e)}) + return request.redirect('/exam/page/%s' % paper_id) + + # ------------------------------------------------------------------ + # 2. 答题主页面 + # ------------------------------------------------------------------ + @http.route('/exam/page/', type='http', auth='user', website=False) + def exam_page(self, paper_id, **kw): + paper = self._check_paper_access(paper_id) + if paper.state == 'pending': + # 未开始则自动开始 + try: + paper.with_user(request.env.user).action_start() + except UserError as e: + return request.render('college.exam_error_page', {'message': str(e)}) + + if paper.state not in ('in_progress', 'submitted', 'graded'): + return request.render('college.exam_error_page', { + 'message': '该答卷当前状态为 %s,无法进入答题页面。' % dict(paper._fields['state'].selection).get(paper.state) + }) + + answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id)) + exam = paper.exam_id + + # 计算剩余时间(秒) + remaining_seconds = 0 + if paper.start_time and exam.duration_minutes: + end_dt = paper.start_time + fields.Datetime.timedelta(minutes=exam.duration_minutes) + remaining_seconds = max(0, int((end_dt - fields.Datetime.now()).total_seconds())) + + values = { + 'paper': paper, + 'exam': exam, + 'course': paper.course_id, + 'chapters': paper.chapter_ids, + 'student': paper.student_id, + 'answers': answers, + 'answer_json': json.dumps([self._answer_to_dict(a) for a in answers]), + 'remaining_seconds': remaining_seconds, + 'duration_minutes': exam.duration_minutes or 0, + 'question_types': dict(request.env['yuthon.school.exam.question']._fields['question_type'].selection), + } + return request.render('college.exam_take_page', values) + + def _answer_to_dict(self, answer): + """将答题记录转为前端 JSON 可用的字典。""" + q = answer.question_id + return { + 'id': answer.id, + 'sequence': answer.sequence or 0, + 'question_type': answer.question_type, + 'question_text': answer.question_text or '', + 'question_text_short': answer.question_text_short or '', + 'max_score': answer.max_score, + 'options': [{ + 'id': opt.id, + 'label': opt.label, + 'content': opt.content or '', + 'text': opt.name or '%s. %s' % (opt.label, opt._strip_html(opt.content or '')), + } for opt in q.option_ids.sorted(lambda o: (o.sequence, o.id))], + # 当前作答 + 'selected_option_id': answer.selected_option_id.id or False, + 'selected_option_ids': answer.selected_option_ids.ids, + 'answer_choice': answer.answer_choice or False, + 'answer_text': answer.answer_text or '', + 'is_answered': bool( + answer.selected_option_id or + answer.selected_option_ids or + answer.answer_choice or + (answer.answer_text and answer._strip_html(answer.answer_text)) + ), + } + + # ------------------------------------------------------------------ + # 3. AJAX 保存单题答案 + # ------------------------------------------------------------------ + @http.route('/exam/save', type='json', auth='user', methods=['POST']) + def exam_save_answer(self, **kw): + # 注意:type='json' 路由的参数在 request.params(JSON-RPC 内层 params), + # 而非 request.jsonrequest(那是 envelope {jsonrpc, method, params, id})。 + params = request.params or {} + paper_id = params.get('paper_id') + answer_id = params.get('answer_id') + data = params.get('data', {}) + + paper = self._check_paper_access(paper_id, states=('in_progress',)) + answer = request.env['yuthon.school.exam.answer'].sudo().browse(int(answer_id)) + if not answer.exists() or answer.paper_id.id != paper.id: + return {'status': 'error', 'message': '题目不属于当前答卷。'} + + qtype = answer.question_type + vals = {} + if qtype == 'single_choice': + vals['selected_option_id'] = int(data.get('selected_option_id') or 0) or False + if vals['selected_option_id']: + vals['selected_option_ids'] = [(6, 0, [vals['selected_option_id']])] + else: + vals['selected_option_ids'] = [(5,)] + elif qtype == 'multiple_choice': + opt_ids = [int(x) for x in data.get('selected_option_ids', []) if x] + vals['selected_option_ids'] = [(6, 0, opt_ids)] + vals['selected_option_id'] = opt_ids[0] if opt_ids else False + elif qtype == 'true_false': + vals['answer_choice'] = data.get('answer_choice') or False + elif qtype in ('fill_blank', 'essay'): + vals['answer_text'] = data.get('answer_text') or False + + answer.write(vals) + return { + 'status': 'ok', + 'is_answered': bool( + answer.selected_option_id or + answer.selected_option_ids or + answer.answer_choice or + (answer.answer_text and answer._strip_html(answer.answer_text)) + ), + } + + # ------------------------------------------------------------------ + # 4. AJAX 提交整卷 + # ------------------------------------------------------------------ + @http.route('/exam/submit', type='json', auth='user', methods=['POST']) + def exam_submit(self, **kw): + params = request.params or {} + paper_id = params.get('paper_id') + paper = self._check_paper_access(paper_id, states=('in_progress',)) + try: + paper.with_user(request.env.user).action_submit() + except UserError as e: + return {'status': 'error', 'message': str(e)} + return { + 'status': 'ok', + 'redirect': '/exam/result/%s' % paper.id, + } + + # ------------------------------------------------------------------ + # 5. 结果页面 + # ------------------------------------------------------------------ + @http.route('/exam/result/', type='http', auth='user', website=False) + def exam_result(self, paper_id, **kw): + paper = self._check_paper_access(paper_id) + if paper.state not in ('submitted', 'graded'): + return request.redirect('/exam/page/%s' % paper_id) + + answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id)) + values = { + 'paper': paper, + 'exam': paper.exam_id, + 'answers': answers, + 'question_types': dict(request.env['yuthon.school.exam.question']._fields['question_type'].selection), + } + return request.render('college.exam_result_page', values) diff --git a/college/data/module_info_data.xml b/college/data/module_info_data.xml new file mode 100644 index 0000000..a4ea3f0 --- /dev/null +++ b/college/data/module_info_data.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/college/data/shixun_module_data.xml b/college/data/shixun_module_data.xml new file mode 100644 index 0000000..c7cf6f7 --- /dev/null +++ b/college/data/shixun_module_data.xml @@ -0,0 +1,31 @@ + + + + + + 实训模块 + ir.module.module + kanban,list,form + + + [('category_id', 'not in', [13, 54, 71, 15, 94, 6, 10, 11, 20])] + {'search_default_app': 1} + 50 + + + + + diff --git a/college/models/exam.py b/college/models/exam.py new file mode 100644 index 0000000..fae0ab3 --- /dev/null +++ b/college/models/exam.py @@ -0,0 +1,539 @@ +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', + } diff --git a/college/models/ir_module_module_info.py b/college/models/ir_module_module_info.py new file mode 100644 index 0000000..ae47d2e --- /dev/null +++ b/college/models/ir_module_module_info.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class IrModuleModule(models.Model): + _inherit = 'ir.module.module' + + introduction = fields.Html( + string='模块介绍', + sanitize=True, + help='在「模块信息 / 功能与介绍」页展示的模块说明(由 college 维护)', + ) + features = fields.Html( + string='功能亮点', + sanitize=True, + help='在「模块信息 / 功能与介绍」页展示的功能清单(由 college 维护)', + ) + knowledge_points = fields.Html( + string='知识点', + sanitize=True, + help='在「模块信息 / 知识点」页展示的核心概念与使用要点(由 college 维护)', + ) + + @api.model + def populate_module_info(self): + """为 school 自建模块写入「功能亮点 / 模块介绍」文案。 + + 由 college 的 data 文件通过 调用,每次升级幂等执行。 + """ + data = { + 'college': { + 'introduction': ( + "

「教育」是面向高校教学的 Odoo 18 综合管理模块,围绕老师与" + "学生双角色设计,打通从师生档案、课程建设到作业与学习进展的全流程。

" + "

它既是师生管理后台,也是学生端的学习入口,所有数据按角色自动隔离可见范围。

" + ), + 'features': ( + "
    " + "
  • 师生管理:班级 / 老师 / 学生档案,支持 CSV 模板批量导入," + "导入时自动创建登录账号;
  • " + "
  • 课程管理:老师创建并独立管理课程,支持理论 / 实践两种形式," + "含视频与 PPT 课件,以及课程介绍、目标、大纲、特色;
  • " + "
  • 作业习题:老师布置与批改,学生在线提交;
  • " + "
  • 学习进展:按角色区分的进展看板,老师看全局、学生看自己;
  • " + "
  • 双角色权限:老师可管理,学生仅可读与提交。
  • " + "
" + ), + 'knowledge_points': ( + "
    " + "
  • 角色隔离:老师与学生看到的数据范围由权限组控制," + "同一菜单、同一视图会根据登录角色自动过滤;
  • " + "
  • CSV 导入规则:导入学生/老师时会按模板字段自动创建用户并绑定 partner," + "确保账号与档案一一对应;
  • " + "
  • 课程归属:课程由创建老师负责维护,其他老师默认不可编辑;
  • " + "
  • 进展看板:学生的提交、成绩、学习行为会汇总到进展视图," + "老师可查看全班统计。
  • " + "
" + ), + }, + 'edu_base': { + 'introduction': ( + "

「教育基础权限」是整套教学体系的权限基座,统一管理跨模块复用的通用角色权限组。

" + ), + 'features': ( + "
    " + "
  • 预置学生 / 教师 / 教务管理员三组权限;
  • " + "
  • 作为基础依赖,供 college、learning_center 等模块直接引用;
  • " + "
  • 不绑定具体业务,只负责角色与授权。
  • " + "
" + ), + 'knowledge_points': ( + "
    " + "
  • 权限组继承:edu_base 只定义基础组,业务模块通过 group_id 字段" + "或菜单 access 引用,实现权限分层;
  • " + "
  • 角色与用户的区别:组(Group)是权限集合,用户通过分配到组获得权限;
  • " + "
  • 安装顺序:依赖 edu_base 的模块会自动触发安装,通常无需单独启用。
  • " + "
" + ), + }, + 'learning_center': { + 'introduction': ( + "

「学习中心」提供课程的基础信息结构与完整教学链路,是课程内容建设的核心。

" + ), + 'features': ( + "
    " + "
  • 课程 / 章节:课程下挂载章节,章节可挂资源;
  • " + "
  • 章节资源:视频、文档等课件管理;
  • " + "
  • 课程作业:布置作业与学员提交;
  • " + "
  • 成绩管理:成绩项定义与学员成绩;
  • " + "
  • 课程大纲 / 教学班:syllabus 与 teaching class 组织。
  • " + "
" + ), + 'knowledge_points': ( + "
    " + "
  • 课程 ≠ 教学班:课程是内容模板,教学班是学期/班级维度的开课实例;
  • " + "
  • 章节资源类型:支持视频、文档等多种资源,资源通过关系模型挂到章节下;
  • " + "
  • 作业与成绩联动:作业提交状态会作为成绩项的数据来源之一;
  • " + "
  • 大纲复用:课程大纲(syllabus)可被多个教学班引用,减少重复录入。
  • " + "
" + ), + }, + 'permission_manager': { + 'introduction': ( + "

「权限管理」提供可视化的角色与权限配置界面。

" + ), + 'features': ( + "
    " + "
  • 角色(Role)定义与维护;
  • " + "
  • 基于角色的权限分配视图;
  • " + "
  • 与 edu_base 权限基座配合,细化授权。
  • " + "
" + ), + 'knowledge_points': ( + "
    " + "
  • Role 模型:本模块新增独立的 Role 业务对象,可与 Odoo 原生组做映射;
  • " + "
  • 权限分配入口:通过表单/列表直接为角色勾选模型级 CRUD 权限;
  • " + "
  • 与 edu_base 的关系:edu_base 提供组定义,permission_manager 负责" + "把这些组分配给具体用户或角色。
  • " + "
" + ), + }, + 'student_organization': { + 'introduction': ( + "

「学生组织」是学生档案与年级 / 班级结构的基础模块,依赖 hr," + "为教学体系提供组织骨架。

" + ), + 'features': ( + "
    " + "
  • 学生组织:年级、班级等层级管理;
  • " + "
  • 学生信息:学生基本档案;
  • " + "
  • 基础年级:年级基础数据初始化。
  • " + "
" + ), + 'knowledge_points': ( + "
    " + "
  • 与 hr 的关系:学生档案复用 employee 模型扩展字段," + "因此依赖 hr 模块;
  • " + "
  • 年级 → 班级 → 学生:组织层级自上而下引用,删除上级会受外键约束;
  • " + "
  • 基础数据:「基础年级」用于初始化常见年级字典,后续可在学生组织中引用。
  • " + "
" + ), + }, + 'yuthon_attendance': { + 'introduction': ( + "

「Yuthon 出勤管理」面向教职工的打卡出勤模块,自动计算工时、迟到与出勤状态。

" + ), + 'features': ( + "
    " + "
  • 签到 / 签退:基于员工记录出勤;
  • " + "
  • 自动计算:工时、迟到分钟、出勤状态由计算字段得出;
  • " + "
  • 约束校验:一人一天一条,签到早于签退;
  • " + "
  • 出勤分析:graph / pivot 统计。
  • " + "
" + ), + 'knowledge_points': ( + "
    " + "
  • 唯一约束:同一员工同一天只能有一条出勤记录,避免重复打卡;
  • " + "
  • 状态计算逻辑:出勤状态由签到/签退时间与阈值比较得出,无需手动选择;
  • " + "
  • 工时公式:实际工时 = 签退时间 − 签到时间 − 扣除规则;
  • " + "
  • 与薪资联动:出勤结果会作为 yuthon_wage 计算月度工资的依据。
  • " + "
" + ), + }, + 'yuthon_employee': { + 'introduction': ( + "

「Yuthon 员工管理」是大学人事基础,管理教职工主数据," + "是整个出勤—薪资体系的数据根。

" + ), + 'features': ( + "
    " + "
  • 教职工档案:工号 / 部门 / 岗位 / 基本工资;
  • " + "
  • 唯一约束:工号不可重复;
  • " + "
  • 模块组合:被出勤、薪资模块通过 _inherit 扩展反向关联。
  • " + "
" + ), + 'knowledge_points': ( + "
    " + "
  • 工号是核心键:工号全局唯一,是出勤、薪资多条记录关联员工的基础;
  • " + "
  • 基本工资用途:基本工资字段写入员工档案,薪资模块每月读取并聚合;
  • " + "
  • 反向扩展模式:出勤、薪资模块通过 _inherit 员工模型添加字段," + "保持主数据单一来源。
  • " + "
" + ), + }, + 'yuthon_login_theme': { + 'introduction': ( + "

「校园登录主题」是纯前端外观模块,将 Odoo 登录页美化为教育网站风格," + "不新增任何业务数据。

" + ), + 'features': ( + "
    " + "
  • 渐变背景:教育紫整页渐变;
  • " + "
  • 玻璃质感:登录卡片 + 品牌标题;
  • " + "
  • 主色统一:登录按钮与主题色一致。
  • " + "
" + ), + 'knowledge_points': ( + "
    " + "
  • 纯前端模块:只包含 SCSS / XML 视图资源,没有模型或业务逻辑;
  • " + "
  • 登录页覆盖:通过继承 Odoo 登录模板并覆盖样式实现;
  • " + "
  • 主题色变量:主色在 SCSS 变量中统一定义,修改一处即可全局生效。
  • " + "
" + ), + }, + 'yuthon_wage': { + 'introduction': ( + "

「Yuthon 薪资管理」在月底把员工基本工资与当月出勤结果聚合," + "自动计算扣款与实发工资。

" + ), + 'features': ( + "
    " + "
  • 月度工资条:基本工资 + 出勤聚合;
  • " + "
  • 自动扣款:迟到 / 缺勤按规则计算;
  • " + "
  • 批量生成:向导批量生成工资条;
  • " + "
  • 状态机:草稿 → 完成。
  • " + "
" + ), + 'knowledge_points': ( + "
    " + "
  • 工资条生成时机:通常每月月底运行向导,批量生成上月工资条;
  • " + "
  • 聚合逻辑:系统按员工汇总当月出勤记录的迟到、缺勤数据," + "再与基本工资做扣款计算;
  • " + "
  • 状态流转:草稿状态可重新计算,完成后建议锁定,防止误改;
  • " + "
  • 与出勤模块的关系:yuthon_wage 依赖 yuthon_attendance," + "考勤数据是薪资计算的关键输入。
  • " + "
" + ), + }, + } + for name, vals in data.items(): + rec = self.search([('name', '=', name)], limit=1) + if rec: + rec.write(vals) diff --git a/college/static/src/img/exam.png b/college/static/src/img/exam.png new file mode 100644 index 0000000..3dbce92 Binary files /dev/null and b/college/static/src/img/exam.png differ diff --git a/college/views/exam_take_templates.xml b/college/views/exam_take_templates.xml new file mode 100644 index 0000000..ac90c3b --- /dev/null +++ b/college/views/exam_take_templates.xml @@ -0,0 +1,672 @@ + + + + + + + + + + + + + + diff --git a/college/views/exam_views.xml b/college/views/exam_views.xml new file mode 100644 index 0000000..9e2c86d --- /dev/null +++ b/college/views/exam_views.xml @@ -0,0 +1,485 @@ + + + + + + + yuthon.school.exam.question.tree + yuthon.school.exam.question + + + + + + + + + + + + + + + + + yuthon.school.exam.question.form + yuthon.school.exam.question + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 单选题请只勾选一个正确答案;多选题可勾选多个正确答案。 +
+
+ + + + + + + +
+
+
+
+
+ + + + yuthon.school.exam.question.search + yuthon.school.exam.question + + + + + + + + + + + + + + + + + + + + + + + yuthon.school.exam.tree + yuthon.school.exam + + + + + + + + + + + + + + + + + + + yuthon.school.exam.form + yuthon.school.exam + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + yuthon.school.exam.search + yuthon.school.exam + + + + + + + + + + + + + + + + + + + yuthon.school.exam.paper.tree + yuthon.school.exam.paper + + + + + + + + + + + + + +
已安装
+ 模块信息 + + + + + + + +
+
+