完善教育

This commit is contained in:
李鹏宇
2026-08-27 19:53:56 +08:00
parent 4c03ef6a90
commit c60acb1984
23 changed files with 2222 additions and 104 deletions
+1
View File
@@ -1,2 +1,3 @@
from . import controllers
from . import models
from . import patch_wkhtmltopdf # noqa: F401 应用 wkhtmltopdf 命令行猴子补丁
+3
View File
@@ -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',
+1
View File
@@ -1 +1,2 @@
from . import exam_controller
from . import paper_pdf_reportlab
+187 -1
View File
@@ -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/<int:paper_id>', 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/<int:paper_id>', 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/<int:paper_id>', 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,
})
+395
View File
@@ -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'</(p|div|br|li|h[1-6]|tr)>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<li>', '• ', 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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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
+5 -2
View File
@@ -16,8 +16,11 @@
10=Productivity(生产力) 11=Calendar(日历)
20=Uncategorized(未分类)
如需保留/恢复某类目,从下面列表中删除对应 id 即可。 -->
<field name="domain">[('category_id', 'not in', [13, 54, 71, 15, 94, 6, 10, 11, 20])]</field>
<field name="context">{'search_default_app': 1}</field>
<!-- 默认只显示「系统原生」的应用,保持原 Apps 看板的横向卡片布局。
用户可通过搜索面板的「本地模块」过滤器切换到本地模块。 -->
<field name="domain">[('module_origin', '=', 'official'), ('application', '=', True), ('category_id', 'not in', [13, 54, 71, 15, 94, 6, 10, 11, 20])]</field>
<field name="context">{}</field>
<field name="search_view_id" ref="shixun_module_search_view"/>
<field name="limit">50</field>
</record>
+2
View File
@@ -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
+1
View File
@@ -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)
+170 -5
View File
@@ -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()
+128
View File
@@ -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)
+39 -16
View File
@@ -86,7 +86,7 @@ class ImportTeacherStudentWizard(models.TransientModel):
'<b>姓名</b>、<b>角色</b>(老师/学生)、<b>手机号</b> 为必填;<br/>'
'<b>班级、学院、性别</b> 为选填;</li>'
'<li>保存为 CSV(逗号分隔)后在此上传并点击「开始导入」;</li>'
'<li>系统将自动创建登录账号(账号=姓名拼音,密码=手机号)并关联师生档案;</li>'
'<li>系统将自动创建登录账号(账号=姓名拼音,<b>学生密码=学号、老师密码=工号</b>)并关联师生档案;</li>'
'<li>若账号已存在则跳过,不会重复创建。</li>'
'</ol>'
),
@@ -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(
+29
View File
@@ -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()
+374
View File
@@ -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: _(
'<p><b>使用步骤:</b></p>'
'<ol>'
'<li>先在上方选择「导入题型」;</li>'
'<li>点击「下载模板」获取该题型专属的 CSV 模板(含一列示例数据);</li>'
'<li>用 Excel / WPS 打开,按列填写:<b>题干</b>、<b>所属课程</b> 为必填;'
'选择题填选项 A~E 并在「正确答案」标出正确项(多选用逗号分隔);'
'判断填 正确/错误;填空填标准答案;简答填参考答案;</li>'
'<li>保存为 CSV(建议 UTF-8)后上传,点击「开始导入」;</li>'
'<li>题目会直接写入题库,出题人默认为当前登录老师,可见范围默认私有。</li>'
'</ol>'
),
)
# ─────────────────── 下载模板 ───────────────────
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
+26
View File
@@ -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
+72
View File
@@ -57,4 +57,76 @@
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- 题库:老师可见“公开”或“本人”的题目(只读他人公开题,不可改动) -->
<record id="rule_exam_question_teacher_visible" model="ir.rule">
<field name="name">题库:老师仅可读取公开或本人题目</field>
<field name="model_id" ref="model_yuthon_school_exam_question"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">['|', ('visibility', '=', 'public'), ('teacher_id', '=', user.id)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- 题库:老师仅能写/删自己出的题目 -->
<record id="rule_exam_question_teacher_own" model="ir.rule">
<field name="name">题库:老师仅可写/删本人题目</field>
<field name="model_id" ref="model_yuthon_school_exam_question"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">[('teacher_id', '=', user.id)]</field>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- 考试:老师可读取本人发布或担任监督的考试 -->
<record id="rule_exam_teacher_read" model="ir.rule">
<field name="name">考试:老师可读取本人发布或监督的考试</field>
<field name="model_id" ref="model_yuthon_school_exam"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">['|', ('teacher_id', '=', user.id), ('supervisor_teacher_ids', 'in', [user.id])]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- 考试:老师仅能写/删本人发布的考试 -->
<record id="rule_exam_teacher_own" model="ir.rule">
<field name="name">考试:老师仅可写/删本人发布的考试</field>
<field name="model_id" ref="model_yuthon_school_exam"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">[('teacher_id', '=', user.id)]</field>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- 答卷:老师可查看/批改本人发布或监督的考试答卷 -->
<record id="rule_exam_paper_teacher_visible" model="ir.rule">
<field name="name">考试答卷:老师可查看/批改本人发布或监督的答卷</field>
<field name="model_id" ref="model_yuthon_school_exam_paper"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">['|', ('exam_id.teacher_id', '=', user.id), ('exam_id.supervisor_teacher_ids', 'in', [user.id])]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- 答题记录:老师可查看/批改本人发布或监督的考试答题记录 -->
<record id="rule_exam_answer_teacher_visible" model="ir.rule">
<field name="name">考试答题:老师可查看/批改本人发布或监督的答题记录</field>
<field name="model_id" ref="model_yuthon_school_exam_answer"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">['|', ('paper_id.exam_id.teacher_id', '=', user.id), ('paper_id.exam_id.supervisor_teacher_ids', 'in', [user.id])]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>
+6
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
67 access_yuthon_school_exam_answer_admin yuthon.school.exam.answer.admin model_yuthon_school_exam_answer base.group_system 1 1 1 1
68 access_yuthon_school_exam_answer_teacher yuthon.school.exam.answer.teacher model_yuthon_school_exam_answer college.group_teacher 1 1 1 1
69 access_yuthon_school_exam_answer_student yuthon.school.exam.answer.student model_yuthon_school_exam_answer college.group_student 1 1 1 0
70 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
71 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
72 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
73 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
74 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
75 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
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===================== 批量导入题库向导:表单 ===================== -->
<record id="yuthon_school_exam_import_wizard_view_form" model="ir.ui.view">
<field name="name">yuthon.school.exam.import.wizard.form</field>
<field name="model">yuthon.school.exam.import.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_title">
<h2>按章节批量导入题库</h2>
</div>
<group>
<group>
<field name="exam_id"/>
<field name="course_id"/>
<field name="import_all_course"/>
</group>
<group>
<field name="chapter_ids" widget="many2many_tags"
invisible="import_all_course"
options="{'no_create': True}"/>
<field name="question_type"/>
<field name="exclude_existing"/>
</group>
</group>
<div class="text-muted mb-2">
选择章节或勾选“导入整门课程题目”后,下方会自动列出匹配的题目;
取消勾选即可排除不需要导入的题目。
</div>
<field name="line_ids" options="{'no_create': True}">
<list editable="bottom">
<field name="selected"/>
<field name="sequence" widget="handle"/>
<field name="question_text_short" string="题干"/>
<field name="question_type"/>
<field name="chapter_id"/>
<field name="visibility"/>
<field name="score"/>
</list>
</field>
</sheet>
<footer>
<button name="action_confirm_import" type="object"
string="确认导入" class="oe_highlight"/>
<button special="cancel" string="取消" class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<!-- ===================== 批量导入题库向导:动作 ===================== -->
<record id="action_yuthon_school_exam_import_wizard" model="ir.actions.act_window">
<field name="name">从题库批量导入题目</field>
<field name="res_model">yuthon.school.exam.import.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="yuthon_school_exam_import_wizard_view_form"/>
<field name="target">new</field>
</record>
</odoo>
File diff suppressed because one or more lines are too long
+333 -30
View File
@@ -2,19 +2,15 @@
<odoo>
<!-- ============================================================
学生答题卡式考试页面模板
学生答题卡式考试页面模板(自包含 HTML,不依赖 website/portal)
============================================================ -->
<template id="exam_take_page" name="考试答题页面">
<t t-call="web.frontend_layout">
<t t-set="html_data" t-value="{'style': 'height: 100%;'}"/>
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<t t-set="head">
<title><t t-esc="exam.name"/> - 在线考试</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</t>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title><t t-esc="exam.name"/> - 在线考试</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>
:root {
--exam-primary: #2563eb;
@@ -68,6 +64,7 @@
border-radius: 8px;
}
.exam-timer.warning { color: var(--exam-danger); background: #fef2f2; }
.exam-timer-label { font-size: 13px; font-weight: 600; }
.exam-container {
display: flex;
gap: 16px;
@@ -277,7 +274,8 @@
.exam-header { flex-direction: column; align-items: flex-start; gap: 10px; }
}
</style>
</head>
<body>
<div class="exam-header">
<div class="exam-header-left">
<h1><t t-esc="exam.name"/></h1>
@@ -290,7 +288,7 @@
</div>
</div>
<div class="exam-timer" id="exam-timer" t-att-data-seconds="remaining_seconds">
<i class="fa fa-clock-o"/>
<span class="exam-timer-label">剩余时间</span>
<span id="timer-text">--:--:--</span>
</div>
</div>
@@ -368,7 +366,7 @@
if (ans.question_type === 'single_choice') return !!ans.selected_option_id;
if (ans.question_type === 'multiple_choice') return (ans.selected_option_ids || []).length > 0;
if (ans.question_type === 'true_false') return !!ans.answer_choice;
return !!(ans.answer_text &amp;&amp; stripHtml(ans.answer_text).trim());
return !!(ans.answer_text && stripHtml(ans.answer_text).trim());
}
function renderQuestion(idx) {
currentIdx = idx;
@@ -484,7 +482,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: payload, id: Math.random() })
}).then(function(r) { return r.json(); }).then(function(res) {
if (res.result &amp;&amp; res.result.status === 'ok') {
if (res.result && res.result.status === 'ok') {
// 静默保存,不打扰
}
}).catch(function() {
@@ -511,10 +509,10 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: { paper_id: paperId }, id: Math.random() })
}).then(function(r) { return r.json(); }).then(function(res) {
if (res.result &amp;&amp; res.result.status === 'ok') {
if (res.result && res.result.status === 'ok') {
window.location.href = res.result.redirect;
} else {
var err = (res.result &amp;&amp; res.result.message) || '提交失败';
var err = (res.result && res.result.message) || '提交失败';
alert(err);
}
}).catch(function() { alert('提交失败,请检查网络后重试。'); });
@@ -561,18 +559,17 @@
})();
//]]>
</script>
</t>
</body>
</html>
</template>
<!-- 结果页面 -->
<template id="exam_result_page" name="考试结果页面">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<t t-set="head">
<title><t t-esc="exam.name"/> - 考试结果</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</t>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title><t t-esc="exam.name"/> - 考试结果</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>
body { background:#f3f4f6; margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC","Microsoft YaHei",sans-serif; }
.result-wrap { max-width:900px; margin:40px auto; padding:0 20px; }
@@ -597,6 +594,8 @@
.result-pending { color:#b45309; font-weight:600; }
.result-btn { display:inline-block; margin-top:20px; padding:10px 24px; background:#2563eb; color:#fff; border-radius:8px; text-decoration:none; font-weight:600; }
</style>
</head>
<body>
<div class="result-wrap">
<div class="result-card">
<h1 class="result-title"><t t-esc="exam.name"/></h1>
@@ -642,18 +641,244 @@
</div>
</div>
</t>
<a t-att-href="'/exam/download/%s' % paper.id" class="result-btn" style="background:#16a34a;">下载试卷 PDF</a>
<a href="/web" class="result-btn">返回首页</a>
</div>
</div>
</t>
</body>
</html>
</template>
<!-- 老师评阅页面:仿学生结果页,在简答题上直接打分 -->
<template id="exam_review_page" name="老师评阅页面">
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title><t t-esc="exam.name"/> - 老师评阅</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>
body { background:#f3f4f6; margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC","Microsoft YaHei",sans-serif; }
.result-wrap { max-width:900px; margin:40px auto; padding:0 20px; }
.result-card { background:#fff; border-radius:16px; padding:32px; box-shadow:0 2px 8px rgba(0,0,0,0.06); margin-bottom:20px; }
.result-title { font-size:22px; font-weight:700; margin:0 0 8px; }
.result-sub { color:#6b7280; font-size:14px; margin-bottom:20px; }
.result-score { display:flex; align-items:center; gap:20px; }
.score-circle { width:110px; height:110px; border-radius:50%; display:flex; flex-direction:column; align-items:center; justify-content:center; font-weight:700; }
.score-circle.pass { background:#dcfce7; color:#15803d; }
.score-circle.fail { background:#fee2e2; color:#b91c1c; }
.score-circle .num { font-size:34px; line-height:1; }
.score-circle .label { font-size:13px; margin-top:4px; }
.result-meta { flex:1; }
.result-meta-row { display:flex; gap:30px; font-size:14px; margin-bottom:8px; }
.result-answer { border-top:1px solid #e5e7eb; padding-top:18px; margin-top:18px; }
.result-answer-title { font-weight:700; margin-bottom:12px; }
.result-question { background:#f9fafb; border-radius:10px; padding:16px; margin-bottom:12px; }
.result-question-header { display:flex; gap:10px; align-items:center; margin-bottom:8px; font-size:14px; flex-wrap:wrap; }
.result-tag { background:#e0e7ff; color:#3730a3; font-size:12px; padding:2px 8px; border-radius:10px; }
.result-correct { color:#15803d; font-weight:600; }
.result-wrong { color:#b91c1c; font-weight:600; }
.result-pending { color:#b45309; font-weight:600; }
.result-btn { display:inline-block; margin-top:20px; padding:10px 24px; background:#2563eb; color:#fff; border-radius:8px; text-decoration:none; font-weight:600; border:none; cursor:pointer; }
.review-badge { display:inline-block; background:#fef3c7; color:#92400e; font-size:12px; padding:4px 10px; border-radius:20px; margin-left:auto; font-weight:600; }
.review-grade-box { margin-top:14px; padding:14px; background:#fff; border:1px solid #e5e7eb; border-radius:8px; }
.review-grade-row { display:flex; align-items:center; gap:10px; margin-bottom:10px; flex-wrap:wrap; }
.review-grade-row label { font-weight:600; color:#374151; min-width:48px; }
.review-score-input { width:90px; padding:8px 10px; border:1px solid #d1d5db; border-radius:6px; font-size:14px; }
.review-comment-input { flex:1; min-width:200px; padding:8px 10px; border:1px solid #d1d5db; border-radius:6px; font-size:14px; font-family:inherit; }
.review-save-btn { padding:7px 14px; background:#2563eb; color:#fff; border:none; border-radius:6px; font-size:13px; cursor:pointer; }
.review-save-btn:hover { background:#1d4ed8; }
.review-save-btn:disabled { opacity:.6; cursor:default; }
.review-msg { margin-left:10px; font-size:13px; color:#15803d; }
.review-reference { margin-top:10px; padding:10px; background:#f3f4f6; border-radius:6px; font-size:13px; color:#374151; }
.review-readonly-box { margin-top:10px; padding:10px; background:#fffbeb; border:1px solid #fde68a; border-radius:6px; font-size:14px; color:#374151; }
.review-finish-area { text-align:center; margin-top:24px; }
.review-finish-btn { padding:12px 32px; background:#16a34a; color:#fff; border:none; border-radius:8px; font-size:15px; font-weight:600; cursor:pointer; }
.review-finish-btn:hover { background:#15803d; }
.review-finish-btn:disabled { opacity:.6; }
</style>
</head>
<body>
<div class="result-wrap">
<div class="result-card">
<h1 class="result-title"><t t-esc="exam.name"/></h1>
<div class="result-sub">
课程:<t t-esc="paper.course_id.name or '-'"/> | 学员:<t t-esc="paper.student_id.name or '-'"/>
</div>
<div class="result-score">
<div t-att-class="'score-circle ' + ('pass' if paper.is_passed else 'fail')">
<span class="num"><t t-esc="paper.score"/></span>
<span class="label">/<t t-esc="paper.total_score"/> 分</span>
</div>
<div class="result-meta">
<div class="result-meta-row"><strong>是否及格:</strong><t t-esc="'及格' if paper.is_passed else '不及格'"/></div>
<div class="result-meta-row"><strong>及格分:</strong><t t-esc="paper.pass_score"/> 分</div>
<div class="result-meta-row"><strong>状态:</strong><t t-esc="dict(paper._fields['state'].selection).get(paper.state)"/></div>
</div>
<span class="review-badge">
<t t-if="readonly">已评阅</t>
<t t-elif="paper.state == 'graded'">修改评阅</t>
<t t-else="">老师评阅中</t>
</span>
</div>
</div>
<div class="result-card">
<div class="result-answer-title">答题详情</div>
<t t-foreach="answers" t-as="ans">
<div class="result-question" t-att-data-answer-id="ans.id">
<div class="result-question-header">
<span>第 <t t-esc="ans_index + 1"/> 题</span>
<span class="result-tag"><t t-esc="question_types.get(ans.question_type, ans.question_type)"/></span>
<span class="answer-score"><t t-esc="ans.score"/> / <t t-esc="ans.max_score"/> 分</span>
<t t-if="ans.question_type != 'essay'">
<span t-att-class="'result-' + ('correct' if ans.is_correct else 'wrong')">
<t t-esc="'正确' if ans.is_correct else '错误'"/>
</span>
</t>
<t t-else="">
<span class="result-pending">简答题</span>
</t>
</div>
<div t-raw="ans.question_text"/>
<div style="margin-top:8px; color:#374151;">
<strong>学生作答:</strong><t t-esc="ans.answer_display or '未作答'"/>
</div>
<div t-if="ans.reference_answer" class="review-reference">
<strong>参考答案:</strong>
<div t-raw="ans.reference_answer"/>
</div>
<div t-if="ans.explanation" style="margin-top:10px; padding:10px; background:#fff; border-radius:6px;">
<strong>答案说明:</strong>
<div t-raw="ans.explanation"/>
</div>
<t t-if="ans.question_type == 'essay' and readonly">
<t t-if="ans.teacher_comment">
<div class="review-readonly-box">
<strong>评语:</strong><t t-esc="ans.teacher_comment"/>
</div>
</t>
</t>
<t t-if="ans.question_type == 'essay' and not readonly">
<div class="review-grade-box">
<div class="review-grade-row">
<label>得分</label>
<input type="number" class="review-score-input" min="0" step="0.1"
t-att-max="ans.max_score"
t-att-value="ans.score or 0"
t-att-data-answer-id="ans.id"/>
<span>/ <t t-esc="ans.max_score"/> 分</span>
</div>
<div class="review-grade-row">
<label>评语</label>
<textarea class="review-comment-input" rows="2" t-att-data-answer-id="ans.id"><t t-esc="ans.teacher_comment or ''"/></textarea>
</div>
</div>
</t>
</div>
</t>
<div class="review-finish-area" t-if="not readonly">
<button type="button" id="btn-finish-review" class="review-finish-btn"
t-att-data-paper-id="paper.id" t-att-data-paper-state="paper.state">
<t t-if="paper.state == 'graded'">保存修改</t>
<t t-else="">完成评阅</t>
</button>
<div id="review-finish-msg" style="margin-top:12px; font-size:14px; font-weight:600;"></div>
</div>
<div style="margin-top:20px;">
<a t-att-href="'/exam/review/download/%s' % paper.id" class="result-btn" style="background:#16a34a;">下载试卷 PDF</a>
<a href="/web" class="result-btn">返回首页</a>
</div>
</div>
</div>
<script type="text/javascript">
//<![CDATA[
(function() {
var finishBtn = document.getElementById('btn-finish-review');
if (!finishBtn) return; // 只读模式无此按钮,无需绑定事件
var paperId = parseInt(finishBtn.dataset.paperId, 10) || 0;
var originalBtnText = finishBtn.textContent || '完成评阅';
var readonlyAfterSubmit = finishBtn.dataset.paperState !== 'graded'; // 首次评阅完需跳只读页
var msgEl = document.getElementById('review-finish-msg');
function showMsg(text, color) {
if (!msgEl) return;
msgEl.textContent = text;
msgEl.style.color = color || '#15803d';
}
function postJson(url, payload, cb) {
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: payload, id: Math.random() })
}).then(function(r) { return r.json(); }).then(function(res) {
cb(res.result || res);
}).catch(function() { cb({status:'error', message:'网络异常'}); });
}
finishBtn.addEventListener('click', function() {
var items = [];
var hasError = false;
document.querySelectorAll('.result-question[data-answer-id]').forEach(function(box) {
if (hasError) return;
var scoreInput = box.querySelector('.review-score-input');
if (!scoreInput) return;
var answerId = parseInt(scoreInput.dataset.answerId, 10);
var scoreRaw = scoreInput.value.trim();
var score = scoreRaw === '' ? '' : parseFloat(scoreRaw);
var maxScore = parseFloat(scoreInput.max) || 0;
if (scoreRaw !== '' && (isNaN(score) || score < 0 || score > maxScore)) {
var hdr = box.querySelector('.result-question-header span');
showMsg((hdr ? hdr.textContent : ('题目 ' + answerId)) + ' 得分不合法,请输入 0 ~ ' + maxScore + ' 之间的数字', '#b91c1c');
hasError = true;
return;
}
var commentInput = box.querySelector('.review-comment-input');
items.push({
answer_id: answerId,
score: scoreRaw === '' ? '' : score,
comment: commentInput ? commentInput.value : ''
});
});
if (hasError) return;
finishBtn.disabled = true;
finishBtn.textContent = '保存中...';
showMsg('正在保存评分...', '#6b7280');
postJson('/exam/finish_review', { paper_id: paperId, answers: items }, function(res) {
finishBtn.disabled = false;
if (res.status === 'ok') {
if (readonlyAfterSubmit) {
// 首次评阅完成:提示后跳转到只读结果页
showMsg('评阅完成,总得分:' + res.paper_score + ' 分(已归档)', '#15803d');
finishBtn.textContent = '已评阅';
finishBtn.disabled = true;
setTimeout(function() {
window.location.href = '/exam/review/' + paperId + '?mode=readonly';
}, 900);
} else {
// 重新评阅(已评阅状态保存修改):不刷新、不禁用,保持「保存修改」入口,
// 老师可继续改分并再次保存;仅提示成功,避免按钮在「已评阅/保存修改」间闪烁
var total = (res.paper_score != null) ? res.paper_score : '';
showMsg('评阅修改已保存' + (total !== '' ? (',总得分:' + total + ' 分') : '') + '(已归档)', '#15803d');
finishBtn.textContent = '保存修改';
}
} else {
finishBtn.textContent = originalBtnText;
showMsg(res.message || '完成评阅失败', '#b91c1c');
}
});
});
})();
//]]>
</script>
</body>
</html>
</template>
<!-- 错误提示页 -->
<template id="exam_error_page" name="考试错误提示">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<t t-set="head"><title>提示</title></t>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>提示</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>
body { background:#f3f4f6; margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC","Microsoft YaHei",sans-serif; }
.err-wrap { max-width:480px; margin:80px auto; background:#fff; border-radius:16px; padding:40px; text-align:center; box-shadow:0 2px 8px rgba(0,0,0,0.06); }
@@ -661,12 +886,90 @@
.err-msg { font-size:16px; color:#374151; margin-bottom:24px; }
.err-btn { display:inline-block; padding:10px 24px; background:#2563eb; color:#fff; border-radius:8px; text-decoration:none; }
</style>
</head>
<body>
<div class="err-wrap">
<div class="err-icon">⚠️</div>
<div class="err-msg"><t t-esc="message"/></div>
<a href="/web" class="err-btn">返回首页</a>
</div>
</t>
</body>
</html>
</template>
<!-- 批量下载引导页:列出有权下载的答卷,前端依次触发每份独立 PDF 下载 -->
<template id="exam_batch_download_page" name="考试答卷批量下载">
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>正在下载答卷…</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>
body { background:#f3f4f6; margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC","Microsoft YaHei",sans-serif; }
.wrap { max-width:520px; margin:80px auto; background:#fff; border-radius:16px; padding:36px 40px; text-align:center; box-shadow:0 2px 8px rgba(0,0,0,0.06); }
.icon { font-size:44px; margin-bottom:12px; }
.title { font-size:18px; color:#111827; margin-bottom:8px; font-weight:600; }
.sub { font-size:14px; color:#6b7280; margin-bottom:20px; }
.bar { height:8px; background:#e5e7eb; border-radius:99px; overflow:hidden; }
.bar > i { display:block; height:100%; width:0; background:linear-gradient(90deg,#2563eb,#22d3ee); transition:width .25s ease; }
.tip { font-size:13px; color:#9ca3af; margin-top:14px; }
.fallback { display:none; margin-top:24px; text-align:left; }
.fallback h3 { font-size:15px; color:#111827; margin:0 0 12px; font-weight:600; }
.fallback ul { margin:0; padding-left:18px; line-height:2; }
.fallback li a { color:#2563eb; text-decoration:none; }
.fallback li a:hover { text-decoration:underline; }
</style>
</head>
<body>
<div class="wrap">
<div class="icon">📥</div>
<div class="title">正在逐份下载 <t t-esc="count"/> 份答卷</div>
<div class="sub">浏览器会依次弹出 <t t-esc="count"/> 个 PDF 文件,如未弹出请在下载日志中查看。</div>
<div class="bar"><i id="pg"></i></div>
<div class="tip" id="tip">准备中…</div>
<div class="fallback" id="fallback">
<h3>如果自动下载未开始,请逐份点击下方链接:</h3>
<ul>
<t t-foreach="papers" t-as="p">
<li><a t-att-href="p['url']" t-att-download="p['name'] + '.pdf'">
<t t-esc="p['name']"/> (#<t t-esc="p['id']"/>)
</a></li>
</t>
</ul>
</div>
</div>
<script>
(function () {
var papers = <t t-out="json.dumps(papers)"/>;
var i = 0;
var pg = document.getElementById('pg');
var tip = document.getElementById('tip');
var fallback = document.getElementById('fallback');
function next() {
if (i >= papers.length) {
pg.style.width = '100%';
tip.textContent = '已发送全部下载请求,请检查浏览器下载目录。';
fallback.style.display = 'block';
return;
}
tip.textContent = '正在下载第 ' + (i + 1) + ' / ' + papers.length + ' 份…';
// 用隐藏 iframe 触发 Content-Disposition: attachment 下载,避免被浏览器当弹窗拦截
var f = document.createElement('iframe');
f.style.display = 'none';
f.src = papers[i].url;
document.body.appendChild(f);
// 短暂保留后移除 iframe
setTimeout(function () { if (f.parentNode) f.parentNode.removeChild(f); }, 6000);
i++;
pg.style.width = Math.round((i / papers.length) * 100) + '%';
// 间隔触发,避免浏览器合并/拦截多文件下载
setTimeout(next, 1500);
}
next();
})();
</script>
</body>
</html>
</template>
</odoo>
+117 -26
View File
@@ -18,6 +18,7 @@
<field name="chapter_id" optional="show"/>
<field name="score"/>
<field name="teacher_id" optional="hide"/>
<field name="visibility" optional="show"/>
</list>
</field>
</record>
@@ -35,6 +36,7 @@
<field name="question_type"/>
<field name="difficulty"/>
<field name="score"/>
<field name="visibility"/>
</group>
<group>
<field name="course_id"/>
@@ -85,12 +87,15 @@
<field name="course_id"/>
<field name="chapter_id"/>
<filter name="mine" string="我的题目" domain="[('teacher_id','=',uid)]"/>
<filter name="public" string="公开题库" domain="[('visibility','=','public')]"/>
<filter name="private" string="私有题库" domain="[('visibility','=','private')]"/>
<filter name="single_choice" string="单选题" domain="[('question_type','=','single_choice')]"/>
<filter name="multiple_choice" string="多选题" domain="[('question_type','=','multiple_choice')]"/>
<filter name="true_false" string="判断题" domain="[('question_type','=','true_false')]"/>
<filter name="fill_blank" string="填空题" domain="[('question_type','=','fill_blank')]"/>
<filter name="essay" string="简答题" domain="[('question_type','=','essay')]"/>
<group expand="0" string="分组">
<filter name="group_chapter" string="按章节" context="{'group_by':'chapter_id'}"/>
<filter name="group_course" string="按课程" context="{'group_by':'course_id'}"/>
<filter name="group_type" string="按题型" context="{'group_by':'question_type'}"/>
</group>
@@ -114,6 +119,8 @@
decoration-success="state == 'published'"
decoration-muted="state == 'closed'"
decoration-warning="state == 'draft'"/>
<field name="teacher_id" optional="hide"/>
<field name="supervisor_teacher_ids" widget="many2many_tags" string="监督老师" optional="hide"/>
<field name="paper_count" string="答卷"/>
<field name="submitted_count" string="已交"/>
<field name="graded_count" string="已阅"/>
@@ -128,6 +135,9 @@
<field name="arch" type="xml">
<form>
<header>
<button name="action_import_from_question_bank" type="object"
string="按章节批量导入"
class="btn-secondary" invisible="state != 'draft'"/>
<button name="action_publish" type="object" string="发布考试"
class="oe_highlight" invisible="state != 'draft'"/>
<button name="action_close" type="object" string="截止考试"
@@ -148,6 +158,8 @@
<field name="course_id"/>
<field name="chapter_ids" widget="many2many_tags"/>
<field name="teacher_id" readonly="1"/>
<field name="supervisor_teacher_ids" widget="many2many_tags"
options="{'no_create': True}"/>
</group>
<group>
<field name="start_time"/>
@@ -170,15 +182,9 @@
</field>
</page>
<page string="答卷" name="papers">
<field name="paper_ids" readonly="1">
<list>
<field name="name"/>
<field name="student_id"/>
<field name="state" widget="badge"/>
<field name="score"/>
<field name="total_score"/>
<field name="is_passed" widget="boolean_toggle" readonly="1"/>
</list>
<field name="paper_ids" readonly="1"
options="{'no_create': True, 'no_delete': True}"
context="{'tree_view_ref': 'college.yuthon_school_exam_paper_view_tree_teacher'}">
</field>
</page>
</notebook>
@@ -197,6 +203,7 @@
<field name="name"/>
<field name="course_id"/>
<filter name="mine" string="我发布的" domain="[('teacher_id','=',uid)]"/>
<filter name="supervised" string="我监督的" domain="[('supervisor_teacher_ids','in',[uid])]"/>
<filter name="published" string="已发布" domain="[('state','=','published')]"/>
<filter name="closed" string="已截止" domain="[('state','=','closed')]"/>
<filter name="draft" string="草稿" domain="[('state','=','draft')]"/>
@@ -214,11 +221,14 @@
<field name="model">yuthon.school.exam.paper</field>
<field name="arch" type="xml">
<list decoration-success="is_passed == True" decoration-danger="is_passed == False">
<field name="name" string="考试/学员"/>
<header>
<button name="action_download_report" type="object" string="批量下载" icon="fa-download"
class="btn-secondary"
groups="college.group_student"/>
</header>
<field name="exam_id" string="考试名称"/>
<field name="course_id" string="所属课程" optional="show"/>
<field name="chapter_ids" widget="many2many_tags" string="关联章节" optional="show"/>
<field name="student_id" optional="show"/>
<field name="state" widget="badge"/>
<field name="score"/>
<field name="total_score"/>
@@ -228,6 +238,56 @@
class="btn-primary" invisible="state != 'pending'"/>
<button name="action_open_take_page" type="object" string="继续考试" icon="fa-play-circle"
class="btn-info" invisible="state != 'in_progress'"/>
<button name="action_open_result_view_page" type="object" string="查看" icon="fa-eye"
class="btn-primary" invisible="state != 'graded'"/>
</list>
</field>
</record>
<!-- ===================== 答卷:老师评阅列表(只显示待评阅) ===================== -->
<record id="yuthon_school_exam_paper_view_tree_teacher" model="ir.ui.view">
<field name="name">yuthon.school.exam.paper.tree.teacher</field>
<field name="model">yuthon.school.exam.paper</field>
<field name="arch" type="xml">
<list decoration-success="is_passed == True" decoration-danger="is_passed == False">
<field name="name" string="答卷标题"/>
<field name="student_id"/>
<field name="state" widget="badge"/>
<field name="score"/>
<field name="total_score"/>
<field name="is_passed"/>
<field name="submit_time" optional="show"/>
<button name="action_open_review_page" type="object" string="评阅" icon="fa-pencil-square-o"
class="btn-primary" invisible="state != 'submitted'"
groups="college.group_teacher,base.group_system"/>
</list>
</field>
</record>
<!-- ===================== 答卷:考试结果列表(只显示已评阅) ===================== -->
<record id="yuthon_school_exam_paper_view_tree_result" model="ir.ui.view">
<field name="name">yuthon.school.exam.paper.tree.result</field>
<field name="model">yuthon.school.exam.paper</field>
<field name="arch" type="xml">
<list decoration-success="is_passed == True" decoration-danger="is_passed == False">
<header>
<button name="action_download_report" type="object" string="下载" icon="fa-download"
class="btn-secondary"
groups="college.group_teacher,base.group_system"/>
</header>
<field name="name" string="答卷标题"/>
<field name="student_id"/>
<field name="state" widget="badge"/>
<field name="score"/>
<field name="total_score"/>
<field name="is_passed"/>
<field name="submit_time" optional="show"/>
<button name="action_open_result_view_page" type="object" string="查看" icon="fa-eye"
class="btn-primary" invisible="state != 'graded'"
groups="college.group_teacher,base.group_system"/>
<button name="action_open_review_page" type="object" string="修改" icon="fa-pencil-square-o"
class="btn-warning" invisible="state != 'graded'"
groups="college.group_teacher,base.group_system"/>
</list>
</field>
</record>
@@ -239,8 +299,16 @@
<field name="arch" type="xml">
<form>
<header>
<button name="action_grade" type="object" string="自动评阅"
<button name="action_open_review_page" type="object" string="进入评阅页"
class="oe_highlight" invisible="state != 'submitted'"/>
<button name="action_open_result_view_page" type="object" string="查看评阅结果"
class="oe_highlight" invisible="state != 'graded'"/>
<button name="action_open_review_page" type="object" string="修改评阅"
class="btn-warning" invisible="state != 'graded'"/>
<button name="action_save_grade" type="object" string="保存评分"
class="btn-secondary" invisible="state not in ('submitted','graded')"/>
<button name="action_download_report" type="object" string="下载试卷"
class="btn-secondary" invisible="state not in ('submitted','graded')"/>
<field name="state" widget="statusbar" statusbar_visible="pending,in_progress,submitted,graded"/>
</header>
<sheet>
@@ -263,15 +331,18 @@
</group>
</group>
<field name="answer_ids" readonly="0" mode="list">
<list>
<field name="sequence" invisible="1"/>
<field name="question_text_short" string="题干"/>
<field name="question_type"/>
<field name="answer_display" string="学生作答"/>
<field name="score"/>
<field name="max_score"/>
<field name="is_correct"/>
<button name="action_view_answer_form" type="object" string="评阅" icon="fa-pencil"/>
<list editable="bottom">
<field name="sequence" invisible="1" readonly="1"/>
<field name="question_text_short" string="题干" readonly="1"/>
<field name="question_type" readonly="1"/>
<field name="answer_display" string="学生作答" readonly="1"/>
<field name="score" groups="college.group_teacher,base.group_system"/>
<field name="teacher_comment" widget="text" string="评语"
groups="college.group_teacher,base.group_system"/>
<field name="max_score" readonly="1"/>
<field name="is_correct" readonly="1"/>
<button name="action_view_answer_form" type="object" string="详情" icon="fa-eye"
groups="college.group_teacher,base.group_system"/>
</list>
</field>
</sheet>
@@ -291,6 +362,10 @@
<button name="action_submit" type="object" string="提交答卷"
class="oe_highlight" invisible="state != 'in_progress'"
confirm="提交后不能再修改,是否继续?"/>
<button name="action_open_result_view_page" type="object" string="查看评阅结果"
class="oe_highlight" invisible="state != 'graded'"/>
<button name="action_download_report" type="object" string="下载试卷"
class="btn-secondary" invisible="state != 'graded'"/>
<field name="state" widget="statusbar" statusbar_visible="pending,in_progress,submitted,graded"/>
</header>
<sheet>
@@ -435,7 +510,7 @@
<field name="res_model">yuthon.school.exam.question</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="yuthon_school_exam_question_view_search"/>
<field name="context">{'search_default_mine': 1}</field>
<field name="context">{'search_default_group_chapter': 1}</field>
</record>
<record id="action_yuthon_school_exam" model="ir.actions.act_window">
@@ -450,16 +525,26 @@
<field name="name">评阅试卷</field>
<field name="res_model">yuthon.school.exam.paper</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="yuthon_school_exam_paper_form"/>
<field name="view_id" ref="yuthon_school_exam_paper_view_tree_teacher"/>
<field name="view_ids" eval="[
(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_exam_paper_view_tree_teacher')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_exam_paper_form')}),
]"/>
<field name="search_view_id" ref="yuthon_school_exam_paper_view_search"/>
<field name="domain">[('state','in',['submitted','graded'])]</field>
<field name="domain">[('state','=','submitted')]</field>
</record>
<record id="action_yuthon_school_exam_paper_student" model="ir.actions.act_window">
<field name="name">我的考试</field>
<field name="res_model">yuthon.school.exam.paper</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="yuthon_school_exam_paper_form_student"/>
<field name="view_id" ref="yuthon_school_exam_paper_view_tree"/>
<field name="view_ids" eval="[
(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_exam_paper_view_tree')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_exam_paper_form_student')}),
]"/>
<field name="search_view_id" ref="yuthon_school_exam_paper_view_search"/>
<field name="domain">[('user_id','=',uid)]</field>
<field name="context">{'search_default_mine': 1}</field>
@@ -469,7 +554,12 @@
<field name="name">考试结果</field>
<field name="res_model">yuthon.school.exam.paper</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="yuthon_school_exam_paper_form"/>
<field name="view_id" ref="yuthon_school_exam_paper_view_tree_result"/>
<field name="view_ids" eval="[
(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_exam_paper_view_tree_result')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_exam_paper_form')}),
]"/>
<field name="search_view_id" ref="yuthon_school_exam_paper_view_search"/>
<field name="domain">[('state','=','graded')]</field>
</record>
@@ -482,4 +572,5 @@
<field name="search_view_id" ref="yuthon_school_exam_answer_view_search"/>
<field name="domain">[('paper_id.state','=','submitted')]</field>
</record>
</odoo>
+85 -24
View File
@@ -40,13 +40,20 @@ else:
]]></field>
</record>
<!-- ===================== 我的课程(统一菜单,老师/学生/管理员都看到同一个入口) ===================== -->
<!-- ===================== 我的课程(学生端父菜单;老师/管理员入口见下方 menu_yuthon_course_my_teacher) ===================== -->
<menuitem id="menu_yuthon_course_my"
name="我的课程"
web_icon="college,static/src/img/my_course.png"
action="action_yuthon_school_course_router"
sequence="3"
groups="college.group_teacher,college.group_student,base.group_system"/>
groups="college.group_student"/>
<!-- 老师/管理员端的「我的课程」入口(我教的课程),与学生端父菜单分离,避免角色路由歧义 -->
<menuitem id="menu_yuthon_course_my_teacher"
name="我的课程"
web_icon="college,static/src/img/my_course.png"
action="action_yuthon_school_course_my"
sequence="4"
groups="college.group_teacher,base.group_system"/>
<!-- ===================== 隐藏 Odoo 默认「讨论」菜单 ===================== -->
<record id="mail.menu_root_discuss" model="ir.ui.menu">
@@ -147,12 +154,6 @@ else:
</field>
</record>
<!-- ===================== 教育(根) ===================== -->
<menuitem id="menu_yuthon_education_root"
name="学习管理"
web_icon="college,static/src/img/education.png"
sequence="5"/>
<!-- ===================== 课程管理(直接指向全部课程的 action,不再有子菜单) ===================== -->
<menuitem id="menu_yuthon_course_root"
name="课程管理"
@@ -197,48 +198,67 @@ else:
sequence="50"
groups="college.group_student"/>
<!-- 我的学习:学生端专用(简单表单 + 去学习按钮) -->
<!-- 课程学习:原「我的学习」迁移为学生端「我的课程」的子菜单 -->
<menuitem id="menu_yuthon_course_study"
name="我的学习"
web_icon="college,static/src/img/course_study.png"
name="课程学习"
parent="menu_yuthon_course_my"
action="action_yuthon_school_course_study"
sequence="2"
sequence="10"
groups="college.group_student"/>
<!-- ===================== 师生管理(顶级菜单,与「教育」平级,仅老师/管理员) ===================== -->
<!-- 课程管理:原学生端「我的课程」迁移为学生端「我的课程」的子菜单 -->
<menuitem id="menu_yuthon_course_my_student"
name="课程管理"
parent="menu_yuthon_course_my"
action="action_yuthon_school_my_course_student"
sequence="20"
groups="college.group_student"/>
<!-- ===================== 班级管理(顶级菜单,仅老师/管理员;下含「师生管理」+「学习管理」两个分组) ===================== -->
<menuitem id="menu_yuthon_org_root"
name="师生管理"
name="班级管理"
web_icon="college,static/src/img/organization.png"
sequence="20"
groups="college.group_teacher,base.group_system"/>
<!-- 师生管理(分组容器,承载班级/老师/学生) -->
<menuitem id="menu_yuthon_org_staff"
name="师生管理"
parent="menu_yuthon_org_root"
sequence="10"/>
<menuitem id="menu_yuthon_class"
name="班级"
parent="menu_yuthon_org_root"
parent="menu_yuthon_org_staff"
action="action_yuthon_school_class"
sequence="10"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_teacher"
name="老师"
parent="menu_yuthon_org_root"
parent="menu_yuthon_org_staff"
action="action_yuthon_school_teacher"
sequence="20"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_student"
name="学生"
parent="menu_yuthon_org_root"
parent="menu_yuthon_org_staff"
action="action_yuthon_school_student"
sequence="30"
groups="college.group_teacher,base.group_system"/>
<!-- ===================== 学习进展(统一入口,服务端按角色路由) ===================== -->
<!-- 学习管理(原顶级菜单迁移至此,与「师生管理」并列;父级仅老师/管理员可见) -->
<menuitem id="menu_yuthon_education_root"
name="学习管理"
parent="menu_yuthon_org_root"
sequence="20"/>
<!-- 学习进展:仅老师/管理员(学生侧改由顶层「我的作业」承载) -->
<menuitem id="menu_yuthon_progress"
name="学习进展"
parent="menu_yuthon_education_root"
action="action_yuthon_school_progress_router"
sequence="20"
groups="college.group_teacher,college.group_student,base.group_system"/>
groups="college.group_teacher,base.group_system"/>
<!-- ===================== 作业习题 ===================== -->
<!-- 作业习题 -->
<menuitem id="menu_yuthon_homework"
name="作业习题"
parent="menu_yuthon_education_root"
@@ -250,13 +270,13 @@ else:
action="action_yuthon_school_assignment"
sequence="10"
groups="college.group_teacher,base.group_system"/>
<!-- 作业提交:仅学生 -->
<!-- 作业提交:仅老师(原学生入口迁移到顶层「我的作业」) -->
<menuitem id="menu_yuthon_homework_submit"
name="作业提交"
parent="menu_yuthon_homework"
action="action_yuthon_school_my_submission"
sequence="20"
groups="college.group_student,base.group_system"/>
groups="college.group_teacher,base.group_system"/>
<!-- 作业批改:仅老师(无提交入口) -->
<menuitem id="menu_yuthon_homework_grade"
name="作业批改"
@@ -270,7 +290,32 @@ else:
parent="menu_yuthon_homework"
action="action_yuthon_school_class_notes"
sequence="40"
groups="college.group_teacher,college.group_student,base.group_system"/>
groups="college.group_teacher,base.group_system"/>
<!-- ===================== 我的作业(顶级菜单,仅学生可见;承载从「学习管理」迁移出的学生侧入口,保证学生不断流) ===================== -->
<menuitem id="menu_yuthon_student_homework"
name="我的作业"
web_icon="college,static/src/img/education.png"
sequence="6"
groups="college.group_student"/>
<menuitem id="menu_yuthon_student_homework_submit"
name="作业提交"
parent="menu_yuthon_student_homework"
action="action_yuthon_school_my_submission"
sequence="10"
groups="college.group_student"/>
<menuitem id="menu_yuthon_student_class_notes"
name="随堂笔记"
parent="menu_yuthon_student_homework"
action="action_yuthon_school_class_notes"
sequence="20"
groups="college.group_student"/>
<menuitem id="menu_yuthon_student_progress"
name="学习进展"
parent="menu_yuthon_student_homework"
action="action_yuthon_school_progress_router"
sequence="30"
groups="college.group_student"/>
<!-- ===================== 关于系统说明 ===================== -->
<!-- 系统说明:打开预置记录 about_page_system -->
@@ -326,4 +371,20 @@ else:
action="action_yuthon_school_course_import_wizard"
sequence="40"
groups="college.group_teacher,base.group_system"/>
<!-- 题库导入:打开批量导入向导弹窗(按题型下载模板 → 上传文件 → 写入题库) -->
<record id="action_yuthon_about_question_import" model="ir.actions.act_window">
<field name="name">题库导入</field>
<field name="res_model">yuthon.school.question.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{}</field>
<field name="res_id" eval="False"/>
</record>
<menuitem id="menu_yuthon_about_question_import"
name="题库导入"
parent="menu_yuthon_about"
action="action_yuthon_about_question_import"
sequence="50"
groups="college.group_teacher,base.group_system"/>
</odoo>
@@ -0,0 +1,28 @@
<odoo>
<!-- ===================== 题库批量导入向导表单 ===================== -->
<record id="yuthon_school_question_import_wizard_view_form" model="ir.ui.view">
<field name="name">yuthon.school.question.import.wizard.form</field>
<field name="model">yuthon.school.question.import.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="question_type" required="1"/>
</group>
<field name="note" widget="html" readonly="1"/>
<group>
<field name="data_file" filename="file_name"/>
<field name="file_name" invisible="1"/>
</group>
</sheet>
<footer>
<button name="action_download_template" type="object"
string="下载模板" class="btn-secondary"/>
<button name="action_import" type="object"
string="开始导入" class="btn-primary"/>
<button string="取消" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>
+16
View File
@@ -17,6 +17,7 @@
<field name="summary"/>
<field name="website"/>
<field name="application"/>
<field name="module_origin"/>
<templates>
<t t-name="menu">
<t t-set="installed" t-value="record.state.raw_value == 'installed'"/>
@@ -48,4 +49,19 @@
</kanban>
</field>
</record>
<!-- 实训模块搜索视图:继承原生 Apps 搜索,追加「模块来源」过滤 -->
<record id="shixun_module_search_view" model="ir.ui.view">
<field name="name">实训模块搜索</field>
<field name="model">ir.module.module</field>
<field name="mode">extension</field>
<field name="inherit_id" ref="base.view_module_filter"/>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<separator/>
<filter name="official" string="系统原生" domain="[('module_origin', '=', 'official')]"/>
<filter name="local" string="本地模块" domain="[('module_origin', '=', 'local')]"/>
</xpath>
</field>
</record>
</odoo>