Zhou #35
@@ -1,2 +1,3 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import patch_wkhtmltopdf # noqa: F401 应用 wkhtmltopdf 命令行猴子补丁
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
from . import exam_controller
|
||||
from . import paper_pdf_reportlab
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
def _p(text, style):
|
||||
"""生成 Paragraph,并转义文本中的 XML 字符。"""
|
||||
return Paragraph(_escape_xml(text), style)
|
||||
|
||||
|
||||
def _build_styles():
|
||||
"""构建 PDF 中使用的段落样式。"""
|
||||
font_name = _get_font_name()
|
||||
base = getSampleStyleSheet()
|
||||
styles = {}
|
||||
styles['title'] = ParagraphStyle(
|
||||
'title', parent=base['Title'],
|
||||
fontName=font_name, fontSize=18, leading=26,
|
||||
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
|
||||
spaceAfter=6,
|
||||
)
|
||||
styles['subtitle'] = ParagraphStyle(
|
||||
'subtitle', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
|
||||
spaceAfter=12,
|
||||
)
|
||||
styles['score_big'] = ParagraphStyle(
|
||||
'score_big', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=26, leading=32,
|
||||
textColor=colors.HexColor('#2563eb'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['score_label'] = ParagraphStyle(
|
||||
'score_label', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=14,
|
||||
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['score_pass'] = ParagraphStyle(
|
||||
'score_pass', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=14,
|
||||
textColor=colors.HexColor('#15803d'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['score_fail'] = ParagraphStyle(
|
||||
'score_fail', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=14,
|
||||
textColor=colors.HexColor('#b91c1c'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['section_title'] = ParagraphStyle(
|
||||
'section_title', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=12, leading=18,
|
||||
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
|
||||
spaceBefore=14, spaceAfter=8,
|
||||
)
|
||||
styles['question_title'] = ParagraphStyle(
|
||||
'question_title', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
|
||||
spaceAfter=4,
|
||||
)
|
||||
styles['body'] = ParagraphStyle(
|
||||
'body', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#374151'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['body_green'] = ParagraphStyle(
|
||||
'body_green', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#15803d'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['body_red'] = ParagraphStyle(
|
||||
'body_red', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#b91c1c'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['body_gray'] = ParagraphStyle(
|
||||
'body_gray', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['body_orange'] = ParagraphStyle(
|
||||
'body_orange', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#b45309'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['tag'] = ParagraphStyle(
|
||||
'tag', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=9, leading=12,
|
||||
textColor=colors.HexColor('#3730a3'), alignment=TA_CENTER,
|
||||
)
|
||||
styles['tag_green'] = ParagraphStyle(
|
||||
'tag_green', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=9, leading=12,
|
||||
textColor=colors.HexColor('#15803d'), alignment=TA_CENTER,
|
||||
)
|
||||
styles['tag_red'] = ParagraphStyle(
|
||||
'tag_red', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=9, leading=12,
|
||||
textColor=colors.HexColor('#b91c1c'), alignment=TA_CENTER,
|
||||
)
|
||||
styles['footer'] = ParagraphStyle(
|
||||
'footer', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=9, leading=12,
|
||||
textColor=colors.HexColor('#9ca3af'), alignment=TA_RIGHT,
|
||||
)
|
||||
return styles
|
||||
|
||||
|
||||
def _question_type_label(qtype):
|
||||
return {
|
||||
'single_choice': '单选题',
|
||||
'multiple_choice': '多选题',
|
||||
'true_false': '判断题',
|
||||
'fill_blank': '填空题',
|
||||
'essay': '简答题',
|
||||
}.get(qtype, qtype)
|
||||
|
||||
|
||||
def _build_answer_block(ans, styles, question_number=None):
|
||||
"""为一道题生成 reportlab 元素列表。
|
||||
|
||||
:param question_number: 在试卷中显示的题号;默认使用题目本身的 sequence。
|
||||
"""
|
||||
story = []
|
||||
q = ans.question_id
|
||||
|
||||
# 题头:题号 + 题型 + 分数 + 对错
|
||||
score_text = f'{ans.score or 0} / {ans.max_score or 0} 分'
|
||||
status_style = styles['tag_green'] if ans.is_correct else styles['tag_red']
|
||||
status_text = ''
|
||||
if ans.question_type != 'essay':
|
||||
status_text = '正确' if ans.is_correct else '错误'
|
||||
|
||||
display_number = question_number if question_number is not None else (ans.sequence or 0)
|
||||
header_data = [
|
||||
[
|
||||
_p(f'第 {display_number} 题', styles['question_title']),
|
||||
_p(_question_type_label(ans.question_type), styles['tag']),
|
||||
_p(score_text, styles['body']),
|
||||
],
|
||||
]
|
||||
if status_text:
|
||||
header_data[0].append(_p(status_text, status_style))
|
||||
|
||||
col_count = len(header_data[0])
|
||||
if col_count == 3:
|
||||
widths = [None, 35 * mm, 35 * mm]
|
||||
else:
|
||||
widths = [None, 35 * mm, 35 * mm, 25 * mm]
|
||||
|
||||
header_table = Table(header_data, colWidths=widths)
|
||||
header_table.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 0),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
||||
]))
|
||||
story.append(header_table)
|
||||
|
||||
# 题干
|
||||
story.append(_p(_strip_html(ans.question_text), styles['body']))
|
||||
|
||||
# 选择题选项
|
||||
if ans.question_type in ('single_choice', 'multiple_choice') and q.option_ids:
|
||||
for opt in q.option_ids.sorted(lambda o: (o.sequence, o.id)):
|
||||
badges = []
|
||||
if opt.is_correct:
|
||||
badges.append('正确')
|
||||
if opt in ans.selected_option_ids:
|
||||
badges.append('你的选择')
|
||||
badge_text = f' [{" · ".join(badges)}]' if badges else ''
|
||||
option_text = f'{opt.label}. {_strip_html(opt.content)}{badge_text}'
|
||||
story.append(_p(option_text, styles['body']))
|
||||
|
||||
# 学生作答
|
||||
if ans.question_type == 'essay':
|
||||
answer_text = _strip_html(ans.answer_text) or '未作答'
|
||||
else:
|
||||
answer_text = ans.answer_display or '未作答'
|
||||
story.append(_p(f'学生作答:{answer_text}', styles['body']))
|
||||
|
||||
# 正确答案 / 参考答案
|
||||
if ans.question_type == 'single_choice':
|
||||
correct_labels = ', '.join(opt.label for opt in q._get_correct_options())
|
||||
story.append(_p(f'正确答案:{correct_labels}', styles['body_green']))
|
||||
elif ans.question_type == 'multiple_choice':
|
||||
correct_labels = ', '.join(opt.label for opt in q._get_correct_options())
|
||||
story.append(_p(f'正确答案:{correct_labels}', styles['body_green']))
|
||||
elif ans.question_type == 'true_false':
|
||||
text = '正确' if q.correct_answer == 'True' else '错误'
|
||||
story.append(_p(f'正确答案:{text}', styles['body_green']))
|
||||
elif ans.question_type == 'fill_blank':
|
||||
story.append(_p(f'正确答案:{q.correct_answer or "-"}', styles['body_green']))
|
||||
elif ans.question_type == 'essay':
|
||||
ref = _strip_html(q.reference_answer) or '-'
|
||||
story.append(_p(f'参考答案:{ref}', styles['body_gray']))
|
||||
|
||||
# 本题评语
|
||||
if ans.teacher_comment:
|
||||
story.append(_p(f'评语:{ans.teacher_comment}', styles['body_orange']))
|
||||
|
||||
return story
|
||||
|
||||
|
||||
def _build_paper_story(paper, styles):
|
||||
"""构建单份答卷的 PDF 流元素列表(不含 doc.build)。"""
|
||||
story = []
|
||||
|
||||
# 表头:考试名 + 学员信息
|
||||
story.append(_p(paper.exam_id.name or '考试', styles['title']))
|
||||
|
||||
info_parts = [f"学员:{paper.student_id.name or '-'}"]
|
||||
if paper.course_id:
|
||||
info_parts.append(f"课程:{paper.course_id.name}")
|
||||
if paper.chapter_ids:
|
||||
info_parts.append(f"章节:{', '.join(paper.chapter_ids.mapped('name'))}")
|
||||
state_map = dict(paper._fields['state'].selection)
|
||||
info_parts.append(f"状态:{state_map.get(paper.state, paper.state)}")
|
||||
if paper.submit_time:
|
||||
info_parts.append(f"交卷:{paper.submit_time.strftime('%Y-%m-%d %H:%M')}")
|
||||
story.append(_p(' | '.join(info_parts), styles['subtitle']))
|
||||
|
||||
# 得分概览(仅显示分数数字 + 及格状态 + 及格线)
|
||||
pass_style = styles['score_pass'] if paper.is_passed else styles['score_fail']
|
||||
pass_text = '及格' if paper.is_passed else '不及格'
|
||||
score_row = [
|
||||
_p(f"{paper.score or 0} 分", styles['score_big']),
|
||||
_p(pass_text, pass_style),
|
||||
_p(f"及格分:{paper.pass_score or 0} 分", styles['score_label']),
|
||||
]
|
||||
score_table = Table([score_row], colWidths=[70 * mm, 35 * mm, 70 * mm])
|
||||
score_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f8fafc')),
|
||||
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e5e7eb')),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 12),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 12),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 10),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 10),
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
]))
|
||||
story.append(score_table)
|
||||
story.append(Spacer(1, 8 * mm))
|
||||
|
||||
# 答题详情
|
||||
story.append(_p('答题详情', styles['section_title']))
|
||||
answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id))
|
||||
for idx, ans in enumerate(answers):
|
||||
block = _build_answer_block(ans, styles, question_number=idx + 1)
|
||||
block_table = Table([[element] for element in block], colWidths=[170 * mm])
|
||||
block_table.setStyle(TableStyle([
|
||||
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e5e7eb')),
|
||||
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#ffffff')),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 10),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 10),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 8),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||||
]))
|
||||
story.append(KeepTogether(block_table))
|
||||
story.append(Spacer(1, 4 * mm))
|
||||
|
||||
# 整卷评语
|
||||
if paper.teacher_comment:
|
||||
story.append(Spacer(1, 6 * mm))
|
||||
comment_table = Table(
|
||||
[[_p(f"整卷评语:{paper.teacher_comment}", styles['body_orange'])]],
|
||||
colWidths=[170 * mm]
|
||||
)
|
||||
comment_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#fffbeb')),
|
||||
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#fde68a')),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 10),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 10),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 8),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||||
]))
|
||||
story.append(comment_table)
|
||||
|
||||
# 页脚生成时间
|
||||
story.append(Spacer(1, 10 * mm))
|
||||
story.append(_p(f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}", styles['footer']))
|
||||
|
||||
return story
|
||||
|
||||
|
||||
def render_paper_pdf(paper):
|
||||
"""渲染指定答卷的 PDF 字节流。"""
|
||||
_register_chinese_fonts()
|
||||
styles = _build_styles()
|
||||
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer, pagesize=A4,
|
||||
rightMargin=20 * mm, leftMargin=20 * mm,
|
||||
topMargin=20 * mm, bottomMargin=18 * mm,
|
||||
)
|
||||
|
||||
story = _build_paper_story(paper, styles)
|
||||
doc.build(story)
|
||||
pdf_bytes = buffer.getvalue()
|
||||
buffer.close()
|
||||
return pdf_bytes
|
||||
|
||||
|
||||
def render_papers_pdf(papers):
|
||||
"""将多份答卷合并渲染为一个 PDF 字节流(每份之间自动分页)。"""
|
||||
_register_chinese_fonts()
|
||||
styles = _build_styles()
|
||||
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer, pagesize=A4,
|
||||
rightMargin=20 * mm, leftMargin=20 * mm,
|
||||
topMargin=20 * mm, bottomMargin=18 * mm,
|
||||
)
|
||||
|
||||
story = []
|
||||
papers_list = list(papers)
|
||||
for idx, paper in enumerate(papers_list):
|
||||
story.extend(_build_paper_story(paper, styles))
|
||||
if idx < len(papers_list) - 1:
|
||||
story.append(PageBreak())
|
||||
|
||||
doc.build(story)
|
||||
pdf_bytes = buffer.getvalue()
|
||||
buffer.close()
|
||||
return pdf_bytes
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
@@ -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 && 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 && 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 && res.result.status === 'ok') {
|
||||
if (res.result && res.result.status === 'ok') {
|
||||
window.location.href = res.result.redirect;
|
||||
} else {
|
||||
var err = (res.result && 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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user