From c60acb1984244dc7aa72ee2c6d4e1a3bec1c6258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?= <9664676+pengyuthon@user.noreply.gitee.com> Date: Thu, 27 Aug 2026 19:53:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=95=99=E8=82=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- college/__init__.py | 1 + college/__manifest__.py | 3 + college/controllers/__init__.py | 1 + college/controllers/exam_controller.py | 188 ++++++++- college/controllers/paper_pdf_reportlab.py | 395 ++++++++++++++++++ college/data/shixun_module_data.xml | 7 +- college/models/__init__.py | 2 + college/models/about_page.py | 1 + college/models/exam.py | 175 +++++++- college/models/exam_import_wizard.py | 128 ++++++ college/models/import_wizard.py | 55 ++- college/models/ir_module_module_info.py | 29 ++ college/models/question_import_wizard.py | 374 +++++++++++++++++ college/patch_wkhtmltopdf.py | 26 ++ college/security/groups.xml | 72 ++++ college/security/ir.model.access.csv | 6 + college/views/exam_import_wizard_views.xml | 60 +++ college/views/exam_paper_report.xml | 144 +++++++ college/views/exam_take_templates.xml | 363 ++++++++++++++-- college/views/exam_views.xml | 143 +++++-- college/views/menus.xml | 109 +++-- .../views/question_import_wizard_views.xml | 28 ++ college/views/shixun_kanban_view.xml | 16 + 23 files changed, 2222 insertions(+), 104 deletions(-) create mode 100644 college/controllers/paper_pdf_reportlab.py create mode 100644 college/models/exam_import_wizard.py create mode 100644 college/models/question_import_wizard.py create mode 100644 college/patch_wkhtmltopdf.py create mode 100644 college/views/exam_import_wizard_views.xml create mode 100644 college/views/exam_paper_report.xml create mode 100644 college/views/question_import_wizard_views.xml diff --git a/college/__init__.py b/college/__init__.py index 91c5580..fdf64b7 100644 --- a/college/__init__.py +++ b/college/__init__.py @@ -1,2 +1,3 @@ from . import controllers from . import models +from . import patch_wkhtmltopdf # noqa: F401 应用 wkhtmltopdf 命令行猴子补丁 diff --git a/college/__manifest__.py b/college/__manifest__.py index d6660ab..e7d52fa 100644 --- a/college/__manifest__.py +++ b/college/__manifest__.py @@ -40,7 +40,10 @@ 'views/assignment_batch_wizard_views.xml', 'views/import_wizard_views.xml', 'views/homework_views.xml', + 'views/exam_import_wizard_views.xml', + 'views/question_import_wizard_views.xml', 'views/exam_views.xml', + 'views/exam_paper_report.xml', 'views/exam_take_templates.xml', 'views/training_views.xml', 'views/course_import_wizard_views.xml', diff --git a/college/controllers/__init__.py b/college/controllers/__init__.py index 132b527..6617364 100644 --- a/college/controllers/__init__.py +++ b/college/controllers/__init__.py @@ -1 +1,2 @@ from . import exam_controller +from . import paper_pdf_reportlab diff --git a/college/controllers/exam_controller.py b/college/controllers/exam_controller.py index e275fe1..c735a96 100644 --- a/college/controllers/exam_controller.py +++ b/college/controllers/exam_controller.py @@ -1,10 +1,14 @@ import json import logging +import urllib.parse +from datetime import timedelta from odoo import http, fields from odoo.http import request from odoo.exceptions import UserError, AccessError +from ..controllers.paper_pdf_reportlab import render_paper_pdf + _logger = logging.getLogger(__name__) @@ -58,7 +62,7 @@ class ExamController(http.Controller): # 计算剩余时间(秒) remaining_seconds = 0 if paper.start_time and exam.duration_minutes: - end_dt = paper.start_time + fields.Datetime.timedelta(minutes=exam.duration_minutes) + end_dt = paper.start_time + timedelta(minutes=exam.duration_minutes) remaining_seconds = max(0, int((end_dt - fields.Datetime.now()).total_seconds())) values = { @@ -183,3 +187,185 @@ class ExamController(http.Controller): 'question_types': dict(request.env['yuthon.school.exam.question']._fields['question_type'].selection), } return request.render('college.exam_result_page', values) + + # ------------------------------------------------------------------ + # 6. 老师评阅页面:仿学生结果页,在详情页中给简答题打分 + # ------------------------------------------------------------------ + def _check_review_access(self, paper_id, allow_student_readonly=False): + """校验当前登录用户是否为该答卷对应考试的发布老师或监督老师。 + + 当 allow_student_readonly=True 时,也允许学生只读查看自己的已评阅卷子。 + """ + paper = request.env['yuthon.school.exam.paper'].sudo().browse(paper_id) + if not paper.exists(): + raise http.NotFound() + user = request.env.user + + # 老师和系统管理员保持原有权限(可评阅/可查看) + if user.has_group('college.group_teacher') or user.has_group('base.group_system'): + exam = paper.exam_id + if user.id != exam.teacher_id.id and user.id not in exam.supervisor_teacher_ids.ids: + raise AccessError('您不是该考试的发布老师或监督老师,无权评阅此答卷。') + if paper.state not in ('submitted', 'graded'): + raise UserError('当前答卷状态为 %s,不能评阅。' % dict(paper._fields['state'].selection).get(paper.state)) + return paper + + # 学生只读访问:只能查看自己的已评阅卷子 + if allow_student_readonly and user.has_group('college.group_student'): + if paper.user_id.id != user.id: + raise AccessError('您只能查看自己的答卷。') + if paper.state != 'graded': + raise UserError('当前答卷尚未评阅,无法查看结果。') + return paper + + raise AccessError('仅老师可访问评阅页面。') + + @http.route('/exam/review/', type='http', auth='user', website=False) + def exam_review(self, paper_id, **kw): + paper = self._check_review_access(paper_id, allow_student_readonly=(kw.get('mode') == 'readonly')) + readonly = kw.get('mode') == 'readonly' + answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id)) + values = { + 'paper': paper, + 'exam': paper.exam_id, + 'answers': answers, + 'readonly': readonly, + 'question_types': dict(request.env['yuthon.school.exam.question']._fields['question_type'].selection), + } + return request.render('college.exam_review_page', values) + + @http.route('/exam/grade_answer', type='json', auth='user', methods=['POST']) + def exam_grade_answer(self, **kw): + params = request.params or {} + paper_id = params.get('paper_id') + answer_id = params.get('answer_id') + score = params.get('score') + comment = params.get('comment', '') + paper = self._check_review_access(paper_id) + 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': '题目不属于当前答卷。'} + try: + score_val = float(score) if score is not None else 0.0 + except (ValueError, TypeError): + return {'status': 'error', 'message': '分数必须是数字。'} + if score_val < 0 or score_val > answer.max_score: + return {'status': 'error', 'message': '分数必须在 0 到 %s 之间。' % answer.max_score} + answer.write({'score': score_val, 'teacher_comment': comment or ''}) + return {'status': 'ok', 'paper_score': paper.score} + + @http.route('/exam/finish_review', type='json', auth='user', methods=['POST']) + def exam_finish_review(self, **kw): + params = request.params or {} + paper_id = params.get('paper_id') + answer_items = params.get('answers', []) or [] + paper = self._check_review_access(paper_id) + + # 先批量保存简答题分数和评语 + for item in answer_items: + answer_id = item.get('answer_id') + score = item.get('score') + comment = item.get('comment', '') + if score is None or score == '': + continue + 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': '题目不属于当前答卷。'} + try: + score_val = float(score) + except (ValueError, TypeError): + return {'status': 'error', 'message': '分数必须是数字。'} + if score_val < 0 or score_val > answer.max_score: + return {'status': 'error', 'message': '第 %s 题分数必须在 0 到 %s 之间。' % (answer.sequence, answer.max_score)} + answer.write({'score': score_val, 'teacher_comment': comment or ''}) + + try: + paper.with_user(request.env.user).action_save_grade() + except UserError as e: + return {'status': 'error', 'message': str(e)} + return {'status': 'ok', 'state': paper.state, 'paper_score': paper.score} + + # ------------------------------------------------------------------ + # 7. 下载已批改答卷 PDF + # ------------------------------------------------------------------ + def _render_paper_pdf(self, paper): + # 使用 reportlab 直接生成 PDF,绕过 Windows 版 wkhtmltopdf 无法加载中文字体的问题 + pdf = render_paper_pdf(paper) + + ascii_name = 'exam_paper_%s.pdf' % paper.id + cn_name = '答卷_%s_%s.pdf' % (paper.exam_id.name or 'exam', paper.student_id.name or 'student') + disposition = "attachment; filename=%s; filename*=UTF-8''%s" % ( + ascii_name, urllib.parse.quote(cn_name)) + return request.make_response(pdf, headers=[ + ('Content-Type', 'application/pdf'), + ('Content-Length', str(len(pdf))), + ('Content-Disposition', disposition), + ]) + + @http.route('/exam/download/', type='http', auth='user') + def exam_download(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) + return self._render_paper_pdf(paper) + + @http.route('/exam/review/download/', type='http', auth='user') + def exam_review_download(self, paper_id, **kw): + # 学生只读查看页里的「下载试卷 PDF」也走这里,需允许学生下载自己的已评阅卷子 + paper = self._check_review_access(paper_id, allow_student_readonly=True) + return self._render_paper_pdf(paper) + + @http.route('/exam/review/download/batch', type='http', auth='user') + def exam_review_download_batch(self, **kw): + """批量下载引导页:根据 ids 逐份校验权限,列出有权下载的答卷, + 前端依次触发每份独立 PDF 下载(浏览器单请求只能落一个文件,故不做合集)。 + + 学生只能下载自己已评阅的答卷;老师/管理员只能下载自己任教/监督考试的答卷。 + 单份(ids 仅一个)时自动直接下载该份,无需二次点击。 + """ + user = request.env.user + is_staff = user.has_group('college.group_teacher') or user.has_group('base.group_system') + + ids_raw = (request.params.get('ids') or '').strip() + paper_ids = [int(x) for x in ids_raw.split(',') if x.strip().isdigit()] + if not paper_ids: + return request.render('college.exam_error_page', + {'message': '请先勾选要下载的答卷。'}) + + papers = request.env['yuthon.school.exam.paper'].sudo().browse(paper_ids) + papers = papers.filtered(lambda p: p.exists()) + + # 逐份校验权限,过滤掉无权的卷子 + allowed = [] + for p in papers: + if is_staff: + exam = p.exam_id + if user.id != exam.teacher_id.id and user.id not in exam.supervisor_teacher_ids.ids: + continue + if p.state not in ('submitted', 'graded'): + continue + else: + # 学生:只能下载自己的已评阅卷子 + if p.user_id.id != user.id: + continue + if p.state != 'graded': + continue + allowed.append(p) + + if not allowed: + return request.render('college.exam_error_page', + {'message': '您没有权限下载所选的答卷,或所选答卷尚未评阅。'}) + + if len(allowed) == 1: + # 单份:直接下载该份独立 PDF + return self._render_paper_pdf(allowed[0]) + + paper_infos = [{ + 'id': p.id, + 'name': '%s - %s' % (p.exam_id.name or '考试', p.student_id.name or '学员'), + 'url': '/exam/review/download/%s' % p.id, + } for p in allowed] + return request.render('college.exam_batch_download_page', { + 'count': len(allowed), + 'papers': paper_infos, + }) diff --git a/college/controllers/paper_pdf_reportlab.py b/college/controllers/paper_pdf_reportlab.py new file mode 100644 index 0000000..585c689 --- /dev/null +++ b/college/controllers/paper_pdf_reportlab.py @@ -0,0 +1,395 @@ +"""使用 reportlab 直接生成学生答卷 PDF,绕过 wkhtmltopdf 中文乱码问题。 + +Windows 环境的 wkhtmltopdf 0.12.6 (with patched qt) 无法正确加载 @font-face 或系统字体, +导致 PDF 中文渲染为乱码/方框。本模块使用 reportlab 在 Python 端直接绘制 PDF, +并注册 Windows 自带中文字体(SimHei / Microsoft YaHei),从根本上保证中文正常显示。 +""" + +import re +from datetime import datetime +from io import BytesIO + +from reportlab.lib import colors +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus import ( + Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, KeepTogether, + PageBreak, +) + + +# 尝试注册系统中文字体,按优先级尝试多个路径 +_FONT_PATHS = [ + ('SimHei', r'C:\Windows\Fonts\simhei.ttf'), + ('MicrosoftYaHei', r'C:\Windows\Fonts\msyh.ttc'), + ('SimSun', r'C:\Windows\Fonts\simsun.ttc'), +] + +_REGISTERED_FONTS = {} + + +def _register_chinese_fonts(): + """注册系统中文字体;返回成功注册的字体名列表。""" + if _REGISTERED_FONTS: + return list(_REGISTERED_FONTS.keys()) + for name, path in _FONT_PATHS: + try: + pdfmetrics.registerFont(TTFont(name, path)) + _REGISTERED_FONTS[name] = path + except Exception: + pass + return list(_REGISTERED_FONTS.keys()) + + +def _get_font_name(preferred='SimHei'): + """返回可用的中文字体名,优先使用指定字体。""" + fonts = _register_chinese_fonts() + if preferred in fonts: + return preferred + return fonts[0] if fonts else 'Helvetica' + + +def _strip_html(html): + """将 HTML 片段转为纯文本(保留换行/空格的可读性)。""" + if not html: + return '' + text = str(html) + text = re.sub(r'\s+', ' ', text) + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + text = re.sub(r'
  • ', '• ', text, flags=re.IGNORECASE) + text = re.sub(r'<[^>]+>', ' ', text) + lines = [re.sub(r'\s+', ' ', line).strip() for line in text.split('\n')] + return '\n'.join(line for line in lines if line) + + +def _escape_xml(text): + """为 reportlab Paragraph 转义 XML 特殊字符。""" + if not text: + return '' + return text.replace('&', '&').replace('<', '<').replace('>', '>') + + +def _p(text, style): + """生成 Paragraph,并转义文本中的 XML 字符。""" + return Paragraph(_escape_xml(text), style) + + +def _build_styles(): + """构建 PDF 中使用的段落样式。""" + font_name = _get_font_name() + base = getSampleStyleSheet() + styles = {} + styles['title'] = ParagraphStyle( + 'title', parent=base['Title'], + fontName=font_name, fontSize=18, leading=26, + textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT, + spaceAfter=6, + ) + styles['subtitle'] = ParagraphStyle( + 'subtitle', parent=base['Normal'], + fontName=font_name, fontSize=10, leading=16, + textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT, + spaceAfter=12, + ) + styles['score_big'] = ParagraphStyle( + 'score_big', parent=base['Normal'], + fontName=font_name, fontSize=26, leading=32, + textColor=colors.HexColor('#2563eb'), alignment=TA_LEFT, + ) + styles['score_label'] = ParagraphStyle( + 'score_label', parent=base['Normal'], + fontName=font_name, fontSize=10, leading=14, + textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT, + ) + styles['score_pass'] = ParagraphStyle( + 'score_pass', parent=base['Normal'], + fontName=font_name, fontSize=10, leading=14, + textColor=colors.HexColor('#15803d'), alignment=TA_LEFT, + ) + styles['score_fail'] = ParagraphStyle( + 'score_fail', parent=base['Normal'], + fontName=font_name, fontSize=10, leading=14, + textColor=colors.HexColor('#b91c1c'), alignment=TA_LEFT, + ) + styles['section_title'] = ParagraphStyle( + 'section_title', parent=base['Normal'], + fontName=font_name, fontSize=12, leading=18, + textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT, + spaceBefore=14, spaceAfter=8, + ) + styles['question_title'] = ParagraphStyle( + 'question_title', parent=base['Normal'], + fontName=font_name, fontSize=10, leading=16, + textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT, + spaceAfter=4, + ) + styles['body'] = ParagraphStyle( + 'body', parent=base['Normal'], + fontName=font_name, fontSize=10, leading=16, + textColor=colors.HexColor('#374151'), alignment=TA_LEFT, + ) + styles['body_green'] = ParagraphStyle( + 'body_green', parent=base['Normal'], + fontName=font_name, fontSize=10, leading=16, + textColor=colors.HexColor('#15803d'), alignment=TA_LEFT, + ) + styles['body_red'] = ParagraphStyle( + 'body_red', parent=base['Normal'], + fontName=font_name, fontSize=10, leading=16, + textColor=colors.HexColor('#b91c1c'), alignment=TA_LEFT, + ) + styles['body_gray'] = ParagraphStyle( + 'body_gray', parent=base['Normal'], + fontName=font_name, fontSize=10, leading=16, + textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT, + ) + styles['body_orange'] = ParagraphStyle( + 'body_orange', parent=base['Normal'], + fontName=font_name, fontSize=10, leading=16, + textColor=colors.HexColor('#b45309'), alignment=TA_LEFT, + ) + styles['tag'] = ParagraphStyle( + 'tag', parent=base['Normal'], + fontName=font_name, fontSize=9, leading=12, + textColor=colors.HexColor('#3730a3'), alignment=TA_CENTER, + ) + styles['tag_green'] = ParagraphStyle( + 'tag_green', parent=base['Normal'], + fontName=font_name, fontSize=9, leading=12, + textColor=colors.HexColor('#15803d'), alignment=TA_CENTER, + ) + styles['tag_red'] = ParagraphStyle( + 'tag_red', parent=base['Normal'], + fontName=font_name, fontSize=9, leading=12, + textColor=colors.HexColor('#b91c1c'), alignment=TA_CENTER, + ) + styles['footer'] = ParagraphStyle( + 'footer', parent=base['Normal'], + fontName=font_name, fontSize=9, leading=12, + textColor=colors.HexColor('#9ca3af'), alignment=TA_RIGHT, + ) + return styles + + +def _question_type_label(qtype): + return { + 'single_choice': '单选题', + 'multiple_choice': '多选题', + 'true_false': '判断题', + 'fill_blank': '填空题', + 'essay': '简答题', + }.get(qtype, qtype) + + +def _build_answer_block(ans, styles, question_number=None): + """为一道题生成 reportlab 元素列表。 + + :param question_number: 在试卷中显示的题号;默认使用题目本身的 sequence。 + """ + story = [] + q = ans.question_id + + # 题头:题号 + 题型 + 分数 + 对错 + score_text = f'{ans.score or 0} / {ans.max_score or 0} 分' + status_style = styles['tag_green'] if ans.is_correct else styles['tag_red'] + status_text = '' + if ans.question_type != 'essay': + status_text = '正确' if ans.is_correct else '错误' + + display_number = question_number if question_number is not None else (ans.sequence or 0) + header_data = [ + [ + _p(f'第 {display_number} 题', styles['question_title']), + _p(_question_type_label(ans.question_type), styles['tag']), + _p(score_text, styles['body']), + ], + ] + if status_text: + header_data[0].append(_p(status_text, status_style)) + + col_count = len(header_data[0]) + if col_count == 3: + widths = [None, 35 * mm, 35 * mm] + else: + widths = [None, 35 * mm, 35 * mm, 25 * mm] + + header_table = Table(header_data, colWidths=widths) + header_table.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING', (0, 0), (-1, -1), 0), + ('RIGHTPADDING', (0, 0), (-1, -1), 6), + ])) + story.append(header_table) + + # 题干 + story.append(_p(_strip_html(ans.question_text), styles['body'])) + + # 选择题选项 + if ans.question_type in ('single_choice', 'multiple_choice') and q.option_ids: + for opt in q.option_ids.sorted(lambda o: (o.sequence, o.id)): + badges = [] + if opt.is_correct: + badges.append('正确') + if opt in ans.selected_option_ids: + badges.append('你的选择') + badge_text = f' [{" · ".join(badges)}]' if badges else '' + option_text = f'{opt.label}. {_strip_html(opt.content)}{badge_text}' + story.append(_p(option_text, styles['body'])) + + # 学生作答 + if ans.question_type == 'essay': + answer_text = _strip_html(ans.answer_text) or '未作答' + else: + answer_text = ans.answer_display or '未作答' + story.append(_p(f'学生作答:{answer_text}', styles['body'])) + + # 正确答案 / 参考答案 + if ans.question_type == 'single_choice': + correct_labels = ', '.join(opt.label for opt in q._get_correct_options()) + story.append(_p(f'正确答案:{correct_labels}', styles['body_green'])) + elif ans.question_type == 'multiple_choice': + correct_labels = ', '.join(opt.label for opt in q._get_correct_options()) + story.append(_p(f'正确答案:{correct_labels}', styles['body_green'])) + elif ans.question_type == 'true_false': + text = '正确' if q.correct_answer == 'True' else '错误' + story.append(_p(f'正确答案:{text}', styles['body_green'])) + elif ans.question_type == 'fill_blank': + story.append(_p(f'正确答案:{q.correct_answer or "-"}', styles['body_green'])) + elif ans.question_type == 'essay': + ref = _strip_html(q.reference_answer) or '-' + story.append(_p(f'参考答案:{ref}', styles['body_gray'])) + + # 本题评语 + if ans.teacher_comment: + story.append(_p(f'评语:{ans.teacher_comment}', styles['body_orange'])) + + return story + + +def _build_paper_story(paper, styles): + """构建单份答卷的 PDF 流元素列表(不含 doc.build)。""" + story = [] + + # 表头:考试名 + 学员信息 + story.append(_p(paper.exam_id.name or '考试', styles['title'])) + + info_parts = [f"学员:{paper.student_id.name or '-'}"] + if paper.course_id: + info_parts.append(f"课程:{paper.course_id.name}") + if paper.chapter_ids: + info_parts.append(f"章节:{', '.join(paper.chapter_ids.mapped('name'))}") + state_map = dict(paper._fields['state'].selection) + info_parts.append(f"状态:{state_map.get(paper.state, paper.state)}") + if paper.submit_time: + info_parts.append(f"交卷:{paper.submit_time.strftime('%Y-%m-%d %H:%M')}") + story.append(_p(' | '.join(info_parts), styles['subtitle'])) + + # 得分概览(仅显示分数数字 + 及格状态 + 及格线) + pass_style = styles['score_pass'] if paper.is_passed else styles['score_fail'] + pass_text = '及格' if paper.is_passed else '不及格' + score_row = [ + _p(f"{paper.score or 0} 分", styles['score_big']), + _p(pass_text, pass_style), + _p(f"及格分:{paper.pass_score or 0} 分", styles['score_label']), + ] + score_table = Table([score_row], colWidths=[70 * mm, 35 * mm, 70 * mm]) + score_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f8fafc')), + ('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e5e7eb')), + ('LEFTPADDING', (0, 0), (-1, -1), 12), + ('RIGHTPADDING', (0, 0), (-1, -1), 12), + ('TOPPADDING', (0, 0), (-1, -1), 10), + ('BOTTOMPADDING', (0, 0), (-1, -1), 10), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ])) + story.append(score_table) + story.append(Spacer(1, 8 * mm)) + + # 答题详情 + story.append(_p('答题详情', styles['section_title'])) + answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id)) + for idx, ans in enumerate(answers): + block = _build_answer_block(ans, styles, question_number=idx + 1) + block_table = Table([[element] for element in block], colWidths=[170 * mm]) + block_table.setStyle(TableStyle([ + ('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e5e7eb')), + ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#ffffff')), + ('LEFTPADDING', (0, 0), (-1, -1), 10), + ('RIGHTPADDING', (0, 0), (-1, -1), 10), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + story.append(KeepTogether(block_table)) + story.append(Spacer(1, 4 * mm)) + + # 整卷评语 + if paper.teacher_comment: + story.append(Spacer(1, 6 * mm)) + comment_table = Table( + [[_p(f"整卷评语:{paper.teacher_comment}", styles['body_orange'])]], + colWidths=[170 * mm] + ) + comment_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#fffbeb')), + ('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#fde68a')), + ('LEFTPADDING', (0, 0), (-1, -1), 10), + ('RIGHTPADDING', (0, 0), (-1, -1), 10), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + story.append(comment_table) + + # 页脚生成时间 + story.append(Spacer(1, 10 * mm)) + story.append(_p(f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}", styles['footer'])) + + return story + + +def render_paper_pdf(paper): + """渲染指定答卷的 PDF 字节流。""" + _register_chinese_fonts() + styles = _build_styles() + + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, pagesize=A4, + rightMargin=20 * mm, leftMargin=20 * mm, + topMargin=20 * mm, bottomMargin=18 * mm, + ) + + story = _build_paper_story(paper, styles) + doc.build(story) + pdf_bytes = buffer.getvalue() + buffer.close() + return pdf_bytes + + +def render_papers_pdf(papers): + """将多份答卷合并渲染为一个 PDF 字节流(每份之间自动分页)。""" + _register_chinese_fonts() + styles = _build_styles() + + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, pagesize=A4, + rightMargin=20 * mm, leftMargin=20 * mm, + topMargin=20 * mm, bottomMargin=18 * mm, + ) + + story = [] + papers_list = list(papers) + for idx, paper in enumerate(papers_list): + story.extend(_build_paper_story(paper, styles)) + if idx < len(papers_list) - 1: + story.append(PageBreak()) + + doc.build(story) + pdf_bytes = buffer.getvalue() + buffer.close() + return pdf_bytes diff --git a/college/data/shixun_module_data.xml b/college/data/shixun_module_data.xml index c7cf6f7..a3d2cdb 100644 --- a/college/data/shixun_module_data.xml +++ b/college/data/shixun_module_data.xml @@ -16,8 +16,11 @@ 10=Productivity(生产力) 11=Calendar(日历) 20=Uncategorized(未分类) 如需保留/恢复某类目,从下面列表中删除对应 id 即可。 --> - [('category_id', 'not in', [13, 54, 71, 15, 94, 6, 10, 11, 20])] - {'search_default_app': 1} + + [('module_origin', '=', 'official'), ('application', '=', True), ('category_id', 'not in', [13, 54, 71, 15, 94, 6, 10, 11, 20])] + {} + 50 diff --git a/college/models/__init__.py b/college/models/__init__.py index 9fa0adb..501250a 100644 --- a/college/models/__init__.py +++ b/college/models/__init__.py @@ -12,3 +12,5 @@ from . import training from . import course_import_wizard from . import ir_module_module_info from . import exam +from . import exam_import_wizard +from . import question_import_wizard diff --git a/college/models/about_page.py b/college/models/about_page.py index afbe0fa..b75e2fc 100644 --- a/college/models/about_page.py +++ b/college/models/about_page.py @@ -17,6 +17,7 @@ class YuthonAboutPage(models.Model): ('system', '系统说明'), ('operation', '操作手册'), ('product', '产品介绍'), + ('import', '导入说明'), ], string='分类', default='system', required=True) content = fields.Html(string='内容', sanitize=True, translate=True) sequence = fields.Integer(string='排序', default=10) diff --git a/college/models/exam.py b/college/models/exam.py index fae0ab3..3d5a937 100644 --- a/college/models/exam.py +++ b/college/models/exam.py @@ -1,5 +1,6 @@ from odoo import api, fields, models, _ from odoo.exceptions import UserError +from datetime import datetime, timedelta _QUESTION_TYPES = [ @@ -47,6 +48,11 @@ class ExamQuestion(models.Model): 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='选项') @@ -130,6 +136,18 @@ class Exam(models.Model): 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='开始时间') @@ -171,6 +189,54 @@ class Exam(models.Model): 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: @@ -294,6 +360,9 @@ class ExamPaper(models.Model): 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='答题记录') @@ -348,7 +417,7 @@ class ExamPaper(models.Model): raise UserError(_('只有进行中的考试可以交卷。')) self.write({ 'state': 'submitted', - 'submit_time': fields.Datetime.now(), + 'submit_time': fields.Datetime.now() + timedelta(hours=8), }) self._auto_grade() # 纯客观题(无简答题)交卷即自动判分完毕,直接标记已评阅; @@ -359,17 +428,36 @@ class ExamPaper(models.Model): return True def action_grade(self): - """老师评阅:客观题自动判分,简答题由老师打分后标记已评阅。""" - self._auto_grade() - # 所有题目(含简答题)均标记已评阅后,整张卷子标记为「已评阅」 + """老师评阅:客观题自动判分,简答题未打分则按 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': - ans.is_graded = True + # 老师未给分时按 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() @@ -394,6 +482,65 @@ class ExamPaper(models.Model): '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: @@ -502,6 +649,13 @@ class ExamAnswer(models.Model): # 简答题默认不正确,需老师打分 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) @@ -524,6 +678,17 @@ class ExamAnswer(models.Model): 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() diff --git a/college/models/exam_import_wizard.py b/college/models/exam_import_wizard.py new file mode 100644 index 0000000..f4b9473 --- /dev/null +++ b/college/models/exam_import_wizard.py @@ -0,0 +1,128 @@ +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +from .exam import _QUESTION_TYPES + + +class ExamImportWizard(models.TransientModel): + """按课程/章节从题库批量导入题目到考试。""" + + _name = 'yuthon.school.exam.import.wizard' + _description = '从题库批量导入题目' + + exam_id = fields.Many2one( + 'yuthon.school.exam', string='考试', required=True, readonly=True, + default=lambda self: self.env.context.get('active_id')) + course_id = fields.Many2one( + 'yuthon.school.course', string='所属课程', + related='exam_id.course_id', readonly=True) + + import_all_course = fields.Boolean( + string='导入整门课程题目', default=False, + help='勾选后,将忽略章节选择并导入本课程所有题目。') + chapter_ids = fields.Many2many( + 'yuthon.school.course.chapter', 'yuthon_exam_import_wizard_chapter_rel', + string='所属章节', + domain="[('course_id', '=', course_id)]", + help='选择要导入的章节;不勾选“导入整门课程题目”时生效。') + question_type = fields.Selection( + _QUESTION_TYPES, string='题型筛选', + help='只导入指定题型;留空则导入全部题型。') + exclude_existing = fields.Boolean( + string='排除已存在题目', default=True, + help='试卷中已有的题目不会重复导入。') + + line_ids = fields.One2many( + 'yuthon.school.exam.import.wizard.line', 'wizard_id', + string='待导入题目') + + @api.onchange('import_all_course', 'chapter_ids', 'question_type', 'exclude_existing') + def _onchange_load_questions(self): + """根据筛选条件实时生成待导入题目列表。""" + self.ensure_one() + if not self.exam_id or not self.course_id: + self.line_ids = [(5, 0, 0)] + return + + # 草稿状态的考试才允许重组题目;非草稿仅做预览,导入时会再次校验 + question_obj = self.env['yuthon.school.exam.question'] + domain = [ + ('course_id', '=', self.course_id.id), + ('active', '=', True), + ] + + if not self.import_all_course and self.chapter_ids: + domain.append(('chapter_id', 'in', self.chapter_ids.ids)) + + if self.question_type: + domain.append(('question_type', '=', self.question_type)) + + existing_qids = set() + if self.exclude_existing: + existing_qids = set(self.exam_id.line_ids.mapped('question_id').ids) + + questions = question_obj.search(domain, order='chapter_id, sequence, id') + questions = questions.filtered(lambda q: q.id not in existing_qids) + + base_seq = 10 + commands = [(5, 0, 0)] + for idx, question in enumerate(questions): + commands.append((0, 0, { + 'sequence': base_seq + idx * 10, + 'question_id': question.id, + 'score': question.score, + 'selected': True, + })) + self.line_ids = commands + + def action_confirm_import(self): + """将选中的题目写入考试组卷明细。""" + self.ensure_one() + exam = self.exam_id + if not exam: + raise UserError(_('未找到目标考试。')) + if exam.state != 'draft': + raise UserError(_('仅草稿状态的考试可以批量导入题目。')) + + existing_qids = set(exam.line_ids.mapped('question_id').ids) + line_vals = [] + for line in self.line_ids.filtered('selected'): + if line.question_id.id in existing_qids: + continue + line_vals.append({ + 'exam_id': exam.id, + 'question_id': line.question_id.id, + 'sequence': line.sequence, + 'score': line.score, + }) + + if not line_vals: + raise UserError(_('没有需要导入的新题目。')) + + self.env['yuthon.school.exam.line'].create(line_vals) + return {'type': 'ir.actions.act_window_close'} + + +class ExamImportWizardLine(models.TransientModel): + """向导中的待导入题目明细行。""" + + _name = 'yuthon.school.exam.import.wizard.line' + _description = '待导入题目明细' + _order = 'sequence, id' + + wizard_id = fields.Many2one( + 'yuthon.school.exam.import.wizard', string='向导', + required=True, ondelete='cascade') + selected = fields.Boolean(string='导入', default=True) + sequence = fields.Integer(string='排序', default=10) + question_id = fields.Many2one( + 'yuthon.school.exam.question', string='题目', required=True, readonly=True) + question_type = fields.Selection( + related='question_id.question_type', string='题型', readonly=True) + question_text_short = fields.Char( + related='question_id.question_text_short', string='题干', readonly=True) + chapter_id = fields.Many2one( + related='question_id.chapter_id', string='所属章节', readonly=True) + visibility = fields.Selection( + related='question_id.visibility', string='可见范围', readonly=True) + score = fields.Float(string='本题分值', default=1.0) diff --git a/college/models/import_wizard.py b/college/models/import_wizard.py index 6d3ded5..417834d 100644 --- a/college/models/import_wizard.py +++ b/college/models/import_wizard.py @@ -86,7 +86,7 @@ class ImportTeacherStudentWizard(models.TransientModel): '姓名、角色(老师/学生)、手机号 为必填;
    ' '班级、学院、性别 为选填;
  • ' '
  • 保存为 CSV(逗号分隔)后在此上传并点击「开始导入」;
  • ' - '
  • 系统将自动创建登录账号(账号=姓名拼音,密码=手机号)并关联师生档案;
  • ' + '
  • 系统将自动创建登录账号(账号=姓名拼音,学生密码=学号、老师密码=工号)并关联师生档案;
  • ' '
  • 若账号已存在则跳过,不会重复创建。
  • ' '' ), @@ -96,13 +96,16 @@ class ImportTeacherStudentWizard(models.TransientModel): def action_download_template(self): """生成 CSV 模板并提供下载。""" - header = ['姓名', '角色(老师/学生)', '手机号', '班级', '学院', '性别(男/女)'] - sample = ['张三', '学生', '13800138001', '计算机2024级1班', '计算机学院', '男'] + header = ['姓名', '角色(老师/学生)', '手机号', '学号', '工号', '班级', '学院', '性别(男/女)'] + sample = [ + ['张三', '学生', '13800138001', '2024001', '', '计算机2024级1班', '计算机学院', '男'], + ['李老师', '老师', '13900139000', '', 'T1001', '', '', ''], + ] buf = io.StringIO() writer = csv.writer(buf) writer.writerow(header) - writer.writerow(sample) + writer.writerows(sample) attachment = self.env['ir.attachment'].sudo().create({ 'name': '师生导入模板.csv', @@ -158,6 +161,8 @@ class ImportTeacherStudentWizard(models.TransientModel): name = row.get('name', '') role = row.get('role', '').strip() phone = row.get('phone', '').strip() + student_number = row.get('student_number', '').strip() + teacher_no = row.get('teacher_no', '').strip() class_name = row.get('class_name', '').strip() college_name = row.get('college', '').strip() gender_raw = row.get('gender', '').strip() @@ -177,6 +182,16 @@ class ImportTeacherStudentWizard(models.TransientModel): is_teacher = role_lower in ('老师', 'teacher') + # 学生必须有学号(作为登录密码),老师必须有工号(作为登录密码) + if is_teacher and not teacher_no: + skipped += 1 + errors.append(_('第 %d 行:老师必须填写「工号」(作为登录密码),跳过。') % row_idx) + continue + if not is_teacher and not student_number: + skipped += 1 + errors.append(_('第 %d 行:学生必须填写「学号」(作为登录密码),跳过。') % row_idx) + continue + try: # 1. 创建/获取 res.users login = _to_login(name) @@ -185,7 +200,8 @@ class ImportTeacherStudentWizard(models.TransientModel): errors.append(_('第 %d 行:无法生成登录账号,跳过。') % row_idx) continue - user = self._get_or_create_user(login, name, phone, is_teacher) + password = teacher_no if is_teacher else student_number + user = self._get_or_create_user(login, name, password, is_teacher) if user is None: skipped += 1 errors.append( @@ -194,11 +210,11 @@ class ImportTeacherStudentWizard(models.TransientModel): # 2. 创建师生档案 if is_teacher: - self._create_teacher(user, name, phone) + self._create_teacher(user, name, phone, teacher_no) created_teachers += 1 else: self._create_student( - user, name, phone, class_name, college_name, gender_raw) + user, name, phone, class_name, college_name, gender_raw, student_number) created_students += 1 created_users += 1 @@ -247,6 +263,10 @@ class ImportTeacherStudentWizard(models.TransientModel): mapping[f] = 'role' elif f_lower in ('手机号', 'phone', '电话', '手机', '联系电话'): mapping[f] = 'phone' + elif f_lower in ('学号', 'student_number', '编号', 'xuehao', '学号/工号'): + mapping[f] = 'student_number' + elif f_lower in ('工号', 'teacher_no', '职工号', 'gonghao', '工号/学号'): + mapping[f] = 'teacher_no' elif f_lower in ('班级', 'class', 'class_name', '所在班级'): mapping[f] = 'class_name' elif f_lower in ('学院', 'college', 'college_name', '所属学院'): @@ -257,16 +277,15 @@ class ImportTeacherStudentWizard(models.TransientModel): mapping[f] = f return mapping - def _get_or_create_user(self, login, name, phone, is_teacher): + def _get_or_create_user(self, login, name, password, is_teacher): """获取或创建 res.users,返回 user 记录或 None(冲突时)。""" group_teacher = self.env.ref('college.group_teacher', raise_if_not_found=False) group_student = self.env.ref('college.group_student', raise_if_not_found=False) - Portal = self.env.ref('base.group_portal', raise_if_not_found=False) existing = self.env['res.users'].sudo().search( [('login', '=', login)], limit=1) if existing: - # 已存在:检查角色兼容 + # 已存在:补加对应角色组(不强行加 Portal,避免内部/外部用户类型冲突) if is_teacher and group_teacher and group_teacher not in existing.groups_id: existing.sudo().write({'groups_id': [(4, group_teacher.id)]}) elif not is_teacher and group_student and group_student not in existing.groups_id: @@ -278,39 +297,43 @@ class ImportTeacherStudentWizard(models.TransientModel): groups.append(group_teacher.id) elif not is_teacher and group_student: groups.append(group_student.id) - if Portal: - groups.append(Portal.id) return self.env['res.users'].sudo().create({ 'name': name, 'login': login, - 'password': phone, + 'password': password, 'groups_id': [(6, 0, groups)], }) - def _create_teacher(self, user, name, phone): + def _create_teacher(self, user, name, phone, teacher_no): """创建老师档案(去重)。""" existing = self.env['yuthon.school.teacher'].sudo().search( [('user_id', '=', user.id)], limit=1) if existing: + if teacher_no and not existing.teacher_no: + existing.sudo().write({'teacher_no': teacher_no}) return existing return self.env['yuthon.school.teacher'].sudo().create({ 'name': name, 'user_id': user.id, 'phone': phone, + 'teacher_no': teacher_no, }) - def _create_student(self, user, name, phone, class_name, college_name, gender_raw): - """创建学生档案(去重)��""" + def _create_student(self, user, name, phone, class_name, college_name, gender_raw, student_number): + """创建学生档案(去重)。""" existing = self.env['yuthon.school.student'].sudo().search( [('user_id', '=', user.id)], limit=1) if existing: + if student_number and not existing.student_number: + existing.sudo().write({'student_number': student_number}) return existing vals = { 'name': name, 'user_id': user.id, 'phone': phone, + 'student_number': student_number, } if class_name: cls = self.env['yuthon.school.class'].sudo().search( diff --git a/college/models/ir_module_module_info.py b/college/models/ir_module_module_info.py index ae47d2e..da35cdc 100644 --- a/college/models/ir_module_module_info.py +++ b/college/models/ir_module_module_info.py @@ -1,10 +1,37 @@ # -*- coding: utf-8 -*- +from pathlib import Path + from odoo import api, fields, models +from odoo.modules.module import get_module_path class IrModuleModule(models.Model): _inherit = 'ir.module.module' + module_origin = fields.Selection( + [('official', '系统原生'), ('local', '本地模块'), ('third_party', '第三方/其它')], + string='模块来源', + compute='_compute_module_origin', + store=True, + index=True, + help='Odoo 官方模块标记为「系统原生」;放在 school 目录下的自建模块标记为「本地模块」;其余为「第三方/其它」。', + ) + + @api.depends('author', 'name') + def _compute_module_origin(self): + official_authors = {'Odoo', 'Odoo S.A.', 'Odoo S.A'} + for rec in self: + # 优先按物理路径判断: addons/school/ 下的模块视为本地自建 + module_path = get_module_path(rec.name) + if module_path and Path(module_path).parent.name.lower() == 'school': + rec.module_origin = 'local' + continue + author = (rec.author or '').strip() + if author in official_authors: + rec.module_origin = 'official' + else: + rec.module_origin = 'third_party' + introduction = fields.Html( string='模块介绍', sanitize=True, @@ -231,3 +258,5 @@ class IrModuleModule(models.Model): rec = self.search([('name', '=', name)], limit=1) if rec: rec.write(vals) + # 确保来源字段对所有模块记录完成计算(覆盖历史模块记录) + self.search([])._compute_module_origin() diff --git a/college/models/question_import_wizard.py b/college/models/question_import_wizard.py new file mode 100644 index 0000000..73942d2 --- /dev/null +++ b/college/models/question_import_wizard.py @@ -0,0 +1,374 @@ +"""题库批量导入向导 —— 按题型下载 CSV 模板,上传后批量创建题库题目。 + +每种题型对应一份专属模板: +- 单选 / 多选:题干 + 选项 A~E + 正确答案(单选填 A;多选填 A,B) +- 判断:题干 + 正确答案(正确 / 错误) +- 填空:题干 + 正确答案(标准答案,多空用 | 分隔) +- 简答:题干 + 参考答案 + +上传后按「所属课程 / 所属章节」定位,自动创建 yuthon.school.exam.question 与对应选项, +出题人默认为当前用户,可见范围默认私有(可在模板中覆盖)。 +""" + +import base64 +import csv +import io +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +# 题型 → 模板列与示例行 +_TEMPLATE_SPECS = { + 'single_choice': { + 'label': '单选题', + 'cols': [ + '题目标题(可选)', '题干', '所属课程', '所属章节(可选)', + '难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)', + '答案说明(可选)', + '选项A', '选项B', '选项C', '选项D', '选项E(可选)', + '正确答案(填A/B/C/D/E)', + ], + 'sample': [ + '第一章练习-单选', '下列选项中正确的是?', 'Odoo 18 框架入门', '第1章 基础', + '中等', '2', '私有', '', + '选项一', '选项二', '选项三', '选项四', '', + 'A', + ], + }, + 'multiple_choice': { + 'label': '多选题', + 'cols': [ + '题目标题(可选)', '题干', '所属课程', '所属章节(可选)', + '难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)', + '答案说明(可选)', + '选项A', '选项B', '选项C', '选项D', '选项E(可选)', + '正确答案(填 A,B 逗号分隔)', + ], + 'sample': [ + '第一章练习-多选', '以下属于可迭代对象的是?', 'Odoo 18 框架入门', '第1章 基础', + '中等', '3', '私有', '', + '列表', '字典', '字符串', '整数', '', + 'A,B,C', + ], + }, + 'true_false': { + 'label': '判断题', + 'cols': [ + '题目标题(可选)', '题干', '所属课程', '所属章节(可选)', + '难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)', + '答案说明(可选)', + '正确答案(正确/错误)', + ], + 'sample': [ + '第一章练习-判断', 'Odoo 是一个开源 ERP 框架。', 'Odoo 18 框架入门', '第1章 基础', + '简单', '1', '私有', '', + '正确', + ], + }, + 'fill_blank': { + 'label': '填空题', + 'cols': [ + '题目标题(可选)', '题干', '所属课程', '所属章节(可选)', + '难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)', + '答案说明(可选)', + '正确答案(标准答案,多个空用 | 分隔)', + ], + 'sample': [ + '第一章练习-填空', 'Odoo 的 ORM 全称是 ____。', 'Odoo 18 框架入门', '第1章 基础', + '简单', '1', '私有', '', + 'Object Relational Mapping', + ], + }, + 'essay': { + 'label': '简答题', + 'cols': [ + '题目标题(可选)', '题干', '所属课程', '所属章节(可选)', + '难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)', + '答案说明(可选)', + '参考答案(可选)', + ], + 'sample': [ + '第一章练习-简答', '简述 Odoo 中 Model 与 Recordset 的关系。', 'Odoo 18 框架入门', '第1章 基础', + '困难', '10', '私有', '', + 'Model 定义数据结构与行为;Recordset 是某模型的记录集合,支持批量操作。', + ], + }, +} + +_DIFFICULTY_MAP = { + '简单': 'easy', '中等': 'medium', '困难': 'hard', + 'easy': 'easy', 'medium': 'medium', 'hard': 'hard', +} + +_VISIBILITY_MAP = { + '公开': 'public', '私有': 'private', + 'public': 'public', 'private': 'private', +} + + +class QuestionImportWizard(models.TransientModel): + """题库批量导入向导。""" + + _name = 'yuthon.school.question.import.wizard' + _description = '题库批量导入' + + question_type = fields.Selection([ + ('single_choice', '单选题'), + ('multiple_choice', '多选题'), + ('true_false', '判断题'), + ('fill_blank', '填空题'), + ('essay', '简答题'), + ], string='导入题型', required=True, + help='选择要导入的题目类型,系统会给出对应的模板。') + data_file = fields.Binary(string='上传文件') + file_name = fields.Char(string='文件名') + note = fields.Html( + string='操作说明', readonly=True, + default=lambda self: _( + '

    使用步骤:

    ' + '
      ' + '
    1. 先在上方选择「导入题型」;
    2. ' + '
    3. 点击「下载模板」获取该题型专属的 CSV 模板(含一列示例数据);
    4. ' + '
    5. 用 Excel / WPS 打开,按列填写:题干、所属课程 为必填;' + '选择题填选项 A~E 并在「正确答案」标出正确项(多选用逗号分隔);' + '判断填 正确/错误;填空填标准答案;简答填参考答案;
    6. ' + '
    7. 保存为 CSV(建议 UTF-8)后上传,点击「开始导入」;
    8. ' + '
    9. 题目会直接写入题库,出题人默认为当前登录老师,可见范围默认私有。
    10. ' + '
    ' + ), + ) + + # ─────────────────── 下载模板 ─────────────────── + + def action_download_template(self): + self.ensure_one() + if not self.question_type: + raise UserError(_('请先选择要导入的题型。')) + spec = _TEMPLATE_SPECS[self.question_type] + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(spec['cols']) + writer.writerow(spec['sample']) + + attachment = self.env['ir.attachment'].sudo().create({ + 'name': '题库导入模板_%s.csv' % spec['label'], + 'datas': base64.b64encode(buf.getvalue().encode('utf-8-sig')), + 'res_model': self._name, + 'res_id': self.id, + 'type': 'binary', + 'mimetype': 'text/csv', + }) + return { + 'type': 'ir.actions.act_url', + 'url': '/web/content/%s?download=true' % attachment.id, + 'target': 'self', + } + + # ─────────────────── 导入 ─────────────────── + + def action_import(self): + self.ensure_one() + if not self.question_type: + raise UserError(_('请先选择要导入的题型。')) + if not self.data_file: + raise UserError(_('请先上传填写好的文件。')) + + content = base64.b64decode(self.data_file) + for encoding in ('utf-8-sig', 'utf-8', 'gbk', 'gb2312'): + try: + text = content.decode(encoding) + break + except UnicodeDecodeError: + continue + else: + raise UserError(_('无法识别文件编码,请使用 UTF-8 或 GBK 编码的 CSV。')) + + reader = csv.DictReader(io.StringIO(text)) + if not reader.fieldnames: + raise UserError(_('文件为空或格式不正确。')) + + col_map = self._normalize_columns(reader.fieldnames) + qtype = self.question_type + + created = 0 + skipped = 0 + errors = [] + Question = self.env['yuthon.school.exam.question'] + + for row_idx, raw in enumerate(reader, start=2): + row = {col_map.get(k, k): (v.strip() if v else '') for k, v in raw.items()} + qtext = row.get('question_text', '').strip() + if not qtext: + skipped += 1 + errors.append(_('第 %d 行:题干为必填,跳过。') % row_idx) + continue + + course_name = row.get('course', '').strip() + if not course_name: + skipped += 1 + errors.append(_('第 %d 行:所属课程为必填,跳过。') % row_idx) + continue + course = self.env['yuthon.school.course'].sudo().search( + [('name', '=', course_name)], limit=1) + if not course: + skipped += 1 + errors.append(_('第 %d 行:找不到课程「%s」,跳过。') % (row_idx, course_name)) + continue + + vals = { + 'question_text': qtext, + 'question_type': qtype, + 'course_id': course.id, + 'teacher_id': self.env.user.id, + 'score': self._parse_float(row.get('score', ''), 1.0), + 'difficulty': _DIFFICULTY_MAP.get(row.get('difficulty', '').strip(), 'medium'), + 'visibility': _VISIBILITY_MAP.get(row.get('visibility', '').strip(), 'private'), + } + title = row.get('title', '').strip() + if title: + vals['name'] = title + chapter_name = row.get('chapter', '').strip() + if chapter_name: + chapter = self.env['yuthon.school.course.chapter'].sudo().search([ + ('course_id', '=', course.id), + ('name', '=', chapter_name), + ], limit=1) + if chapter: + vals['chapter_id'] = chapter.id + explanation = row.get('explanation', '').strip() + if explanation: + vals['explanation'] = explanation + + try: + if qtype in ('single_choice', 'multiple_choice'): + option_cmds, correct_set = self._build_options(row, qtype) + if not option_cmds: + skipped += 1 + errors.append(_('第 %d 行:未填写任何选项,跳过。') % row_idx) + continue + vals['option_ids'] = option_cmds + elif qtype == 'true_false': + ans = row.get('correct', '').strip() + mapped = self._map_bool(ans) + if mapped is None: + skipped += 1 + errors.append(_('第 %d 行:正确答案请填 正确/错误,跳过。') % row_idx) + continue + vals['correct_answer'] = mapped + elif qtype == 'fill_blank': + ans = row.get('correct', '').strip() + if not ans: + skipped += 1 + errors.append(_('第 %d 行:正确答案(标准答案)为必填,跳过。') % row_idx) + continue + vals['correct_answer'] = ans + elif qtype == 'essay': + ref = row.get('reference', '').strip() + if ref: + vals['reference_answer'] = ref + + Question.create(vals) + created += 1 + except Exception as exc: + skipped += 1 + errors.append(_('第 %d 行导入失败:%s') % (row_idx, str(exc))) + + summary = _('导入完成!新建题目:%d,跳过:%d') % (created, skipped) + if errors: + summary += '\n' + _('注意事项:\n') + '\n'.join(errors[:10]) + if len(errors) > 10: + summary += _('\n... 还有 %d 条,详见日志。') % (len(errors) - 10) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('导入结果'), + 'message': summary, + 'type': 'success' if not errors else 'warning', + 'sticky': True, + 'next': {'type': 'ir.actions.act_window_close'}, + }, + } + + # ─────────────────── 辅助 ─────────────────── + + def _build_options(self, row, qtype): + """根据选项列构造 option_ids 命令,并返回正确选项标签集合。""" + correct_raw = row.get('correct', '').strip().upper() + if qtype == 'single_choice': + # 单选只取第一个正确项 + first = next((c for c in correct_raw.replace(',', ',').split(',') if c.strip()), None) + correct_set = {first} if first else set() + else: + correct_set = {c.strip() for c in correct_raw.replace(',', ',').split(',') if c.strip()} + + cmds = [] + for idx in range(5): + content = row.get('option_%s' % chr(97 + idx), '').strip() + if not content: + continue + label = chr(65 + idx) # A,B,C,D,E + cmds.append((0, 0, { + 'label': label, + 'content': content, + 'is_correct': label in correct_set, + })) + return cmds, correct_set + + def _parse_float(self, value, default): + try: + return float(str(value).strip() or default) + except (ValueError, TypeError): + return default + + def _map_bool(self, value): + v = value.strip().lower() + if v in ('正确', 'true', 't', '1', '是', '对'): + return 'True' + if v in ('错误', 'false', 'f', '0', '否', '错'): + return 'False' + return None + + def _normalize_columns(self, fieldnames): + """列名 → 内部 key。模板表头常带括号说明(如「正确答案(填A/B/C/D/E)」), + 故用「包含」匹配而非全等,兼容中英文与 Excel 自定义列名。""" + mapping = {} + for f in fieldnames: + fl = f.strip().lower() + if '题目标题' in fl or fl in ('title', 'name', '标题'): + mapping[f] = 'title' + elif '题干' in fl or fl in ('question', 'text', '题目'): + mapping[f] = 'question_text' + elif '所属课程' in fl or fl in ('course', '课程'): + mapping[f] = 'course' + elif '所属章节' in fl or fl in ('chapter', '章节'): + mapping[f] = 'chapter' + elif '难度' in fl or fl == 'difficulty': + mapping[f] = 'difficulty' + elif '分值' in fl or '分数' in fl or fl == 'score': + mapping[f] = 'score' + elif '可见范围' in fl or fl == 'visibility': + mapping[f] = 'visibility' + elif '参考答案' in fl or fl == 'reference': + mapping[f] = 'reference' + elif '正确答案' in fl or fl in ('correct', 'correct_answer', '答案'): + mapping[f] = 'correct' + elif '答案说明' in fl or '解析' in fl or fl == 'explanation': + mapping[f] = 'explanation' + elif '选项a' in fl or fl in ('a', 'option_a'): + mapping[f] = 'option_a' + elif '选项b' in fl or fl in ('b', 'option_b'): + mapping[f] = 'option_b' + elif '选项c' in fl or fl in ('c', 'option_c'): + mapping[f] = 'option_c' + elif '选项d' in fl or fl in ('d', 'option_d'): + mapping[f] = 'option_d' + elif '选项e' in fl or fl in ('e', 'option_e'): + mapping[f] = 'option_e' + else: + mapping[f] = f + return mapping diff --git a/college/patch_wkhtmltopdf.py b/college/patch_wkhtmltopdf.py new file mode 100644 index 0000000..73cf977 --- /dev/null +++ b/college/patch_wkhtmltopdf.py @@ -0,0 +1,26 @@ +"""wkhtmltopdf 命令行猴子补丁。 + +Odoo 18 base 模块在 `ir.actions_report._build_wkhtmltopdf_args` 里 +显式传入 `['--disable-local-file-access']`(见 odoo/addons/base/models/ir_actions_report.py 第 304 行), +导致 wkhtmltopdf 无法读取本地文件——既包括 @font-face 的 file://,也包括 Qt 从 `C:\Windows\Fonts` +读取系统字体(@font-face 用注册名找字体也走不到文件)。结果是 PDF 中文渲染成豆腐方框。 + +本补丁把 `--disable-local-file-access` 换成 `--enable-local-file-access`,让 wkhtmltopdf 能读到 +中文字体文件,从而正确渲染 PDF 中文。 +""" + +from odoo.addons.base.models import ir_actions_report + +_original_build_args = ir_actions_report.IrActionsReport._build_wkhtmltopdf_args + + +def _patched_build_wkhtmltopdf_args(self, *args, **kwargs): + cmd = _original_build_args(self, *args, **kwargs) + # 把 --disable-local-file-access 替换成 --enable-local-file-access + cmd = [a for a in cmd if a != '--disable-local-file-access'] + if '--enable-local-file-access' not in cmd: + cmd.append('--enable-local-file-access') + return cmd + + +ir_actions_report.IrActionsReport._build_wkhtmltopdf_args = _patched_build_wkhtmltopdf_args \ No newline at end of file diff --git a/college/security/groups.xml b/college/security/groups.xml index 0961089..9ba65d8 100644 --- a/college/security/groups.xml +++ b/college/security/groups.xml @@ -57,4 +57,76 @@ + + + + 题库:老师仅可读取公开或本人题目 + + + ['|', ('visibility', '=', 'public'), ('teacher_id', '=', user.id)] + + + + + + + + + 题库:老师仅可写/删本人题目 + + + [('teacher_id', '=', user.id)] + + + + + + + + + 考试:老师可读取本人发布或监督的考试 + + + ['|', ('teacher_id', '=', user.id), ('supervisor_teacher_ids', 'in', [user.id])] + + + + + + + + + 考试:老师仅可写/删本人发布的考试 + + + [('teacher_id', '=', user.id)] + + + + + + + + + 考试答卷:老师可查看/批改本人发布或监督的答卷 + + + ['|', ('exam_id.teacher_id', '=', user.id), ('exam_id.supervisor_teacher_ids', 'in', [user.id])] + + + + + + + + + 考试答题:老师可查看/批改本人发布或监督的答题记录 + + + ['|', ('paper_id.exam_id.teacher_id', '=', user.id), ('paper_id.exam_id.supervisor_teacher_ids', 'in', [user.id])] + + + + + diff --git a/college/security/ir.model.access.csv b/college/security/ir.model.access.csv index d9ab7a6..de943b0 100644 --- a/college/security/ir.model.access.csv +++ b/college/security/ir.model.access.csv @@ -67,3 +67,9 @@ access_yuthon_school_exam_paper_student,yuthon.school.exam.paper.student,model_y access_yuthon_school_exam_answer_admin,yuthon.school.exam.answer.admin,model_yuthon_school_exam_answer,base.group_system,1,1,1,1 access_yuthon_school_exam_answer_teacher,yuthon.school.exam.answer.teacher,model_yuthon_school_exam_answer,college.group_teacher,1,1,1,1 access_yuthon_school_exam_answer_student,yuthon.school.exam.answer.student,model_yuthon_school_exam_answer,college.group_student,1,1,1,0 +access_yuthon_school_exam_import_wizard_admin,yuthon.school.exam.import.wizard.admin,model_yuthon_school_exam_import_wizard,base.group_system,1,1,1,1 +access_yuthon_school_exam_import_wizard_teacher,yuthon.school.exam.import.wizard.teacher,model_yuthon_school_exam_import_wizard,college.group_teacher,1,1,1,1 +access_yuthon_school_exam_import_wizard_line_admin,yuthon.school.exam.import.wizard.line.admin,model_yuthon_school_exam_import_wizard_line,base.group_system,1,1,1,1 +access_yuthon_school_exam_import_wizard_line_teacher,yuthon.school.exam.import.wizard.line.teacher,model_yuthon_school_exam_import_wizard_line,college.group_teacher,1,1,1,1 +access_yuthon_school_question_import_wizard_admin,yuthon.school.question.import.wizard.admin,model_yuthon_school_question_import_wizard,base.group_system,1,1,1,1 +access_yuthon_school_question_import_wizard_teacher,yuthon.school.question.import.wizard.teacher,model_yuthon_school_question_import_wizard,college.group_teacher,1,1,1,1 diff --git a/college/views/exam_import_wizard_views.xml b/college/views/exam_import_wizard_views.xml new file mode 100644 index 0000000..43ff51b --- /dev/null +++ b/college/views/exam_import_wizard_views.xml @@ -0,0 +1,60 @@ + + + + + yuthon.school.exam.import.wizard.form + yuthon.school.exam.import.wizard + +
    + +
    +

    按章节批量导入题库

    +
    + + + + + + + + + + + + +
    + 选择章节或勾选“导入整门课程题目”后,下方会自动列出匹配的题目; + 取消勾选即可排除不需要导入的题目。 +
    + + + + + + + + + + + +
    +
    +
    +
    +
    +
    + + + + 从题库批量导入题目 + yuthon.school.exam.import.wizard + form + + new + +
    diff --git a/college/views/exam_paper_report.xml b/college/views/exam_paper_report.xml new file mode 100644 index 0000000..8bb2418 --- /dev/null +++ b/college/views/exam_paper_report.xml @@ -0,0 +1,144 @@ + + + + + 答卷(批改结果) + yuthon.school.exam.paper + qweb-pdf + college.exam_paper_report_doc + college.exam_paper_report_doc + False + 答卷_%(name)s + + + + diff --git a/college/views/exam_take_templates.xml b/college/views/exam_take_templates.xml index ac90c3b..fdc9a08 100644 --- a/college/views/exam_take_templates.xml +++ b/college/views/exam_take_templates.xml @@ -2,19 +2,15 @@ + + + + + + diff --git a/college/views/exam_views.xml b/college/views/exam_views.xml index 9e2c86d..d5ff8e9 100644 --- a/college/views/exam_views.xml +++ b/college/views/exam_views.xml @@ -18,6 +18,7 @@ + @@ -35,6 +36,7 @@ + @@ -85,12 +87,15 @@ + + + @@ -114,6 +119,8 @@ decoration-success="state == 'published'" decoration-muted="state == 'closed'" decoration-warning="state == 'draft'"/> + + @@ -128,6 +135,9 @@
    +
    - @@ -228,6 +238,56 @@ class="btn-primary" invisible="state != 'pending'"/>