Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec4c918899 | ||
|
|
87e8f0c636 | ||
|
|
8ea8b089af | ||
|
|
dfa665bbde | ||
|
|
8939e61094 | ||
|
|
0a96d0e868 | ||
|
|
ab62d0e79b | ||
|
|
d84c482c84 | ||
|
|
7b393414b3 | ||
|
|
fa5b2c649e | ||
|
|
27b17aeaa6 | ||
|
|
246326ed9c | ||
|
|
c84da2ed10 | ||
|
|
9d5a60af09 | ||
|
|
d73bd82769 | ||
|
|
d1a217f8cf | ||
|
|
0d6d3e0bb9 | ||
|
|
4cdbaf9799 | ||
|
|
84df154ce2 | ||
|
|
63c87c7e56 | ||
|
|
ba32af9e04 | ||
|
|
07c29e451d | ||
|
|
194594ffa5 | ||
|
|
2b89ca6cb5 | ||
|
|
013aa37ebc | ||
|
|
9f909e4833 | ||
|
|
544de24893 | ||
|
|
1b6ea810b2 | ||
|
|
f75aca9fd5 | ||
|
|
aa07c87bbb | ||
|
|
0bcbba4383 | ||
|
|
ad7bc5877d | ||
|
|
aa150974b5 | ||
|
|
2b186521ff | ||
|
|
c02a4c0f51 | ||
|
|
47dc1c428f | ||
|
|
5c8f8fa895 | ||
|
|
f5967ae13d | ||
|
|
285aeed475 | ||
|
|
40a9ed9bd1 | ||
|
|
c471fe95bc | ||
|
|
2627d3e8de | ||
|
|
5602975376 | ||
|
|
5c95a5852f | ||
|
|
89b67f0e7f | ||
|
|
fa061bf754 | ||
|
|
7601338f99 | ||
|
|
9d91137396 | ||
|
|
ed36f20beb | ||
|
|
70dece41ed | ||
|
|
102a8ca4e1 | ||
|
|
c3b994ca86 | ||
|
|
b044ba3367 | ||
|
|
148ba8b6c0 | ||
|
|
cd68aa8c72 | ||
|
|
c60acb1984 | ||
|
|
6bb6e7fc6d |
@@ -1,2 +1,3 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import patch_wkhtmltopdf # noqa: F401 应用 wkhtmltopdf 命令行猴子补丁
|
||||
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': '教育',
|
||||
'version': '18.0.3.0.4',
|
||||
'version': '18.0.3.0.7',
|
||||
'summary': 'Odoo 18 教学管理平台 — 师生管理 + 课程管理 + 作业/进展(老师·学生双角色)',
|
||||
'description': """
|
||||
Odoo 18 教学管理平台(面向老师与学生,双角色权限)
|
||||
@@ -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',
|
||||
@@ -55,6 +58,12 @@
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'college/static/src/scss/college_theme.scss',
|
||||
'college/static/src/scss/hide_about_edit.scss',
|
||||
'college/static/src/js/hide_company_menu.js',
|
||||
'college/static/src/js/hide_user_menu_items.js',
|
||||
'college/static/src/js/hide_activity_menu.js',
|
||||
'college/static/src/js/portal_indicator.js',
|
||||
'college/static/src/xml/portal_indicator.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
'college/static/src/scss/college_theme_dark.scss',
|
||||
|
||||
@@ -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
|
||||
@@ -236,4 +236,63 @@
|
||||
</div>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 团队介绍 ===================== -->
|
||||
<record id="about_page_team" model="yuthon.about.page">
|
||||
<field name="name">团队介绍</field>
|
||||
<field name="category">team</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="active">True</field>
|
||||
<field name="content"><![CDATA[
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang SC','Microsoft YaHei',sans-serif;color:#fff;line-height:1.65;">
|
||||
<!-- 顶部 banner:深蓝科技风 -->
|
||||
<div style="background:linear-gradient(135deg,#0f172a 0%,#1e40af 50%,#0ea5e9 100%);padding:72px 32px 56px;text-align:center;position:relative;overflow:hidden;">
|
||||
<!-- 装饰圆 -->
|
||||
<div style="position:absolute;top:-60px;left:-60px;width:220px;height:220px;background:rgba(255,255,255,0.06);border-radius:50%;"></div>
|
||||
<div style="position:absolute;bottom:-40px;right:-40px;width:180px;height:180px;background:rgba(255,255,255,0.05);border-radius:50%;"></div>
|
||||
|
||||
<!-- 顶部胶囊标签 -->
|
||||
<div style="display:inline-flex;align-items:center;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.18);border-radius:999px;padding:6px 18px 6px 8px;margin-bottom:28px;position:relative;z-index:1;">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;background:#facc15;color:#0f172a;border-radius:8px;font-weight:800;font-size:14px;margin-right:10px;">P</span>
|
||||
<span style="font-size:14px;color:#e2e8f0;letter-spacing:0.5px;">Peng Yuthon</span>
|
||||
</div>
|
||||
|
||||
<h1 style="font-size:42px;font-weight:800;margin:0 0 16px;color:#fff !important;position:relative;z-index:1;">鹏程万里 气宇轩昂</h1>
|
||||
<p style="font-size:18px;margin:0 0 36px;color:#cbd5e1;position:relative;z-index:1;">软件 · 硬件 · AI 智能体</p>
|
||||
|
||||
<!-- 底部胶囊标签 -->
|
||||
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:12px;position:relative;z-index:1;">
|
||||
<span style="background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:999px;padding:8px 18px;font-size:14px;color:#e2e8f0;">软件</span>
|
||||
<span style="background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:999px;padding:8px 18px;font-size:14px;color:#e2e8f0;">硬件</span>
|
||||
<span style="background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:999px;padding:8px 18px;font-size:14px;color:#e2e8f0;">AI 智能体</span>
|
||||
<span style="background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:999px;padding:8px 18px;font-size:14px;color:#e2e8f0;">工程化教育</span>
|
||||
<span style="background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:999px;padding:8px 18px;font-size:14px;color:#e2e8f0;">企业实训</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<div style="padding:36px 28px;background:#fff;color:#334155;">
|
||||
<h2 style="color:#0f172a;font-size:20px;margin:0 0 16px;">关于我们</h2>
|
||||
<p style="margin:0 0 28px;font-size:15px;color:#475569;">
|
||||
Peng Yuthon 专注于软件、硬件与 AI 智能体方向的工程化教育,
|
||||
致力于为高校与职业院校提供真实企业场景驱动的实训解决方案。
|
||||
</p>
|
||||
|
||||
<h2 style="color:#0f172a;font-size:20px;margin:0 0 16px;">联系方式</h2>
|
||||
<ul style="list-style:none;padding:0;margin:0;font-size:15px;line-height:2;color:#475569;">
|
||||
<li><b style="color:#0f172a;">B 站:</b>bilibili: pengyuthon</li>
|
||||
<li><b style="color:#0f172a;">邮箱:</b>pengyuthon@163.com</li>
|
||||
<li><b style="color:#0f172a;">官网:</b>pengyuthon.cn</li>
|
||||
<li><b style="color:#0f172a;">学习平台:</b>edu.pengyuthon.cn</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 版权页脚 -->
|
||||
<div style="padding:18px 24px;background:#0f172a;color:#94a3b8;font-size:13px;text-align:center;">
|
||||
© 2026 Peng Yuthon. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
<field name="end_date">2026-01-15</field>
|
||||
<field name="introduction"><![CDATA[<p>面向零基础开发者,系统讲解 Odoo 18 的模块结构、模型(Model)、视图(View)与控制器(Controller),带你跑通第一个自定义模块。</p>]]></field>
|
||||
<field name="objectives"><![CDATA[<ul><li>理解 Odoo 的 MVC 架构</li><li>能独立创建并安装一个模块</li><li>掌握模型字段与关系(M2O / O2M / M2M)</li></ul>]]></field>
|
||||
<field name="syllabus"><![CDATA[<ol><li>开发环境搭建</li><li>模块清单与目录结构</li><li>模型定义与字段类型</li><li>视图(list / form / kanban)</li><li>菜单与权限(ACL)</li></ol>]]></field>
|
||||
<field name="features"><![CDATA[<p>配套视频讲解 + 可运行示例代码,支持线下实训环境动手练习。</p>]]></field>
|
||||
<field name="student_ids" eval="[(6, 0, [ref('student_wang'), ref('student_01'), ref('student_02'), ref('student_03')])]"/>
|
||||
<field name="state">published</field>
|
||||
@@ -102,7 +101,6 @@
|
||||
<field name="end_date">2026-06-30</field>
|
||||
<field name="introduction"><![CDATA[<p>深入 Odoo 的视图体系:列表、表单、看板、搜索、图形,以及 CSS 与 JS 资产定制。</p>]]></field>
|
||||
<field name="objectives"><![CDATA[<ul><li>掌握 list/form/kanban 视图语法</li><li>能用 inherit 扩展已有视图</li><li>理解静态资源(assets)加载机制</li></ul>]]></field>
|
||||
<field name="syllabus"><![CDATA[<ol><li>视图类型总览</li><li>视图继承与 xpath</li><li>看板与搜索视图</li><li>前端资产与主题定制</li></ol>]]></field>
|
||||
<field name="features"><![CDATA[<p>视频 + PPT 双形态,含实操演练。</p>]]></field>
|
||||
<field name="student_ids" eval="[(6, 0, [ref('student_02'), ref('student_04')])]"/>
|
||||
<field name="state">draft</field>
|
||||
@@ -120,7 +118,6 @@
|
||||
<field name="end_date">2027-01-10</field>
|
||||
<field name="introduction"><![CDATA[<p>以 PPT 讲义为主,讲解 QWeb 报表、PDF 打印与数据导出。</p>]]></field>
|
||||
<field name="objectives"><![CDATA[<ul><li>掌握 QWeb 报表模板</li><li>能定制 PDF 打印</li></ul>]]></field>
|
||||
<field name="syllabus"><![CDATA[<ol><li>报表模型与 action</li><li>QWeb 模板语法</li><li>打印与导出</li></ol>]]></field>
|
||||
<field name="features"><![CDATA[<p>适合线下培训,讲师直接分发 PPT。</p>]]></field>
|
||||
<field name="state">draft</field>
|
||||
</record>
|
||||
|
||||
@@ -15,9 +15,18 @@
|
||||
94=Administration(后台) 6=Tools(工具)
|
||||
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>
|
||||
2026-09-18 追加过滤(各类目仅 1 个应用、教学实训用不上),使总数精确收敛到 50:
|
||||
33=Timesheets(工时单) 55=Lunch(午餐) 37=Live Chat(在线聊天)
|
||||
59=Social Marketing(社会营销) 21=Events(活动) 89=Sign(签署)
|
||||
90=Helpdesk(服务台) 92=Field Service(现场服务) 103=Planning(排班)
|
||||
28=Time Off(休息时间) 32=Recruitment(招聘) 88=Appraisals(评估)
|
||||
23=Fleet(车辆)
|
||||
如需保留/恢复某类目,从下面列表中删除对应 id 即可(注意总数会变化)。 -->
|
||||
<field name="domain">[('application', '=', True), ('category_id', 'not in', [13, 54, 71, 15, 94, 6, 10, 11, 20, 33, 55, 37, 59, 21, 89, 90, 92, 103, 28, 32, 88, 23])]</field>
|
||||
<!-- shixun_hide_empty_categories:搜索面板「类别」列过滤掉计数为 0 的类别
|
||||
(见 models/ir_module_module_info.py 对 search_panel_select_range 的包装) -->
|
||||
<field name="context">{'shixun_hide_empty_categories': True}</field>
|
||||
<field name="search_view_id" ref="shixun_module_search_view"/>
|
||||
<field name="limit">50</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * college
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 18.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-21 11:31:00+0000\n"
|
||||
"PO-Revision-Date: 2026-09-21 11:31:00+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"Language: zh_CN\n"
|
||||
|
||||
#. module: base
|
||||
#: model:ir.module.category,name:base.module_category_education
|
||||
msgid "Education"
|
||||
msgstr "教育"
|
||||
|
||||
#. module: base
|
||||
#: model:ir.module.category,name:base.module_category_game
|
||||
msgid "Game"
|
||||
msgstr "游戏"
|
||||
|
||||
#. module: base
|
||||
#: model:ir.module.category,name:base.module_category_backend
|
||||
msgid "Backend"
|
||||
msgstr "后端"
|
||||
|
||||
#. module: base
|
||||
#: model:ir.module.category,name:base.module_category_themes
|
||||
msgid "Themes"
|
||||
msgstr "主题"
|
||||
|
||||
# 以下四项 base 自带翻译已存在,这里补一份,
|
||||
# 保证 college 模块单独升级时这几个类别也一定是中文。
|
||||
#. module: base
|
||||
#: model:ir.module.category,name:base.module_category_tools
|
||||
#: model:ir.module.category,name:base.module_category_hidden_tools
|
||||
msgid "Tools"
|
||||
msgstr "工具"
|
||||
|
||||
#. module: base
|
||||
#: model:ir.module.category,name:base.module_category_uncategorized
|
||||
msgid "Uncategorized"
|
||||
msgstr "未分类"
|
||||
|
||||
#. module: base
|
||||
#: model:ir.module.category,name:base.module_category_localization
|
||||
msgid "Localization"
|
||||
msgstr "本地化"
|
||||
|
||||
#. module: base
|
||||
#: model:ir.module.category,name:base.module_category_manufacturing_maintenance
|
||||
msgid "Maintenance"
|
||||
msgstr "维修"
|
||||
@@ -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,8 @@ class YuthonAboutPage(models.Model):
|
||||
('system', '系统说明'),
|
||||
('operation', '操作手册'),
|
||||
('product', '产品介绍'),
|
||||
('import', '导入说明'),
|
||||
('team', '团队介绍'),
|
||||
], string='分类', default='system', required=True)
|
||||
content = fields.Html(string='内容', sanitize=True, translate=True)
|
||||
sequence = fields.Integer(string='排序', default=10)
|
||||
|
||||
@@ -20,6 +20,13 @@ class SchoolAssignment(models.Model):
|
||||
ondelete='cascade',
|
||||
domain="[('state', '=', 'published'), ('teacher_id', '=', uid)]",
|
||||
help='只能选择自己已发布的课程。')
|
||||
chapter_id = fields.Many2one(
|
||||
'yuthon.school.course.chapter',
|
||||
string='对应章节',
|
||||
domain="[('course_id', '=', course_id)]",
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='该作业所属的课程章节,不选则代表整门课程作业。')
|
||||
teacher_id = fields.Many2one(
|
||||
'res.users', string='布置教师',
|
||||
default=lambda self: self.env.user, required=True)
|
||||
@@ -111,6 +118,15 @@ class SchoolAssignmentSubmission(models.Model):
|
||||
_order = 'submit_time desc, id'
|
||||
_rec_name = 'name'
|
||||
|
||||
chapter_id = fields.Many2one(
|
||||
'yuthon.school.course.chapter',
|
||||
string='章节',
|
||||
related='assignment_id.chapter_id',
|
||||
store=True, index=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
name = fields.Char(string='提交标题', readonly=True, copy=False)
|
||||
assignment_id = fields.Many2one(
|
||||
'yuthon.school.assignment', string='作业', required=True, ondelete='cascade')
|
||||
|
||||
@@ -325,7 +325,7 @@ class CourseChapter(models.Model):
|
||||
], limit=1, order='id desc')
|
||||
if attachment:
|
||||
chapter.video_embed_html = (
|
||||
f'<video controls style="width:100%;max-width:720px;" '
|
||||
f'<video controls style="width:100%;" '
|
||||
f'preload="metadata">'
|
||||
f'<source src="/web/content/{attachment.id}" '
|
||||
f'type="video/mp4">'
|
||||
@@ -350,7 +350,10 @@ class CourseChapter(models.Model):
|
||||
r.is_done_for_me = bool(student and student in r.completed_student_ids)
|
||||
|
||||
def action_open_study(self):
|
||||
"""学生端:弹窗打开当前章节的学习页(带视频播放/附件下载)。"""
|
||||
"""学生端:在当前窗口打开当前章节的学习页(带视频播放/附件下载)。
|
||||
|
||||
从弹窗改为全屏表单,避免视频播放区域过窄。
|
||||
"""
|
||||
self.ensure_one()
|
||||
study_form = self.env.ref(
|
||||
'college.yuthon_school_course_chapter_study_form')
|
||||
@@ -361,7 +364,7 @@ class CourseChapter(models.Model):
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'views': [(study_form.id, 'form')],
|
||||
'target': 'new',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_open_edit(self):
|
||||
|
||||
@@ -74,8 +74,8 @@ class SchoolCourse(models.Model):
|
||||
# 课程详情
|
||||
introduction = fields.Html(string='课程介绍', sanitize=True)
|
||||
objectives = fields.Html(string='课程目标', sanitize=True)
|
||||
syllabus = fields.Html(string='课程大纲', sanitize=True)
|
||||
features = fields.Html(string='课程特色', sanitize=True)
|
||||
syllabus = fields.Html(string='课程大纲', sanitize=True)
|
||||
business_scenario = fields.Html(string='业务情景', sanitize=True)
|
||||
|
||||
# 学员分配
|
||||
@@ -106,6 +106,20 @@ class SchoolCourse(models.Model):
|
||||
chapter_count = fields.Integer(
|
||||
string='章节数', compute='_compute_chapter_count', store=True)
|
||||
|
||||
# 课程作业:一门课对应多条作业记录
|
||||
assignment_ids = fields.One2many(
|
||||
'yuthon.school.assignment',
|
||||
'course_id',
|
||||
string='作业布置',
|
||||
)
|
||||
|
||||
# 学习进展:一门课对应多条学员进度记录
|
||||
progress_ids = fields.One2many(
|
||||
'yuthon.school.progress',
|
||||
'course_id',
|
||||
string='学习进展',
|
||||
)
|
||||
|
||||
@api.depends('student_ids')
|
||||
def _compute_student_count(self):
|
||||
for record in self:
|
||||
|
||||
@@ -64,7 +64,7 @@ class CourseImportWizard(models.TransientModel):
|
||||
'<li>点击下方「下载模板」按钮下载 CSV 模板;</li>'
|
||||
'<li>用 Excel / WPS 打开模板,按示例填写数据:<br/>'
|
||||
'<b>课程名称</b>、<b>课程编号</b> 为必填;<br/>'
|
||||
'<b>所属学院、学期、课程类型、课时、课程大纲、业务情景</b> 为选填;</li>'
|
||||
'<b>所属学院、学期、课程类型、课时、课程介绍、业务情景</b> 为选填;</li>'
|
||||
'<li>课程类型可选:理论 / 实践</li>'
|
||||
'<li><b>章节 / 任务(可选)</b>:填「章节标题」会自动建/复用章节,'
|
||||
'填「任务名称」会在该章节下建一条任务列表明细(是否完成填 是/否);'
|
||||
@@ -80,12 +80,12 @@ class CourseImportWizard(models.TransientModel):
|
||||
def action_download_template(self):
|
||||
"""生成 CSV 模板并提供下载。"""
|
||||
header = ['课程名称', '课程编号', '所属学院', '学期',
|
||||
'课程类型(理论/实践)', '课时', '课程大纲', '业务情景',
|
||||
'课程类型(理论/实践)', '课时', '课程介绍', '业务情景',
|
||||
'章节标题', '章节说明', '章节课时',
|
||||
'任务名称', '是否完成']
|
||||
sample = ['Python 编程基础', 'PY101', '计算机学院',
|
||||
'2025-2026第1学期', '理论', '48',
|
||||
'Python基础语法、数据结构、面向对象编程',
|
||||
'本课程面向零基础学员,以项目驱动方式讲解核心知识',
|
||||
'通过电商订单管理场景贯穿课程',
|
||||
'第1章 环境搭建', '本章介绍开发环境', '4',
|
||||
'安装Python', '否']
|
||||
@@ -183,9 +183,9 @@ class CourseImportWizard(models.TransientModel):
|
||||
vals['hours'] = int(float(hours_raw))
|
||||
except ValueError:
|
||||
pass
|
||||
syllabus = row.get('syllabus', '')
|
||||
if syllabus:
|
||||
vals['syllabus'] = syllabus
|
||||
introduction = row.get('introduction', '')
|
||||
if introduction:
|
||||
vals['introduction'] = introduction
|
||||
business_scenario = row.get('business_scenario', '')
|
||||
if business_scenario:
|
||||
vals['business_scenario'] = business_scenario
|
||||
@@ -282,8 +282,8 @@ class CourseImportWizard(models.TransientModel):
|
||||
mapping[f] = 'course_type'
|
||||
elif f_lower in ('课时', 'hours'):
|
||||
mapping[f] = 'hours'
|
||||
elif f_lower in ('课程大纲', 'syllabus', '大纲'):
|
||||
mapping[f] = 'syllabus'
|
||||
elif f_lower in ('课程介绍', 'introduction', '课程简介', '课程大纲', 'syllabus', '大纲'):
|
||||
mapping[f] = 'introduction'
|
||||
elif f_lower in ('业务情景', 'business_scenario', 'scenario'):
|
||||
mapping[f] = 'business_scenario'
|
||||
elif f_lower in ('章节标题', 'chapter', '章节', 'chapter_name'):
|
||||
|
||||
+214
-9
@@ -1,5 +1,6 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
_QUESTION_TYPES = [
|
||||
@@ -30,13 +31,14 @@ class ExamQuestion(models.Model):
|
||||
_rec_name = 'question_text_short'
|
||||
|
||||
sequence = fields.Integer(string='排序', default=10)
|
||||
name = fields.Char(string='题目标题', help='内部管理用,不展示给学生')
|
||||
name = fields.Char(string='题目标题', compute='_compute_name', store=True,
|
||||
help='自动取题干文本(与题干一致),内部管理用,不展示给学生')
|
||||
question_text = fields.Html(string='题干', sanitize=True, required=True)
|
||||
question_text_short = fields.Char(
|
||||
string='题干摘要', compute='_compute_question_text_short', store=True)
|
||||
|
||||
question_type = fields.Selection(
|
||||
_QUESTION_TYPES, string='题型', required=True, default='single_choice', index=True)
|
||||
_QUESTION_TYPES, string='题型', default='single_choice', index=True)
|
||||
difficulty = fields.Selection(
|
||||
_DIFFICULTY_LEVELS, string='难度', default='medium')
|
||||
|
||||
@@ -47,12 +49,23 @@ 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', index=True,
|
||||
help='公开:同校其他老师均可查看并在组卷时选用;'
|
||||
'私有:仅出题人本人可见,其他老师看不到也无法选用。')
|
||||
|
||||
option_ids = fields.One2many(
|
||||
'yuthon.school.exam.question.option', 'question_id', string='选项')
|
||||
correct_answer = fields.Char(
|
||||
string='正确答案',
|
||||
help='判断题填 True/False;填空题填标准答案(多个空用 | 分隔)。选择题由「选项」的「正确答案」标记决定。')
|
||||
# 判断题专用:把 True/False 映射成 对/错 展示与选择,存储仍是 True/False
|
||||
correct_answer_tf = fields.Selection(
|
||||
[('True', '对'), ('False', '错')],
|
||||
string='正确答案',
|
||||
compute='_compute_correct_answer_tf', inverse='_inverse_correct_answer_tf',
|
||||
help='判断题的正确答案:对 / 错。')
|
||||
reference_answer = fields.Html(
|
||||
string='参考答案', sanitize=True,
|
||||
help='简答题的参考答案,供老师评阅与学生查看解析时参考。')
|
||||
@@ -60,7 +73,7 @@ class ExamQuestion(models.Model):
|
||||
string='答案说明', sanitize=True,
|
||||
help='答案解析:学生交卷后可查看本题解析。')
|
||||
|
||||
score = fields.Float(string='默认分值', default=1.0, required=True)
|
||||
score = fields.Float(string='默认分值', default=1.0,)
|
||||
active = fields.Boolean(string='启用', default=True)
|
||||
|
||||
@api.depends('question_text')
|
||||
@@ -71,11 +84,44 @@ class ExamQuestion(models.Model):
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
q.question_text_short = (text[:40] + '…') if len(text) > 40 else text
|
||||
|
||||
@api.depends('question_text')
|
||||
def _compute_name(self):
|
||||
import re
|
||||
for q in self:
|
||||
text = re.sub(r'<[^>]+>', ' ', q.question_text or '')
|
||||
q.name = re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""新建题目时,单选/多选题默认带 A/B/C/D 四个选项行。"""
|
||||
res = super().default_get(fields_list)
|
||||
if 'option_ids' in fields_list and res.get('question_type') in (
|
||||
'single_choice', 'multiple_choice'):
|
||||
res['option_ids'] = self._default_option_lines()
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def _default_option_lines():
|
||||
return [(0, 0, {'label': lbl, 'sequence': seq})
|
||||
for lbl, seq in (('A', 10), ('B', 20), ('C', 30), ('D', 40))]
|
||||
|
||||
@api.onchange('question_type')
|
||||
def _onchange_question_type(self):
|
||||
"""切换题型时,对判断题给出默认正确答案提示。"""
|
||||
"""切换题型时:判断题给默认正确答案;单选/多选在无选项时补 A/B/C/D。"""
|
||||
if self.question_type == 'true_false':
|
||||
self.correct_answer = 'True'
|
||||
if self.question_type in ('single_choice', 'multiple_choice') and not self.option_ids:
|
||||
self.option_ids = self._default_option_lines()
|
||||
|
||||
@api.depends('correct_answer')
|
||||
def _compute_correct_answer_tf(self):
|
||||
for q in self:
|
||||
q.correct_answer_tf = q.correct_answer if q.correct_answer in ('True', 'False') else 'True'
|
||||
|
||||
def _inverse_correct_answer_tf(self):
|
||||
for q in self:
|
||||
if q.correct_answer_tf:
|
||||
q.correct_answer = q.correct_answer_tf
|
||||
|
||||
def _get_correct_options(self):
|
||||
"""返回本题的正确选项记录(仅选择题有效)。"""
|
||||
@@ -130,6 +176,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 +229,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 +400,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 +457,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 +468,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 +522,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 +689,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 +718,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,
|
||||
@@ -21,6 +48,21 @@ class IrModuleModule(models.Model):
|
||||
help='在「模块信息 / 知识点」页展示的核心概念与使用要点(由 college 维护)',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def search_panel_select_range(self, field_name, **kwargs):
|
||||
"""搜索面板类别列:按 context 开关过滤掉计数为 0 的顶层类别。
|
||||
|
||||
背景:base 对 ir.module.module 的 search_panel_select_range 做了重写
|
||||
(ir_module.py),只要顶层类别的子树里存在任何模块就会返回该类别,
|
||||
即使它名下没有一条记录匹配当前搜索域(计数为 0、界面上不显示数字)。
|
||||
「实训模块」动作通过 context 开关 shixun_hide_empty_categories 启用本过滤,
|
||||
不影响原生「应用」页面;hierarchize/expand 等视图属性在该重写分支中不生效。
|
||||
"""
|
||||
res = super().search_panel_select_range(field_name, **kwargs)
|
||||
if field_name == 'category_id' and self.env.context.get('shixun_hide_empty_categories'):
|
||||
res['values'] = [v for v in res.get('values', []) if v.get('__count')]
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def populate_module_info(self):
|
||||
"""为 school 自建模块写入「功能亮点 / 模块介绍」文案。
|
||||
@@ -231,3 +273,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
|
||||
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 隐藏导航栏右侧的「活动」图标(日历+时钟那个,点击弹出"我的活动"下拉)
|
||||
*
|
||||
* 原理:导航栏右侧的系统托盘 (systray) 是一个组件注册表,
|
||||
* mail 模块在加载时把活动菜单注册了进去(见
|
||||
* odoo18/addons/mail/static/src/core/web/activity_menu.js 最后一行
|
||||
* registry.category("systray").add("mail.activity_menu", ...))。
|
||||
* 这里在资源包加载阶段把它从注册表里摘掉,导航栏就不会渲染它。
|
||||
*
|
||||
* 如需恢复:删除本文件并把 __manifest__.py assets 里对应的一行去掉即可。
|
||||
*
|
||||
* 注意:本文件改动需要「升级模块」才会生效(-d <库名> -u college),
|
||||
* 并强刷浏览器 (Ctrl+Shift+R)。
|
||||
*/
|
||||
/** @odoo-module **/
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
registry.category("systray").remove("mail.activity_menu");
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 隐藏导航栏「公司切换器」(YourCompany 那一块)
|
||||
*
|
||||
* 原理:Odoo 导航栏右侧的系统托盘 (systray) 是一个组件注册表,
|
||||
* web 模块在启动时把 SwitchCompanyMenu 注册了进去(见
|
||||
* odoo18/addons/web/static/src/webclient/switch_company_menu/switch_company_menu.js
|
||||
* 最后一行 registry.category("systray").add(...))。
|
||||
* 这里在资源包加载阶段把它从注册表里摘掉,导航栏就不会渲染它。
|
||||
*
|
||||
* 单公司场景下该组件无意义;如需恢复,删除本文件并把
|
||||
* __manifest__.py assets 里对应的一行去掉即可。
|
||||
*
|
||||
* 注意:本文件改动需要「升级模块 + 重启」才会生效
|
||||
* (-d <库名> -u college 重启主进程),并强刷浏览器 (Ctrl+Shift+R)。
|
||||
*/
|
||||
/** @odoo-module **/
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
registry.category("systray").remove("SwitchCompanyMenu");
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 右上角头像下拉菜单:只保留「退出登录」
|
||||
*
|
||||
* 原理:Odoo 前端把头像菜单的每个条目注册到
|
||||
* registry.category("user_menuitems") 里(web / web_tour / hr /
|
||||
* yuthon_base 的 udoo_om_ux、quick_navigation 等模块都会往里加)。
|
||||
* 这里在资源包加载阶段把白名单之外的条目全部摘掉,并监听 UPDATE 事件,
|
||||
* 保证晚于本文件注册的条目(例如以后新装的模块)也会被自动移除,
|
||||
* 因此不用关心资源加载顺序。
|
||||
*
|
||||
* 被移除的条目一览(想恢复哪个,把它的 key 加进 KEEP 即可):
|
||||
* documentation 文档 (web)
|
||||
* support 支持 (web)
|
||||
* shortcuts 快捷键 (web)
|
||||
* profile 偏好 (web / hr)
|
||||
* odoo_account Odoo.com 账户 (web)
|
||||
* install_pwa 安装应用 (web)
|
||||
* separator 分隔线 (web)
|
||||
* web_tour.tour_enabled 新手上路 (web_tour)
|
||||
* appearance 外观 (udoo_om_ux)
|
||||
* separator42 分隔线 (udoo_om_ux)
|
||||
* quick_menu_config 快捷菜单配置 (quick_navigation)
|
||||
*
|
||||
* 注意:本文件改动需要「升级模块 + 重启」才会生效
|
||||
* (-d <库名> -u college),并强刷浏览器 (Ctrl+Shift+R)。
|
||||
*/
|
||||
/** @odoo-module **/
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const KEEP = new Set(["log_out"]);
|
||||
const userMenuRegistry = registry.category("user_menuitems");
|
||||
|
||||
/**
|
||||
* 移除所有不在白名单里的菜单项。
|
||||
* 用 getEntries() 先取快照再删,避免边遍历边删。
|
||||
*/
|
||||
function pruneUserMenu() {
|
||||
const keys = userMenuRegistry.getEntries().map(([key]) => key);
|
||||
for (const key of keys) {
|
||||
if (!KEEP.has(key)) {
|
||||
userMenuRegistry.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1) 清理已经注册过的条目
|
||||
pruneUserMenu();
|
||||
|
||||
// 2) 监听后续新增:只处理 add,不处理 delete,避免自己触发自己造成递归。
|
||||
// 注意:Owl 的 EventBus 继承自 EventTarget,trigger 用 CustomEvent 派发,
|
||||
// 监听函数收到的是事件对象,注册表传的 payload 在 ev.detail 里。
|
||||
userMenuRegistry.addEventListener("UPDATE", (ev) => {
|
||||
const { operation, key } = ev.detail || {};
|
||||
if (operation === "add" && !KEEP.has(key)) {
|
||||
userMenuRegistry.remove(key);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/** @odoo-module **/
|
||||
import { registry } from "@web/core/registry";
|
||||
import { user } from "@web/core/user";
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
|
||||
export class PortalIndicator extends Component {
|
||||
static template = "college.PortalIndicator";
|
||||
|
||||
setup() {
|
||||
this.state = useState({ label: "" });
|
||||
onWillStart(async () => {
|
||||
// 顺序:管理员 -> 老师 -> 学生(高权限优先)
|
||||
if (await user.hasGroup("base.group_system")) {
|
||||
this.state.label = "管理员端";
|
||||
} else if (await user.hasGroup("college.group_teacher")) {
|
||||
this.state.label = "教师端";
|
||||
} else if (await user.hasGroup("college.group_student")) {
|
||||
this.state.label = "学生端";
|
||||
} else {
|
||||
this.state.label = "用户端";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// sequence 越小越靠右(user_menu 是 0)。想贴最右边用负数。
|
||||
registry.category("systray").add(
|
||||
"college.PortalIndicator",
|
||||
{ Component: PortalIndicator },
|
||||
{ sequence: 1}
|
||||
);
|
||||
@@ -267,3 +267,26 @@
|
||||
p, li { color: $o-main-text-color; }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 隐藏右上角「公司切换器」(我的学校)
|
||||
// 项目为单学校场景,不需要用户切换公司。
|
||||
// ============================================================
|
||||
.o_switch_company_menu {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
// 角色提示徽章:避免被 Odoo 导航栏 .badge 通知样式影响
|
||||
.o_college_portal_indicator {
|
||||
align-self: center; // 在 systray 中垂直居中,不被拉伸/裁切
|
||||
}
|
||||
|
||||
.o_portal_badge {
|
||||
display: inline-block;
|
||||
padding: 0.2em 0.7em;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
color: black;
|
||||
white-space: nowrap; // 不换行
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// 关于页(yuthon.about.page)为只读展示页:
|
||||
// form 视图已设 edit="false",但教师组有该模型写权限时控制面板仍会渲染「编辑」按钮,
|
||||
// 这里用 CSS 精准隐藏该页面的编辑按钮(作用域限定 o_about_full_form,不影响其他表单)。
|
||||
.o_form_view.o_about_full_form .o_form_button_edit,
|
||||
.o_form_view.o_about_full_form button[data-hotkey="e"] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<div t-name="college.PortalIndicator"
|
||||
class="o_college_portal_indicator d-flex align-items-center ms-2">
|
||||
<span class="o_portal_badge" t-esc="state.label"/>
|
||||
</div>
|
||||
</templates>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
侧边栏窄模式悬浮提示
|
||||
|
||||
背景:udoo_om_ux 左侧导航栏(.o_sidenav)拖窄 / 切换窄模式(宽度 < 142 时加 .sm)
|
||||
后,菜单文字 .nav-title 被 display:none 隐藏、只剩图标,而原生 title 属性恰好在
|
||||
被隐藏的 span 上,导致鼠标悬停看不到任何菜单名提示。
|
||||
|
||||
做法:继承 wub.NavBar.AppsMenu,给每个菜单外壳 div.nav-item 挂 Odoo 原生
|
||||
data-tooltip(黑色气泡,与系统其它提示一致),只在窄模式输出。
|
||||
|
||||
⚠ 为什么必须挂在外壳 div 上,而不是里面的 DropdownItem(踩过的坑):
|
||||
Odoo 在开发者模式(URL 带 ?debug=1,debug 值会存进 session)下,模板继承的
|
||||
applyInheritance() 会给每个被修改的元素「前面」插入一个调试注释节点
|
||||
(见 odoo/addons/web/static/src/core/template_inheritance.js:260)。
|
||||
- 如果改的是 .nav-item 的子元素,这个注释就成了 .nav-item 的 firstChild;
|
||||
而主题 OmuxNavBar.preSubnavTiming 里写死了
|
||||
evTarget.firstChild.classList.contains('on')
|
||||
注释节点没有 classList → 报
|
||||
TypeError: Cannot read properties of undefined (reading 'contains')
|
||||
- 改外壳后,注释落在 .sidenav_cont 里当兄弟节点:主题其它地方用的是
|
||||
element.children[...](只数元素,忽略注释),完全不受影响,悬停范围还是整行。
|
||||
|
||||
想改气泡方向把下面的 'right' 改成 'bottom' 即可。
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="wub.NavBar.AppsMenu" t-inherit-mode="extension">
|
||||
<!-- 应用列表项(t-foreach 渲染出来的那一批) -->
|
||||
<xpath expr="//div[hasclass('nav-item')][@t-foreach]" position="attributes">
|
||||
<attribute name="t-att-data-tooltip">ue.sidenavWidth < 142 ? (app.label or app.name) : false</attribute>
|
||||
<attribute name="data-tooltip-position">right</attribute>
|
||||
</xpath>
|
||||
<!-- 收藏夹里「当前应用不在收藏中」时额外显示的那一项 -->
|
||||
<xpath expr="//div[hasclass('nav-item')][@t-if][not(@t-foreach)]" position="attributes">
|
||||
<attribute name="t-att-data-tooltip">ue.sidenavWidth < 142 ? (currentApp.label or currentApp.name) : false</attribute>
|
||||
<attribute name="data-tooltip-position">right</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -7,7 +7,8 @@
|
||||
<field name="name">yuthon.about.page.form</field>
|
||||
<field name="model">yuthon.about.page</field>
|
||||
<field name="arch" type="xml">
|
||||
<form class="o_about_full_form">
|
||||
<!-- create/edit 关掉:关于页内容由代码预置维护,页面右上角不再显示「创建/编辑」按钮 -->
|
||||
<form class="o_about_full_form" create="false" edit="false">
|
||||
<field name="content" widget="html" readonly="1" class="o_about_full"/>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 章节:学生学习表单(弹窗播放视频/下载附件) ===================== -->
|
||||
<!-- ===================== 章节:学生学习表单(全屏播放视频/下载附件) ===================== -->
|
||||
<record id="yuthon_school_course_chapter_study_form" model="ir.ui.view">
|
||||
<field name="name">yuthon.school.course.chapter.study.form</field>
|
||||
<field name="model">yuthon.school.course.chapter</field>
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
invisible="[('state', '!=', 'published')]"/>
|
||||
<button name="action_to_draft" type="object" string="转为草稿"
|
||||
invisible="[('state', '=', 'draft')]"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,published,archived"/>
|
||||
<!-- <field name="state" widget="statusbar"-->
|
||||
<!-- statusbar_visible="draft,published,archived"/>-->
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
@@ -55,9 +55,6 @@
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="课程内容">
|
||||
<group>
|
||||
<field name="syllabus" string="课程大纲"/>
|
||||
</group>
|
||||
<separator string="课程章节(每章含视频与资料)"/>
|
||||
<field name="chapter_ids">
|
||||
<list editable="top">
|
||||
@@ -107,6 +104,23 @@
|
||||
<page string="业务情景">
|
||||
<field name="business_scenario"/>
|
||||
</page>
|
||||
|
||||
<page string="学习进度" groups="college.group_teacher,base.group_system">
|
||||
<field name="progress_ids" mode="kanban,list" readonly="1"
|
||||
context="{'kanban_view_ref': 'college.yuthon_school_progress_view_kanban', 'tree_view_ref': 'college.yuthon_school_progress_view_list'}"/>
|
||||
</page>
|
||||
<page string="作业布置" groups="college.group_teacher,base.group_system">
|
||||
<field name="assignment_ids">
|
||||
<list>
|
||||
<field name="name" string="作业标题"/>
|
||||
<field name="chapter_id" string="对应章节"/>
|
||||
<field name="due_date" string="截止日期"/>
|
||||
<field name="submission_count" string="提交数"/>
|
||||
<field name="state" string="状态"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
@@ -143,7 +157,6 @@
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="课程内容">
|
||||
<field name="syllabus" readonly="1" string="课程大纲"/>
|
||||
<separator string="课程章节(按顺序学习视频与资料)"/>
|
||||
<field name="chapter_ids" readonly="1">
|
||||
<list>
|
||||
@@ -200,6 +213,10 @@
|
||||
<filter name="group_state" string="状态"
|
||||
context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
<searchpanel>
|
||||
<field name="course_type" string="课程模块" enable_counters="1"/>
|
||||
<field name="college" string="学院" enable_counters="1"/>
|
||||
</searchpanel>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
@@ -209,8 +226,7 @@
|
||||
<field name="name">yuthon.school.course.kanban</field>
|
||||
<field name="model">yuthon.school.course</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="state"
|
||||
default_order="semester,college,name"
|
||||
<kanban default_order="semester,college,name"
|
||||
class="college-course-kanban college-kanban-teacher o_kanban_mobile">
|
||||
<field name="name"/>
|
||||
<field name="college"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+123
-31
@@ -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>
|
||||
@@ -29,12 +30,13 @@
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="question_text" widget="html" string="题干" required="1"/>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="question_type"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="score"/>
|
||||
<field name="visibility"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="course_id"/>
|
||||
@@ -42,7 +44,6 @@
|
||||
<field name="teacher_id" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="question_text" widget="html" string="题干"/>
|
||||
<notebook>
|
||||
<page string="选项" name="options"
|
||||
invisible="question_type not in ('single_choice','multiple_choice')">
|
||||
@@ -60,9 +61,12 @@
|
||||
</page>
|
||||
<page string="答案与解析" name="answer">
|
||||
<group>
|
||||
<field name="correct_answer_tf"
|
||||
invisible="question_type != 'true_false'"
|
||||
required="question_type == 'true_false'"/>
|
||||
<field name="correct_answer"
|
||||
invisible="question_type not in ('true_false','fill_blank')"
|
||||
required="question_type in ('true_false','fill_blank')"/>
|
||||
invisible="question_type != 'fill_blank'"
|
||||
required="question_type == 'fill_blank'"/>
|
||||
<field name="reference_answer" widget="html"
|
||||
invisible="question_type != 'essay'"/>
|
||||
</group>
|
||||
@@ -80,17 +84,19 @@
|
||||
<field name="model">yuthon.school.exam.question</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="question_text"/>
|
||||
<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 +120,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 +136,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 +159,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 +183,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 +204,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 +222,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 +239,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 +300,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 +332,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 +363,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 +511,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 +526,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 +555,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 +573,5 @@
|
||||
<field name="search_view_id" ref="yuthon_school_exam_answer_view_search"/>
|
||||
<field name="domain">[('paper_id.state','=','submitted')]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -44,9 +44,13 @@
|
||||
<field name="name" placeholder="请输入作业标题"/>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group>
|
||||
<field name="course_id"/>
|
||||
<field name="chapter_id"
|
||||
domain="[('course_id', '=', course_id)]"
|
||||
context="{'default_course_id': course_id}"/>
|
||||
<field name="teacher_id" readonly="1"/>
|
||||
<field name="due_date"/>
|
||||
</group>
|
||||
@@ -55,6 +59,7 @@
|
||||
<field name="attachment_filename" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="作业说明">
|
||||
<field name="description"/>
|
||||
@@ -119,7 +124,7 @@
|
||||
<field name="name">yuthon.school.assignment.submission.list</field>
|
||||
<field name="model">yuthon.school.assignment.submission</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="作业提交" multi_edit="1">
|
||||
<list string="作业提交" multi_edit="1" create="0">
|
||||
<header>
|
||||
<button name="action_open_batch_wizard"
|
||||
type="object" string="批量批改"
|
||||
@@ -146,7 +151,8 @@
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="state"
|
||||
default_order="due_date desc, id"
|
||||
class="college-course-kanban college-kanban-homework o_kanban_mobile">
|
||||
class="college-course-kanban college-kanban-homework o_kanban_mobile"
|
||||
create="0">
|
||||
<field name="assignment_id"/>
|
||||
<field name="student_id"/>
|
||||
<field name="score"/>
|
||||
|
||||
+137
-30
@@ -12,16 +12,15 @@
|
||||
<menuitem id="menu_yuthon_product_intro"
|
||||
name="产品介绍"
|
||||
web_icon="college,static/src/img/product_intro.png"
|
||||
sequence="0"
|
||||
sequence="-10"
|
||||
action="action_yuthon_product_intro"
|
||||
groups="college.group_teacher,college.group_student,base.group_system"/>
|
||||
|
||||
<!-- ===================== 我的实训(顶级菜单,置顶) ===================== -->
|
||||
<!-- ===================== 我的实训(顶级菜单,置顶;作为实训相关功能的容器) ===================== -->
|
||||
<menuitem id="menu_yuthon_training"
|
||||
name="我的实训"
|
||||
web_icon="college,static/src/img/training.png"
|
||||
sequence="1"
|
||||
action="action_yuthon_school_training"
|
||||
groups="college.group_teacher,college.group_student,base.group_system"/>
|
||||
|
||||
<!-- ===================== 我的课程(统一入口,按用户角色路由) ===================== -->
|
||||
@@ -40,13 +39,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"/>
|
||||
|
||||
<!-- ===================== 隐藏 Odoo 默认「讨论」菜单 ===================== -->
|
||||
<record id="mail.menu_root_discuss" model="ir.ui.menu">
|
||||
@@ -147,16 +153,16 @@ 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="课程管理"
|
||||
web_icon="college,static/src/img/Course Management.png"
|
||||
sequence="10"
|
||||
groups="college.group_teacher,base.group_system"/>
|
||||
<!-- 全部课程(原来直连的 action 移到这里) -->
|
||||
<menuitem id="menu_yuthon_course_all"
|
||||
name="全部课程"
|
||||
parent="menu_yuthon_course_root"
|
||||
action="action_yuthon_school_course_all"
|
||||
sequence="10"
|
||||
groups="college.group_teacher,base.group_system"/>
|
||||
@@ -197,48 +203,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 +275,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 +295,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 -->
|
||||
@@ -290,6 +340,20 @@ else:
|
||||
<field name="target">current</field>
|
||||
</record>
|
||||
|
||||
<!-- 团队介绍:打开预置记录 about_page_team -->
|
||||
<record id="action_yuthon_about_team" model="ir.actions.act_window">
|
||||
<field name="name">团队介绍</field>
|
||||
<field name="res_model">yuthon.about.page</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="res_id" ref="about_page_team"/>
|
||||
<field name="target">current</field>
|
||||
</record>
|
||||
<menuitem id="menu_yuthon_about_team"
|
||||
name="团队介绍"
|
||||
parent="menu_yuthon_about"
|
||||
action="action_yuthon_about_team"
|
||||
sequence="30"/>
|
||||
|
||||
<!-- 课程导入动作(关联 yuthon.school.course.import.wizard) -->
|
||||
<record id="action_yuthon_school_course_import_wizard" model="ir.actions.act_window">
|
||||
<field name="name">课程导入</field>
|
||||
@@ -316,14 +380,57 @@ else:
|
||||
sequence="20"/>
|
||||
<menuitem id="menu_yuthon_import_wizard"
|
||||
name="导入师生"
|
||||
parent="menu_yuthon_about"
|
||||
parent="menu_yuthon_org_staff"
|
||||
action="action_yuthon_school_import_wizard"
|
||||
sequence="30"
|
||||
sequence="40"
|
||||
groups="college.group_teacher,base.group_system"/>
|
||||
<menuitem id="menu_yuthon_course_import_wizard"
|
||||
name="课程导入"
|
||||
parent="menu_yuthon_about"
|
||||
parent="menu_yuthon_course_root"
|
||||
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_exam_question"
|
||||
action="action_yuthon_about_question_import"
|
||||
sequence="60"
|
||||
groups="college.group_teacher,base.group_system"/>
|
||||
|
||||
<!-- ===================== 应用类别配置(挂在「实训模块」下) ===================== -->
|
||||
<!-- Odoo 默认只有 form 视图,没有 list 视图,需要先补一个 -->
|
||||
<record id="view_yuthon_module_category_list" model="ir.ui.view">
|
||||
<field name="name">ir.module.category.list (college)</field>
|
||||
<field name="model">ir.module.category</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="应用类别">
|
||||
<field name="name" string="名称"/>
|
||||
<field name="description" string="描述"/>
|
||||
<field name="sequence" string="排序"/>
|
||||
<field name="parent_id" string="上级应用"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_yuthon_module_category" model="ir.actions.act_window">
|
||||
<field name="name">应用类别配置</field>
|
||||
<field name="res_model">ir.module.category</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_yuthon_module_category"
|
||||
name="应用类别配置"
|
||||
parent="menu_shixun_module"
|
||||
action="action_yuthon_module_category"
|
||||
sequence="10"/>
|
||||
</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,21 @@
|
||||
</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>
|
||||
<!-- 隐藏搜索面板「应用」组(全部/官方应用程序/行业,来自 base_import_module 的 module_type facet),保留「类别」列 -->
|
||||
<xpath expr="//searchpanel/field[@name='module_type']" position="replace"/>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -35,15 +35,7 @@ class CourseHomework(models.Model):
|
||||
teaching_class_id = fields.Many2one('course.teaching_class', string='教学班',
|
||||
ondelete='cascade', domain="[('course_id', '=', course_id)]")
|
||||
submit_ids = fields.One2many('course.homework.submit', 'homework_id', string='提交记录')
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
score_item_ids = fields.One2many('course.score.item', 'homework_id', string='成绩项目')
|
||||
=======
|
||||
score_item_ids = fields.One2many('course.score.item', 'homework_id', string='成绩项')
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
score_item_ids = fields.One2many('course.score.item', 'homework_id', string='成绩项')
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
|
||||
# ====================== 新增:自动计算提交通道是否开启 ======================
|
||||
@api.depends('state', 'deadline')
|
||||
@@ -90,8 +82,6 @@ class CourseHomework(models.Model):
|
||||
self.publish_time = fields.Datetime.now()
|
||||
# 1. 批量为本班学生生成空白未提交记录
|
||||
self._auto_create_empty_submit()
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
# 新增:自动生成对应作业的学生成绩明细
|
||||
self._auto_create_homework_score_detail()
|
||||
# 2. 推送作业通知给学生
|
||||
@@ -147,15 +137,7 @@ class CourseHomework(models.Model):
|
||||
|
||||
if detail_vals_list:
|
||||
self.env['course.student.score.detail'].create(detail_vals_list)
|
||||
=======
|
||||
# 2. 推送作业通知给学生
|
||||
self._send_homework_notice_to_students()
|
||||
|
||||
=======
|
||||
# 2. 推送作业通知给学生
|
||||
self._send_homework_notice_to_students()
|
||||
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
def _send_homework_notice_to_students(self):
|
||||
"""发布作业后推送消息给教学班学生"""
|
||||
self.ensure_one()
|
||||
@@ -184,10 +166,6 @@ class CourseHomework(models.Model):
|
||||
partner_ids=student_users.mapped('partner_id').ids,
|
||||
subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
<<<<<<< HEAD
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
|
||||
def _auto_create_empty_submit(self):
|
||||
"""仅发布时执行:自动给教学班所有已选课学生生成空白未提交记录"""
|
||||
@@ -215,53 +193,12 @@ class CourseHomework(models.Model):
|
||||
batch_vals.append({
|
||||
'homework_id': self.id,
|
||||
'student_id': stu_id,
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
'course_score_item_id':self.course_score_item_id.id,
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
'state': 'unsubmit' # 默认未提交
|
||||
})
|
||||
# 批量创建空白提交记录
|
||||
submit_model.create(batch_vals)
|
||||
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
def _send_homework_notice_to_students(self):
|
||||
"""发布作业后推送消息给教学班学生"""
|
||||
self.ensure_one()
|
||||
if not self.teaching_class_id:
|
||||
return
|
||||
# 获取当前教学班所有已选课学生
|
||||
enrolls = self.env['course.teaching_class.enrollment'].search([
|
||||
('teaching_class_id', '=', self.teaching_class_id.id),
|
||||
('state', '=', 'enrolled')
|
||||
])
|
||||
student_users = enrolls.mapped('student_id.user_id').filtered(lambda u: u)
|
||||
if not student_users:
|
||||
return
|
||||
# 消息内容
|
||||
subject = f"新作业发布:{self.name}"
|
||||
body = f"""
|
||||
<p>课程:{self.course_id.name}</p>
|
||||
<p>作业名称:{self.name}</p>
|
||||
<p>截止时间:{self.deadline or '无'}</p>
|
||||
<p>作业要求:{self.requirement or '无'}</p>
|
||||
"""
|
||||
# 发送消息(关联当前作业记录,学生在消息中心可直接跳转)
|
||||
self.message_post(
|
||||
subject=subject,
|
||||
body=body,
|
||||
partner_ids=student_users.mapped('partner_id').ids,
|
||||
subtype_xmlid='mail.mt_note'
|
||||
)
|
||||
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
# ====================== 草稿/关闭作业 ======================
|
||||
def action_draft(self):
|
||||
self.state = 'draft'
|
||||
@@ -388,21 +325,14 @@ class CourseHomework(models.Model):
|
||||
def create(self, vals):
|
||||
homework_record = super().create(vals)
|
||||
# 草稿状态不执行自动生成提交记录,移到action_publish
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
|
||||
target_a = self.env["course.score.item"].search([
|
||||
("name", "=", "作业"),
|
||||
("create_uid", "=", self.env.uid)
|
||||
], limit=1)
|
||||
|
||||
if target_a:
|
||||
# 将刚新建的B追加到A的多对多字段
|
||||
# 将新建的作业追加到"作业"成绩项目的多对多字段
|
||||
target_a.write({
|
||||
"homework_ids": [(4, homework_record.id)]
|
||||
})
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
return homework_record
|
||||
@@ -93,13 +93,6 @@ class CourseHomeworkSubmit(models.Model):
|
||||
def create(self, vals):
|
||||
submit_rec = super().create(vals)
|
||||
self._check_submit_change_state(submit_rec)
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
return submit_rec
|
||||
|
||||
def write(self, vals):
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# OdooshCN Lab Environments
|
||||
|
||||
Syncs the users of this Odoo instance to an OdooshCN platform and gives every user a
|
||||
one-click entry to their own lab environment.
|
||||
|
||||
## Install
|
||||
|
||||
1. Add the parent folder to `addons_path`:
|
||||
|
||||
```
|
||||
addons_path = ...,D:\odoo_project\odoo18-2\odoo\custom_project\school-python
|
||||
```
|
||||
|
||||
2. Restart Odoo, update the app list and install **OdooshCN Lab Environments**.
|
||||
|
||||
## Configure
|
||||
|
||||
### 1. Create an access token on OdooshCN
|
||||
|
||||
Sign in as a super administrator, open the user menu in the top right corner and choose
|
||||
**Access tokens**, then create one:
|
||||
|
||||
| Field | Suggested value |
|
||||
|---|---|
|
||||
| Name | Odoo course system |
|
||||
| Source | `school` (synced users are recorded under this source) |
|
||||
| Organisation | where the users should land |
|
||||
| Scopes | tick all four |
|
||||
| Maximum grantable role | Organisation manager, if any user needs that role |
|
||||
| Instances per user | 1 |
|
||||
| Allow deleting users | on, if deleting a user here should delete the platform account |
|
||||
| Allow other organisations / platform administrator | off |
|
||||
|
||||
The token is shown once, so copy it immediately. Scopes can be edited later without
|
||||
changing the token value.
|
||||
|
||||
### 2. Fill it in here
|
||||
|
||||
Settings > General Settings > OdooshCN Lab Environments:
|
||||
|
||||
1. Turn on **Enable sync**.
|
||||
2. Enter the platform URL and the token, then press **Test connection**. It validates the
|
||||
URL and token only, reports the organisation, the scopes and the limits, and warns when
|
||||
a scope is missing.
|
||||
For local testing use `http://127.0.0.1:port` rather than `localhost`: on Windows
|
||||
`localhost` resolves to IPv6 first while WSL2 and Docker only forward IPv4, and every
|
||||
request would wait out a 20 second timeout. The module rewrites localhost anyway, but
|
||||
an explicit address is safer.
|
||||
3. Adjust the environment defaults if needed and save.
|
||||
|
||||
That is all. There is nothing to configure per user.
|
||||
|
||||
## Daily use
|
||||
|
||||
New users are synced automatically. Everything the platform knows about a user sits on the
|
||||
**Lab Environment** tab of the user form:
|
||||
|
||||
| Field | Meaning | Default |
|
||||
|---|---|---|
|
||||
| Sync to lab platform | Master switch for this user | On |
|
||||
| Platform username override | Empty derives it from the login | Empty |
|
||||
| Organisation | Platform organisation slug | Empty (default) |
|
||||
| Platform role | Regular user or platform administrator | Regular user |
|
||||
| Role in organisation | Tester / Developer / Organisation manager | Developer |
|
||||
| Web Shell, Enterprise edition, Auto-provision | Switches | On / Off / On |
|
||||
| Instance limit, Extra database limit | 0 means unlimited | 1 / 2 |
|
||||
| Platform menus | Overview / Git / AI / Backups | Off / Off / On / Off |
|
||||
|
||||
Select several users in the list and use the **Actions** menu to sync, enable or disable
|
||||
them in bulk.
|
||||
|
||||
Users open their environment from the **Lab Environment** menu. The platform console - the
|
||||
instance list and the instance detail page - is embedded in a frame on that same page, so
|
||||
nobody leaves the course system, with an *Open in a new tab* button for a full window. The
|
||||
instance's own Odoo always opens in a new tab: nesting a whole Odoo inside another one
|
||||
stacks three navigation bars and two sidebars.
|
||||
|
||||
For the frame to work the platform has to allow being embedded by this Odoo. Its console
|
||||
answers `X-Frame-Options: SAMEORIGIN` by default and the frame stays blank; the platform
|
||||
administrator drops a file `console-frame-odoo.inc` into `<data dir>/nginx/` naming this
|
||||
server, then reloads the platform's web container:
|
||||
|
||||
```
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' http://school.example.com:8069" always;
|
||||
```
|
||||
|
||||
The value has to match what users actually have in the address bar, scheme and port
|
||||
included: `http://localhost:8069` does not cover `http://the-machine-name:8069`.
|
||||
|
||||
## What happens when
|
||||
|
||||
| In Odoo | On the platform |
|
||||
|---|---|
|
||||
| Create a user | Account created, sync switch turned on, environment provisioned |
|
||||
| Update a user | Pushed when the switch is on, ignored when it is off |
|
||||
| Archive a user | Account deactivated, instances stopped, nothing deleted |
|
||||
| Restore an archived user | Account activated again |
|
||||
| Delete a user | Account deleted; the instances move to the platform recycle bin, where the data stays recoverable for the retention period |
|
||||
|
||||
Deleting requires the access token to allow it. Without that permission the platform
|
||||
refuses and the queue row shows `delete_not_allowed`; the account is then only deactivated.
|
||||
|
||||
Portal users and built-in Odoo accounts (OdooBot, the public user, the portal template and
|
||||
the new-user default template) are never synced.
|
||||
|
||||
## How syncing works
|
||||
|
||||
The hooks only write a row into `odoosh.sync.log`; **no HTTP happens inside a user
|
||||
request**. A dedicated thread pushes right after the transaction commits, using its own
|
||||
database connection, so a failure there can never affect what was already saved.
|
||||
|
||||
Three paths back each other up:
|
||||
|
||||
| Path | Trigger | Purpose |
|
||||
|---|---|---|
|
||||
| Background thread | Right after commit | The user barely notices a delay |
|
||||
| Queue cron | Every minute | Takes over when the thread was killed with the worker, and drives the retry back-off |
|
||||
| User scan | Inside that same cron, every 30 minutes | Walks users changed since the last scan and compares fingerprints |
|
||||
|
||||
The scan has no cron of its own: it is cheap, but Odoo's cron pool is shared by the whole
|
||||
database (`max_cron_threads`, 2 by default), so a slot costs more than the scan does. Set the
|
||||
system parameter `odoosh.scan_interval_minutes` to change the interval, or to `0` to turn the
|
||||
scan off and rely on the hooks alone.
|
||||
|
||||
Cost control: only `login`, `name`, `active`, the sync switch and the platform permission
|
||||
fields trigger a push, so changing an avatar or a preference does not; the scan reads at
|
||||
most 200 users per round through an index; identical content is never pushed twice.
|
||||
|
||||
A daily reconciliation sends the full list of users so the platform can deactivate accounts
|
||||
that disappeared. An empty local list is skipped, and the platform refuses a list that
|
||||
would deactivate an implausible share of the accounts.
|
||||
|
||||
## Security
|
||||
|
||||
* The token is stored in a system parameter, readable by system administrators only, and
|
||||
the settings page shows only a mask plus the last four characters.
|
||||
* The identity sent to the platform always comes from the current session, never from a
|
||||
request parameter. Otherwise a user could edit a URL and land in somebody else's
|
||||
environment.
|
||||
* Entry URLs are short lived and single use: they are redirected to immediately, never
|
||||
stored and never logged.
|
||||
|
||||
## Adjusting the code
|
||||
|
||||
Everything a user gets is a field, so day-to-day changes need no code. If the mapping
|
||||
itself has to change, it lives in `models/res_users.py`:
|
||||
|
||||
* `_odoosh_payload()` builds the user payload from the fields.
|
||||
* `_odoosh_env_spec()` builds the environment specification.
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
{
|
||||
'name': 'OdooshCN Lab Environments',
|
||||
'category': 'Productivity',
|
||||
'version': '18.0.2.3.0',
|
||||
'sequence': 20,
|
||||
'summary': "Sync users to the OdooshCN platform and let them open their own lab environment in one click",
|
||||
'description': """
|
||||
OdooshCN Lab Environments
|
||||
=========================
|
||||
|
||||
Syncs the users of this system to an OdooshCN platform and gives every user a
|
||||
one-click entry to their own lab environment, without a second set of credentials.
|
||||
|
||||
Features
|
||||
--------
|
||||
* **User sync** - creating, updating, archiving or deleting a user is pushed to
|
||||
OdooshCN asynchronously. The hooks only write to a local queue, so the platform
|
||||
being slow or down never blocks user management here.
|
||||
* **Per-user permissions** - the platform role, organisation, quotas, Web Shell,
|
||||
enterprise edition and menu visibility are plain fields on the user form.
|
||||
Change a field, it is synced.
|
||||
* **One-click entry** - a menu entry opens the user's own lab environment with no
|
||||
extra login.
|
||||
* **Environment board** - each user sees the name, status and version of their
|
||||
own environments, read live from the platform.
|
||||
* **Sync queue** - administrators can inspect every push, its payload, its result
|
||||
and retry the failed ones.
|
||||
|
||||
Rules
|
||||
-----
|
||||
1. Once the platform URL and access token are configured, a new user is synced
|
||||
immediately and their sync switch is turned on automatically.
|
||||
2. Updating a user syncs when the switch is on, and does nothing when it is off.
|
||||
3. Archiving deactivates the platform account; deleting deletes it; restoring an
|
||||
archived user activates it again.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
Settings > General Settings > OdooshCN Lab Environments: fill in the platform URL
|
||||
and the access token, then press *Test connection*.
|
||||
""",
|
||||
'author': 'Charles',
|
||||
'website': '',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['base', 'base_setup', 'web', 'college'],
|
||||
'data': [
|
||||
'security/odoosh_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_cron_data.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/res_users_views.xml',
|
||||
'views/odoosh_sync_log_views.xml',
|
||||
'views/odoosh_environment_views.xml',
|
||||
'views/odoosh_menus.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'odoosh_connector/static/src/js/odoosh_embed.js',
|
||||
'odoosh_connector/static/src/xml/odoosh_embed.xml',
|
||||
'odoosh_connector/static/src/scss/odoosh.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import main
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""User-facing entry points.
|
||||
|
||||
Hard rule: the identity sent to OdooshCN always comes from request.env.user, never from a
|
||||
request parameter. Otherwise a user could edit the URL and land in somebody else's
|
||||
environment. No line in this file reads an identity from kw.
|
||||
|
||||
The access token is read on the server only (inside odoosh.client) and never reaches the
|
||||
browser.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.http import request
|
||||
|
||||
from ..models.odoosh_client import OdooshError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OdooshPortal(http.Controller):
|
||||
|
||||
def _guard(self):
|
||||
"""Whether the current user may use a lab environment."""
|
||||
user = request.env.user
|
||||
if not user or user._is_public():
|
||||
return _("Please sign in first.")
|
||||
if not user.sudo()._odoosh_in_scope():
|
||||
return _("Your account is not set up for lab environments. Ask your administrator.")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ redirect entry
|
||||
@http.route('/odoosh/go', type='http', auth='user', website=False)
|
||||
def go(self, target='console', env=None, **kw):
|
||||
"""Exchange for a passwordless URL and redirect. target=console opens the console,
|
||||
target=odoo goes straight into the instance.
|
||||
|
||||
`env` is only an environment name, not an identity; the platform still checks that
|
||||
the environment belongs to the current user."""
|
||||
error = self._guard()
|
||||
if error:
|
||||
return request.render('odoosh_connector.entry_error', {'message': error})
|
||||
uid = str(request.env.user.id) # identity comes from the session, only ever from there
|
||||
try:
|
||||
result = request.env['odoosh.client'].sudo().handoff(
|
||||
uid, target='odoo' if target == 'odoo' else 'console', env=env)
|
||||
except OdooshError as err:
|
||||
_logger.warning("OdooshCN handoff failed for user#%s: %s", uid, err)
|
||||
return request.render('odoosh_connector.entry_error', {
|
||||
'message': _("Cannot open the lab environment: %s", err.message),
|
||||
'code': err.code,
|
||||
})
|
||||
return request.redirect(result['url'], local=False)
|
||||
|
||||
# ------------------------------------------------------------------ JSON for the client action
|
||||
@http.route('/odoosh/my/envs', type='json', auth='user')
|
||||
def my_envs(self, **kw):
|
||||
"""The current user's environments, for the board in the menu."""
|
||||
error = self._guard()
|
||||
if error:
|
||||
return {'error': error, 'envs': []}
|
||||
try:
|
||||
data = request.env['odoosh.client'].sudo().list_envs(str(request.env.user.id))
|
||||
except OdooshError as err:
|
||||
return {'error': err.message, 'code': err.code, 'envs': []}
|
||||
return {'envs': data.get('envs', []), 'username': data.get('username')}
|
||||
|
||||
@http.route('/odoosh/my/url', type='json', auth='user')
|
||||
def my_url(self, target='console', env=None, **kw):
|
||||
"""Exchange for a passwordless URL for the browser to open right away."""
|
||||
error = self._guard()
|
||||
if error:
|
||||
return {'error': error}
|
||||
try:
|
||||
result = request.env['odoosh.client'].sudo().handoff(
|
||||
str(request.env.user.id), target='odoo' if target == 'odoo' else 'console', env=env)
|
||||
except OdooshError as err:
|
||||
return {'error': err.message, 'code': err.code}
|
||||
return {'url': result['url'], 'expires_in': result.get('expires_in')}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Upgrading from 18.0.2.0.0: the per-minute queue cron and the 5-minute scan cron are
|
||||
merged into cron_odoosh_tick below. Matched by code rather than by XML id, so a fresh
|
||||
install stays quiet (a missing XML id logs a warning and a traceback), and restricted
|
||||
to this model so no other module's cron can be caught by the generic code strings. -->
|
||||
<delete model="ir.cron"
|
||||
search="[('model_id.model', '=', 'odoosh.sync.log'),
|
||||
('code', 'in', ['model.cron_process()', 'model.cron_scan()']),
|
||||
('active', 'in', [True, False])]"/>
|
||||
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- The only frequent cron. Every minute it drains the queue: one indexed query on a
|
||||
table that is normally empty, so an idle tick costs nothing worth measuring.
|
||||
Every `odoosh.scan_interval_minutes` minutes (30 by default, 0 disables it) the
|
||||
same tick walks the users first.
|
||||
|
||||
Why one cron instead of two: the work is tiny either way, but Odoo's cron pool is
|
||||
shared by the whole database (max_cron_threads, 2 by default), and it is the slot
|
||||
held every minute - not the CPU - that competes with other modules. -->
|
||||
<record id="cron_odoosh_tick" model="ir.cron">
|
||||
<field name="name">OdooshCN: sync queue and user scan</field>
|
||||
<field name="model_id" ref="model_odoosh_sync_log"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_tick()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Daily: send the full list of users so the platform can deactivate the ones that
|
||||
disappeared. An empty local list is skipped; the platform guards the ratio too. -->
|
||||
<record id="cron_odoosh_reconcile" model="ir.cron">
|
||||
<field name="name">OdooshCN: daily reconciliation</field>
|
||||
<field name="model_id" ref="model_odoosh_sync_log"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_reconcile()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
|
||||
</odoo>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import odoosh_client
|
||||
from . import odoosh_sync_log
|
||||
from . import res_users
|
||||
from . import res_config_settings
|
||||
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The single place that talks to the OdooshCN platform.
|
||||
|
||||
Keeping every outbound request here means retry classification, timeouts and error
|
||||
decoding have one implementation, and the access token is read in this file only.
|
||||
|
||||
Errors come in two kinds, which decide whether the queue retries or gives up:
|
||||
* OdooshRetryable - network trouble, rate limiting, platform 5xx. Worth retrying.
|
||||
* OdooshPermanent - bad parameters, name already taken, beyond the token limits.
|
||||
Retrying cannot help; a human has to look at it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from odoo import _, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT = 20 # seconds; syncing runs in a background queue, never in a user request
|
||||
PARAM_URL = 'odoosh.base_url'
|
||||
PARAM_TOKEN = 'odoosh.token'
|
||||
|
||||
|
||||
class OdooshError(Exception):
|
||||
"""A call to OdooshCN failed. `code` is the platform's stable error code."""
|
||||
|
||||
def __init__(self, message, code='unknown', status=None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
def __str__(self):
|
||||
return '[%s] %s' % (self.code, self.message)
|
||||
|
||||
|
||||
class OdooshRetryable(OdooshError):
|
||||
"""Temporary failure, worth retrying."""
|
||||
|
||||
|
||||
class OdooshPermanent(OdooshError):
|
||||
"""The caller's own problem; retrying will not help."""
|
||||
|
||||
|
||||
class OdooshClient(models.AbstractModel):
|
||||
_name = 'odoosh.client'
|
||||
_description = 'OdooshCN API Client'
|
||||
|
||||
# ------------------------------------------------------------------ configuration
|
||||
def _config(self):
|
||||
"""Read the platform URL and the token. Read here only, and always as sudo:
|
||||
regular users have no access to system parameters."""
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
base = (icp.get_param(PARAM_URL) or '').strip().rstrip('/')
|
||||
token = (icp.get_param(PARAM_TOKEN) or '').strip()
|
||||
if not base or not token:
|
||||
raise OdooshPermanent(
|
||||
_("The OdooshCN platform URL or access token is not configured yet. "
|
||||
"Go to Settings > General Settings > OdooshCN Lab Environments."),
|
||||
code='not_configured')
|
||||
return self._prefer_ipv4(base), token
|
||||
|
||||
@staticmethod
|
||||
def _prefer_ipv4(base):
|
||||
"""Local development trap on Windows: `localhost` resolves to the IPv6 address
|
||||
::1 first, while WSL2 and Docker only forward IPv4. urllib has no happy-eyeballs
|
||||
fallback, so it waits out the full timeout (20s) on every single request.
|
||||
Swapping localhost for 127.0.0.1 avoids it. Real deployments use a domain name."""
|
||||
parts = urllib.parse.urlsplit(base)
|
||||
if parts.hostname and parts.hostname.lower() == 'localhost':
|
||||
netloc = '127.0.0.1' + (':%s' % parts.port if parts.port else '')
|
||||
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
||||
return base
|
||||
|
||||
def _enabled(self):
|
||||
return self.env['ir.config_parameter'].sudo().get_param('odoosh.enabled') in ('True', 'true', '1', True)
|
||||
|
||||
# ------------------------------------------------------------------ request
|
||||
def _request(self, method, path, payload=None):
|
||||
"""Send one request and return the decoded body.
|
||||
Raises OdooshRetryable / OdooshPermanent on failure."""
|
||||
base, token = self._config()
|
||||
url = '%s%s' % (base, path)
|
||||
data = None
|
||||
headers = {'Authorization': 'Bearer %s' % token, 'Accept': 'application/json'}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode('utf-8') or '{}')
|
||||
except urllib.error.HTTPError as err:
|
||||
raise self._http_error(err, method, path)
|
||||
except urllib.error.URLError as err:
|
||||
# DNS failure, refused connection, timeout: the platform may just be restarting
|
||||
raise OdooshRetryable(_("Cannot reach OdooshCN (%(url)s): %(reason)s",
|
||||
url=base, reason=err.reason), code='unreachable')
|
||||
except json.JSONDecodeError:
|
||||
raise OdooshRetryable(_("OdooshCN returned a body that is not valid JSON"), code='bad_response')
|
||||
|
||||
def _http_error(self, err, method, path):
|
||||
"""Turn an HTTP error into a coded exception and decide whether it is worth retrying."""
|
||||
raw = ''
|
||||
try:
|
||||
raw = err.read().decode('utf-8')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
code, detail = 'http_%s' % err.code, raw or err.reason
|
||||
try:
|
||||
parsed = json.loads(raw or '{}')
|
||||
code = parsed.get('code') or code
|
||||
d = parsed.get('detail')
|
||||
if isinstance(d, list): # 422 validation errors come as a list
|
||||
d = '; '.join(str(x.get('msg') or x) for x in d)
|
||||
detail = d or detail
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
_logger.warning("OdooshCN %s %s -> %s %s", method, path, err.code, detail)
|
||||
exc = OdooshRetryable if err.code in (408, 429) or err.code >= 500 else OdooshPermanent
|
||||
return exc(detail, code=code, status=err.code)
|
||||
|
||||
# ------------------------------------------------------------------ endpoints
|
||||
def ping(self):
|
||||
"""Connectivity probe: validates URL and token only, no side effect.
|
||||
Returns the token's source, organisation, scopes and limits."""
|
||||
return self._request('GET', '/api/ext/ping')
|
||||
|
||||
def upsert_user(self, payload):
|
||||
"""Create or update a user, keyed by external_id and idempotent.
|
||||
`payload['provision']` additionally asks for a lab environment."""
|
||||
return self._request('POST', '/api/ext/users', payload)
|
||||
|
||||
def deactivate_user(self, external_id, stop_envs=True):
|
||||
"""Deactivate the platform account. Nothing is deleted."""
|
||||
return self._request('POST', '/api/ext/users/deactivate',
|
||||
{'external_id': str(external_id), 'stop_envs': stop_envs})
|
||||
|
||||
def delete_user(self, external_id, purge_envs=False):
|
||||
"""Delete the platform account for good. Requires the token to allow deletion.
|
||||
The user's environments go to the platform recycle bin unless purge_envs is set."""
|
||||
return self._request('POST', '/api/ext/users/delete',
|
||||
{'external_id': str(external_id), 'purge_envs': purge_envs})
|
||||
|
||||
def reconcile(self, external_ids, stop_envs=True, confirm=False):
|
||||
"""Full reconciliation: accounts of this source that are not in the list get
|
||||
deactivated. The platform refuses an obviously incomplete list unless confirmed."""
|
||||
return self._request('POST', '/api/ext/users/reconcile',
|
||||
{'external_ids': [str(x) for x in external_ids],
|
||||
'stop_envs': stop_envs, 'confirm': confirm})
|
||||
|
||||
def list_envs(self, uid):
|
||||
"""List the lab environments of one user."""
|
||||
return self._request('GET', '/api/ext/envs?uid=%s' % urllib.parse.quote(str(uid)))
|
||||
|
||||
def create_env(self, uid, spec):
|
||||
"""Provision one lab environment for a user."""
|
||||
body = dict(spec or {})
|
||||
body['uid'] = str(uid)
|
||||
return self._request('POST', '/api/ext/envs', body)
|
||||
|
||||
def handoff(self, uid, target='console', env=None):
|
||||
"""Exchange for a passwordless entry URL: {'url': ..., 'expires_in': ...}.
|
||||
|
||||
The URL is short lived and single use: redirect to it straight away, never
|
||||
store it and never log it."""
|
||||
body = {'uid': str(uid), 'target': target}
|
||||
if env:
|
||||
body['env'] = env
|
||||
return self._request('POST', '/api/ext/handoff', body)
|
||||
@@ -0,0 +1,458 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Sync queue: user changes land in this table and are pushed to OdooshCN asynchronously.
|
||||
|
||||
Why the hooks do not call the API directly: that would make user management here depend
|
||||
on the platform being up. Writing a queue row is a local insert and never fails.
|
||||
|
||||
Three paths, each a fallback for the previous one:
|
||||
* kick_async - a dedicated thread right after the transaction commits, so the user
|
||||
barely notices any delay.
|
||||
* cron_tick - every minute, in case the thread was killed with the worker. It is also
|
||||
the only thing that drives the retry back-off: nothing else comes back
|
||||
for a row whose next_retry_at is four hours away.
|
||||
* cron_scan - folded into that same tick, at most every SCAN_INTERVAL_DEFAULT minutes.
|
||||
Walks users by write_date and compares a payload fingerprint, catching
|
||||
anything the hooks missed (changes made outside the ORM, or made while
|
||||
the module was disabled).
|
||||
|
||||
The scan shares the queue cron's slot instead of owning one. Both jobs are cheap in
|
||||
themselves, but Odoo's cron pool is shared by the whole database (max_cron_threads, 2 by
|
||||
default), so a slot taken every few minutes costs more than the work does.
|
||||
|
||||
Retry policy: temporary failures back off 1, 5, 15, 60 and 240 minutes, then the row is
|
||||
marked failed and waits for a human. Permanent failures are not retried at all.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import SUPERUSER_ID, _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.modules.registry import Registry
|
||||
|
||||
from .odoosh_client import OdooshPermanent, OdooshRetryable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Minutes to wait after the n-th failure; once exhausted the row is marked failed
|
||||
BACKOFF_MINUTES = [1, 5, 15, 60, 240]
|
||||
BATCH_SIZE = 50 # rows processed per queue round
|
||||
SCAN_BATCH = 200 # users inspected per scan round
|
||||
SCAN_INTERVAL_DEFAULT = 30 # minutes between two scans; the parameter set to 0 disables it
|
||||
PARAM_LAST_SCAN = 'odoosh.last_scan_at' # cursor: user write_date reached by the scan
|
||||
PARAM_SCAN_RUN = 'odoosh.last_scan_run' # when the scan last ran, to space the runs out
|
||||
PARAM_SCAN_INTERVAL = 'odoosh.scan_interval_minutes'
|
||||
|
||||
# At most one push thread per database: a second request finding the lock taken just
|
||||
# returns, because the running thread re-queries the queue until it is empty.
|
||||
_workers = {}
|
||||
_workers_guard = threading.Lock()
|
||||
|
||||
|
||||
class OdooshSyncLog(models.Model):
|
||||
_name = 'odoosh.sync.log'
|
||||
_description = 'OdooshCN Sync Queue'
|
||||
_order = 'id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
# ondelete must be `set null`: when the Odoo user is deleted this row still has to
|
||||
# travel to the platform to delete the account there.
|
||||
user_id = fields.Many2one('res.users', string='User', ondelete='set null', index=True)
|
||||
login = fields.Char(string='Login', help="Kept so the row still identifies someone after the user is deleted")
|
||||
external_id = fields.Char(string='External ID', required=True, index=True,
|
||||
help="The stable key sent to OdooshCN, i.e. the user ID in this system")
|
||||
operation = fields.Selection([
|
||||
('upsert', 'Create / Update'),
|
||||
('deactivate', 'Deactivate'),
|
||||
('delete', 'Delete'),
|
||||
], string='Operation', required=True, default='upsert')
|
||||
state = fields.Selection([
|
||||
('pending', 'Pending'),
|
||||
('done', 'Done'),
|
||||
('skipped', 'Skipped (no change)'),
|
||||
('failed', 'Failed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], string='Status', default='pending', required=True, index=True)
|
||||
force = fields.Boolean(string='Force push',
|
||||
help="Ignore the payload fingerprint and push even when nothing changed")
|
||||
attempts = fields.Integer(string='Attempts', default=0)
|
||||
next_retry_at = fields.Datetime(string='Next retry', index=True)
|
||||
last_error = fields.Text(string='Last error')
|
||||
error_code = fields.Char(string='Error code',
|
||||
help="The platform's stable error code, used to decide retry versus manual handling")
|
||||
payload_preview = fields.Text(string='Payload', help="What was sent last time; contains no secrets")
|
||||
result = fields.Text(string='Response')
|
||||
synced_at = fields.Datetime(string='Completed on')
|
||||
display_name = fields.Char(compute='_compute_display_name')
|
||||
|
||||
@api.depends('login', 'operation', 'state')
|
||||
def _compute_display_name(self):
|
||||
ops = dict(self._fields['operation'].selection)
|
||||
for rec in self:
|
||||
rec.display_name = '%s - %s' % (rec.login or _('Unknown user'), ops.get(rec.operation, rec.operation))
|
||||
|
||||
# ------------------------------------------------------------------ enqueue
|
||||
@api.model
|
||||
def enqueue(self, users, operation='upsert', force=False):
|
||||
"""Queue a batch of users. An existing pending row for the same user and operation
|
||||
is reused (back-off reset, latest data resent) so renaming twice does not pile up
|
||||
rows. One query for the whole batch, never one per user."""
|
||||
users = users.sudo()
|
||||
if not users:
|
||||
return self.browse()
|
||||
pending = self.sudo().search([
|
||||
('user_id', 'in', users.ids),
|
||||
('operation', '=', operation),
|
||||
('state', '=', 'pending'),
|
||||
])
|
||||
by_user = {p.user_id.id: p for p in pending}
|
||||
if pending:
|
||||
vals = {'next_retry_at': False, 'attempts': 0}
|
||||
if force:
|
||||
vals['force'] = True
|
||||
pending.write(vals)
|
||||
new_vals = [{
|
||||
'user_id': u.id,
|
||||
'login': u.login,
|
||||
'external_id': str(u.id),
|
||||
'operation': operation,
|
||||
'state': 'pending',
|
||||
'force': force,
|
||||
} for u in users if u.id not in by_user]
|
||||
created = self.sudo().create(new_vals) if new_vals else self.browse()
|
||||
return pending | created
|
||||
|
||||
@api.model
|
||||
def enqueue_delete(self, users):
|
||||
"""Deleting an Odoo user: record the identity now, because after `unlink` the
|
||||
user record is gone and the row has to survive on its own."""
|
||||
users = users.sudo()
|
||||
if not users:
|
||||
return self.browse()
|
||||
return self.sudo().create([{
|
||||
'user_id': u.id,
|
||||
'login': u.login,
|
||||
'external_id': str(u.id),
|
||||
'operation': 'delete',
|
||||
'state': 'pending',
|
||||
} for u in users])
|
||||
|
||||
# ------------------------------------------------------------------ background thread
|
||||
@api.model
|
||||
def kick_async(self, dbname=None, scan_first=False):
|
||||
"""Called after commit: drain the queue in a dedicated thread. One thread per
|
||||
database. `scan_first` runs a user scan first, used after the settings change.
|
||||
|
||||
The thread owns its cursor and environment, fully isolated from the request that
|
||||
started it; anything it raises is logged and cannot affect committed data."""
|
||||
dbname = dbname or self.env.cr.dbname
|
||||
with _workers_guard:
|
||||
lock = _workers.setdefault(dbname, threading.Lock())
|
||||
if not lock.acquire(blocking=False):
|
||||
return False # a thread is already running and will pick up the new rows
|
||||
|
||||
def run():
|
||||
threading.current_thread().dbname = dbname # makes the log lines carry the db name
|
||||
try:
|
||||
registry = Registry(dbname)
|
||||
if scan_first:
|
||||
with registry.cursor() as cr:
|
||||
api.Environment(cr, SUPERUSER_ID, {})['odoosh.sync.log'].cron_scan(kick=False)
|
||||
rounds = 0
|
||||
while rounds < 20: # guard: hand back to the cron if rows keep pouring in
|
||||
rounds += 1
|
||||
with registry.cursor() as cr:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
n = env['odoosh.sync.log'].cron_process()
|
||||
if n == 0:
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("OdooshCN push thread died (the cron will take over)")
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
threading.Thread(target=run, name='odoosh-sync-%s' % dbname, daemon=True).start()
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ the cron
|
||||
@api.model
|
||||
def cron_tick(self):
|
||||
"""The module's only frequent cron: drain the queue, and fold the user scan into the
|
||||
same run every `odoosh.scan_interval_minutes` minutes (0 turns the scan off).
|
||||
|
||||
Scanning first and pushing afterwards means whatever the scan finds leaves in this
|
||||
tick instead of waiting for the next one."""
|
||||
if not self.env['odoosh.client']._enabled():
|
||||
return 0
|
||||
if self._scan_due():
|
||||
self.cron_scan(kick=False) # kick_async would only duplicate the push below
|
||||
return self.cron_process()
|
||||
|
||||
@api.model
|
||||
def _scan_due(self):
|
||||
"""Whether this tick also scans. The timestamp is written before the scan runs, so a
|
||||
scan that crashes waits for the next interval instead of retrying every minute."""
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
try:
|
||||
minutes = int(icp.get_param(PARAM_SCAN_INTERVAL) or SCAN_INTERVAL_DEFAULT)
|
||||
except (TypeError, ValueError):
|
||||
minutes = SCAN_INTERVAL_DEFAULT
|
||||
if minutes <= 0:
|
||||
return False
|
||||
now = fields.Datetime.now()
|
||||
last = icp.get_param(PARAM_SCAN_RUN)
|
||||
if last:
|
||||
try:
|
||||
if now - fields.Datetime.to_datetime(last) < timedelta(minutes=minutes):
|
||||
return False
|
||||
except ValueError:
|
||||
pass # unreadable value: scan now and write a good one back
|
||||
icp.set_param(PARAM_SCAN_RUN, fields.Datetime.to_string(now))
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ queue processing
|
||||
@api.model
|
||||
def cron_process(self, limit=BATCH_SIZE):
|
||||
"""Process the rows that are due; returns how many were handled, failures included."""
|
||||
client = self.env['odoosh.client']
|
||||
if not client._enabled():
|
||||
return 0
|
||||
now = fields.Datetime.now()
|
||||
records = self.sudo().search([
|
||||
('state', '=', 'pending'),
|
||||
'|', ('next_retry_at', '=', False), ('next_retry_at', '<=', now),
|
||||
], order='id asc', limit=limit)
|
||||
if not records:
|
||||
return 0
|
||||
ctx = self.env['res.users']._odoosh_scope_ctx() # shared by the whole batch
|
||||
for rec in records:
|
||||
rec._process_one(ctx)
|
||||
return len(records)
|
||||
|
||||
def _process_one(self, ctx=None):
|
||||
"""Process one row; returns whether it succeeded (skipped counts as success).
|
||||
Each row commits on its own so one failure never rolls back the others."""
|
||||
self.ensure_one()
|
||||
client = self.env['odoosh.client']
|
||||
user = self.user_id.sudo() if self.user_id else self.env['res.users']
|
||||
try:
|
||||
if self.operation == 'delete':
|
||||
payload = {'external_id': self.external_id}
|
||||
result = client.delete_user(self.external_id)
|
||||
self._finish('done', payload, result)
|
||||
if user.exists():
|
||||
user._odoosh_mark('none')
|
||||
return True
|
||||
|
||||
if self.operation == 'deactivate':
|
||||
payload = {'external_id': self.external_id, 'stop_envs': True}
|
||||
result = client.deactivate_user(self.external_id, stop_envs=True)
|
||||
self._finish('done', payload, result)
|
||||
if user.exists():
|
||||
user._odoosh_mark('none')
|
||||
return True
|
||||
|
||||
if not user.exists():
|
||||
self._mark_failed(_("The user was deleted; there is nothing left to sync"), 'user_gone')
|
||||
return False
|
||||
payload = user._odoosh_payload(ctx)
|
||||
digest = user._odoosh_payload_hash(payload)
|
||||
if not self.force and user.odoosh_sync_state == 'synced' and user.odoosh_payload_hash == digest:
|
||||
self._finish('skipped', payload, {'note': 'unchanged'}) # nothing changed, leave the platform alone
|
||||
return True
|
||||
try:
|
||||
result = client.upsert_user(payload)
|
||||
except OdooshPermanent as err:
|
||||
# Platform username already taken (typically Odoo's `admin` colliding with the
|
||||
# platform's own admin): retry once with a "-<odoo user id>" suffix and remember it.
|
||||
if err.code != 'username_taken' or (user.odoosh_username or '').strip():
|
||||
raise
|
||||
alt = ('%s-%s' % (payload['username'], user.id))[:64]
|
||||
payload['username'] = alt
|
||||
if payload.get('provision'):
|
||||
payload['provision']['name'] = alt
|
||||
result = client.upsert_user(payload)
|
||||
user.with_context(odoosh_no_sync=True).write({'odoosh_username': alt})
|
||||
digest = user._odoosh_payload_hash(payload)
|
||||
self._finish('done', payload, result)
|
||||
user._odoosh_mark('synced', payload_hash=digest, result=result)
|
||||
return True
|
||||
except OdooshPermanent as err:
|
||||
self._mark_failed(err.message, err.code)
|
||||
if user.exists():
|
||||
user._odoosh_mark('failed', error='[%s] %s' % (err.code, err.message))
|
||||
return False
|
||||
except OdooshRetryable as err:
|
||||
self._mark_retry(err.message, err.code)
|
||||
if user.exists():
|
||||
user._odoosh_mark('pending', error='[%s] %s' % (err.code, err.message))
|
||||
return False
|
||||
except Exception as err: # noqa: BLE001 - unexpected errors are retried too, never fatal
|
||||
_logger.exception("OdooshCN sync crashed on log#%s", self.id)
|
||||
self._mark_retry(str(err), 'unexpected')
|
||||
return False
|
||||
finally:
|
||||
# Commit per row: progress survives the thread or worker being killed mid-way
|
||||
self.env.cr.commit()
|
||||
|
||||
def _finish(self, state, payload, result):
|
||||
self.write({
|
||||
'state': state,
|
||||
'attempts': self.attempts + 1,
|
||||
'synced_at': fields.Datetime.now(),
|
||||
'payload_preview': self._pretty(payload),
|
||||
'result': self._pretty(result),
|
||||
'last_error': False,
|
||||
'error_code': False,
|
||||
'next_retry_at': False,
|
||||
'force': False,
|
||||
})
|
||||
|
||||
def _mark_retry(self, message, code):
|
||||
attempts = self.attempts + 1
|
||||
if attempts > len(BACKOFF_MINUTES):
|
||||
return self._mark_failed(
|
||||
_("Gave up after %(n)s attempts. Last error: %(err)s", n=attempts - 1, err=message), code)
|
||||
delay = BACKOFF_MINUTES[attempts - 1]
|
||||
self.write({
|
||||
'attempts': attempts,
|
||||
'last_error': message,
|
||||
'error_code': code,
|
||||
'next_retry_at': fields.Datetime.now() + timedelta(minutes=delay),
|
||||
})
|
||||
_logger.warning("OdooshCN sync postponed log#%s (%s), retry in %s min: %s", self.id, code, delay, message)
|
||||
|
||||
def _mark_failed(self, message, code):
|
||||
self.write({
|
||||
'state': 'failed',
|
||||
'attempts': self.attempts + 1,
|
||||
'last_error': message,
|
||||
'error_code': code,
|
||||
'next_retry_at': False,
|
||||
})
|
||||
_logger.error("OdooshCN sync failed log#%s (%s): %s", self.id, code, message)
|
||||
|
||||
@staticmethod
|
||||
def _pretty(data):
|
||||
try:
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
except (TypeError, ValueError):
|
||||
return str(data)
|
||||
|
||||
# ------------------------------------------------------------------ periodic scan
|
||||
@api.model
|
||||
def cron_scan(self, limit=SCAN_BATCH, kick=True):
|
||||
"""Walk users by write_date to catch whatever the hooks missed. Only rows changed
|
||||
since the last scan are read, never the whole table.
|
||||
|
||||
Cost control: one indexed query, one in-memory fingerprint comparison per user,
|
||||
at most SCAN_BATCH users per round; the cursor only advances to the last user
|
||||
actually inspected, so nobody is skipped."""
|
||||
if not self.env['odoosh.client']._enabled():
|
||||
return 0
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
last = icp.get_param(PARAM_LAST_SCAN)
|
||||
Users = self.env['res.users'].sudo()
|
||||
ctx = Users._odoosh_scope_ctx()
|
||||
|
||||
domain = [('odoosh_sync_enabled', '=', True), ('share', '=', False)]
|
||||
if last:
|
||||
domain.append(('write_date', '>', last))
|
||||
# Archived users matter too, so active_test is off; deactivation is only sent for
|
||||
# accounts the platform already knows.
|
||||
users = Users.with_context(active_test=False).search(domain, order='write_date asc, id asc', limit=limit)
|
||||
if not users:
|
||||
icp.set_param(PARAM_LAST_SCAN, fields.Datetime.to_string(fields.Datetime.now()))
|
||||
return 0
|
||||
|
||||
to_upsert = Users.browse()
|
||||
to_deactivate = Users.browse()
|
||||
in_scope = users._odoosh_filter_in_scope(ctx)
|
||||
for u in users:
|
||||
if u not in in_scope:
|
||||
continue # switch off, portal or built-in account
|
||||
if not u.active:
|
||||
if u.odoosh_sync_state in ('synced', 'pending', 'failed'):
|
||||
to_deactivate |= u
|
||||
continue
|
||||
if u.odoosh_sync_state != 'synced' or u.odoosh_payload_hash != u._odoosh_payload_hash(ctx=ctx):
|
||||
to_upsert |= u
|
||||
|
||||
if to_upsert:
|
||||
self.enqueue(to_upsert, 'upsert')
|
||||
to_upsert._odoosh_mark('pending')
|
||||
if to_deactivate:
|
||||
self.enqueue(to_deactivate, 'deactivate')
|
||||
|
||||
# Cursor: when the batch was full, only advance to its last write_date
|
||||
cursor = users[-1].write_date if len(users) >= limit else fields.Datetime.now()
|
||||
icp.set_param(PARAM_LAST_SCAN, fields.Datetime.to_string(cursor))
|
||||
n = len(to_upsert) + len(to_deactivate)
|
||||
if n:
|
||||
_logger.info("OdooshCN scan: inspected %s users, queued %s changes", len(users), n)
|
||||
self.env.cr.commit()
|
||||
if kick:
|
||||
self.kick_async()
|
||||
return n
|
||||
|
||||
# ------------------------------------------------------------------ manual actions
|
||||
def action_retry(self):
|
||||
"""Put failed or cancelled rows back in the queue and process them at once."""
|
||||
self.write({'state': 'pending', 'attempts': 0, 'next_retry_at': False, 'force': True})
|
||||
ctx = self.env['res.users']._odoosh_scope_ctx()
|
||||
for rec in self:
|
||||
rec._process_one(ctx)
|
||||
return True
|
||||
|
||||
def action_cancel(self):
|
||||
self.filtered(lambda r: r.state in ('pending', 'failed')).write({'state': 'cancelled', 'next_retry_at': False})
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ reconciliation
|
||||
@api.model
|
||||
def cron_reconcile(self):
|
||||
"""Daily reconciliation: send the full list of users who should have an account,
|
||||
so the platform can deactivate the ones that are gone.
|
||||
|
||||
An incomplete list would deactivate everyone, so an empty list is skipped here and
|
||||
the platform applies a ratio guard of its own."""
|
||||
client = self.env['odoosh.client']
|
||||
if not client._enabled():
|
||||
return False
|
||||
users = self.env['res.users'].sudo().search([('active', '=', True)])._odoosh_filter_in_scope()
|
||||
if not users:
|
||||
_logger.warning("OdooshCN reconciliation skipped: the local list is empty")
|
||||
return False
|
||||
try:
|
||||
result = client.reconcile([u.id for u in users], stop_envs=True, confirm=False)
|
||||
_logger.info("OdooshCN reconciliation done: %s users sent, %s deactivated on the platform",
|
||||
len(users), result.get('deactivated'))
|
||||
except OdooshPermanent as err:
|
||||
# The ratio guard lands here: never auto-confirm, let an administrator decide
|
||||
_logger.warning("OdooshCN reconciliation refused (%s): %s", err.code, err.message)
|
||||
except OdooshRetryable as err:
|
||||
_logger.warning("OdooshCN reconciliation postponed (%s): %s", err.code, err.message)
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def action_sync_all(self):
|
||||
"""Re-queue every user in scope, ignoring fingerprints."""
|
||||
users = self.env['res.users'].sudo().search([])._odoosh_filter_in_scope()
|
||||
if not users:
|
||||
raise UserError(_("No user is in sync scope. Check the settings and the per-user switch."))
|
||||
self.enqueue(users, 'upsert', force=True)
|
||||
users._odoosh_mark('pending')
|
||||
self.env.cr.commit()
|
||||
self.kick_async()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _("Queued"),
|
||||
'message': _("%s users queued; the background worker has started.", len(users)),
|
||||
'type': 'success',
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Settings: platform connection and the defaults used for auto-provisioned environments.
|
||||
|
||||
The access token lives in ir.config_parameter, readable only by system administrators.
|
||||
Nothing sent to the browser ever contains it in clear text.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from .odoosh_client import OdooshError
|
||||
|
||||
MASK = '********'
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
odoosh_enabled = fields.Boolean(
|
||||
string='Enable OdooshCN sync',
|
||||
config_parameter='odoosh.enabled',
|
||||
help="Master switch. When off nothing is pushed and queued rows are left alone.")
|
||||
odoosh_base_url = fields.Char(
|
||||
string='Platform URL',
|
||||
config_parameter='odoosh.base_url',
|
||||
help="Address of OdooshCN, for example https://lab.school.edu. No trailing slash.")
|
||||
odoosh_token = fields.Char(
|
||||
string='Access token',
|
||||
help="Created by an OdooshCN super administrator under Access tokens. Stored on the server only.")
|
||||
odoosh_token_set = fields.Boolean(string='Token configured', compute='_compute_token_set')
|
||||
|
||||
odoosh_tenant = fields.Char(
|
||||
string='Default organisation',
|
||||
config_parameter='odoosh.tenant',
|
||||
help="Slug of the platform organisation users land in. Empty means the organisation the token "
|
||||
"is bound to. Another organisation requires the token to allow it.")
|
||||
odoosh_default_version = fields.Char(
|
||||
string='Default Odoo version', config_parameter='odoosh.default_version', default='18',
|
||||
help="Odoo version used for new lab environments.")
|
||||
odoosh_default_lang = fields.Char(
|
||||
string='Default language', config_parameter='odoosh.default_lang', default='en_US',
|
||||
help="Initial language of new lab environments, for example en_US or zh_CN.")
|
||||
odoosh_default_mem_mb = fields.Integer(
|
||||
string='Memory limit per environment (MB)', config_parameter='odoosh.default_mem_mb',
|
||||
help="Empty uses the platform default. This value times the number of concurrent users "
|
||||
"should stay below the server memory.")
|
||||
odoosh_default_sleep_hours = fields.Float(
|
||||
string='Idle sleep (hours)', config_parameter='odoosh.default_sleep_hours',
|
||||
help="How long an environment may stay idle before it is put to sleep to free memory. "
|
||||
"It wakes up on the next visit. 0 disables sleeping.")
|
||||
|
||||
# ------------------------------------------------------------------ token
|
||||
@api.depends('odoosh_base_url')
|
||||
def _compute_token_set(self):
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
has = bool((icp.get_param('odoosh.token') or '').strip())
|
||||
for rec in self:
|
||||
rec.odoosh_token_set = has
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
values = super().get_values()
|
||||
token = (self.env['ir.config_parameter'].sudo().get_param('odoosh.token') or '').strip()
|
||||
# Only a mask plus the last 4 characters: enough to confirm it is set, never the secret
|
||||
values['odoosh_token'] = (MASK + token[-4:]) if token else ''
|
||||
return values
|
||||
|
||||
# Changing any of these changes the sync scope or target, so existing users are rescanned
|
||||
SCOPE_PARAMS = ('odoosh.enabled', 'odoosh.tenant', 'odoosh.base_url', 'odoosh.token')
|
||||
|
||||
def set_values(self):
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
before = {k: icp.get_param(k) for k in self.SCOPE_PARAMS}
|
||||
super().set_values()
|
||||
value = (self.odoosh_token or '').strip()
|
||||
if not value:
|
||||
icp.set_param('odoosh.token', '')
|
||||
elif not value.startswith(MASK): # the mask sent back untouched means "unchanged"
|
||||
icp.set_param('odoosh.token', value)
|
||||
after = {k: icp.get_param(k) for k in self.SCOPE_PARAMS}
|
||||
if after != before and after.get('odoosh.enabled') in ('True', 'true', '1', True):
|
||||
# Reset the scan cursor and rescan every user in the background after commit
|
||||
icp.set_param('odoosh.last_scan_at', '')
|
||||
dbname = self.env.cr.dbname
|
||||
self.env.cr.postcommit.add(
|
||||
lambda: self.env['odoosh.sync.log'].kick_async(dbname, scan_first=True))
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
def action_odoosh_test(self):
|
||||
"""Connectivity check: probes the platform, does not depend on any synced user, and
|
||||
reports whether the token carries the scopes this module needs."""
|
||||
self.ensure_one()
|
||||
client = self.env['odoosh.client']
|
||||
try:
|
||||
info = client.ping()
|
||||
except OdooshError as err:
|
||||
hint = ''
|
||||
if err.code == 'http_404':
|
||||
hint = _(" The platform answered 404, which usually means it still runs an older "
|
||||
"version without the integration API. Rebuild and restart it first.")
|
||||
elif err.code == 'unreachable':
|
||||
hint = _(" Check the URL and whether this server can reach it.")
|
||||
raise UserError(_("Connection failed (%(code)s): %(msg)s%(hint)s",
|
||||
code=err.code, msg=err.message, hint=hint))
|
||||
if not info.get('ok'):
|
||||
raise UserError(_("The platform returned something unexpected. Check its version."))
|
||||
|
||||
need = {'handoff': _("passwordless entry"), 'users:write': _("user sync"),
|
||||
'envs:read': _("read instances"), 'envs:write': _("create instances")}
|
||||
have = set(info.get('scopes') or [])
|
||||
missing = [label for scope, label in need.items() if scope not in have]
|
||||
limits = info.get('limits') or {}
|
||||
lines = [
|
||||
_("Organisation: %(name)s (%(slug)s)", name=info.get('tenant_name'), slug=info.get('tenant_slug')),
|
||||
_("Source: %s", info.get('source')),
|
||||
_("Scopes: %s", ', '.join(need.get(s, s) for s in sorted(have)) or _("none")),
|
||||
_("Limits: up to %(role)s, %(n)s instance(s) per user",
|
||||
role=limits.get('max_grant_role'), n=limits.get('max_envs_per_user')),
|
||||
]
|
||||
if not limits.get('allow_delete_user'):
|
||||
lines.append(_("Note: this token may not delete users, so deleting a user here only "
|
||||
"deactivates the platform account."))
|
||||
if info.get('expires_at'):
|
||||
lines.append(_("Token expires: %s", str(info['expires_at'])[:10]))
|
||||
if missing:
|
||||
lines.append(_("Missing scopes: %s. Edit the access token on the platform.", ', '.join(missing)))
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _("Connection OK") if not missing else _("Connected, but the token lacks scopes"),
|
||||
'message': '\n'.join(lines),
|
||||
'type': 'success' if not missing else 'warning',
|
||||
'sticky': bool(missing),
|
||||
},
|
||||
}
|
||||
|
||||
def action_odoosh_sync_all(self):
|
||||
"""Re-queue every user in scope."""
|
||||
return self.env['odoosh.sync.log'].sudo().action_sync_all()
|
||||
|
||||
def action_odoosh_open_logs(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Sync queue"),
|
||||
'res_model': 'odoosh.sync.log',
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""User synchronisation: the hooks, and the platform permission fields on the user.
|
||||
|
||||
There are only three rules, all implemented here:
|
||||
1. Once the platform URL and token are configured, creating a user syncs it right away
|
||||
and turns that user's sync switch on.
|
||||
2. Updating a user syncs when the switch is on and does nothing when it is off.
|
||||
3. Archiving deactivates the platform account, deleting deletes it, and restoring an
|
||||
archived user activates it again.
|
||||
|
||||
Everything the platform knows about a user - role, organisation, quotas, switches, menu
|
||||
visibility - is a field on the user form, so there is no hidden policy in the code.
|
||||
|
||||
Syncing never issues HTTP inside a request: the hooks only write a local queue row and a
|
||||
dedicated thread pushes after commit (see odoosh_sync_log.py).'
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Fields that end up in the payload; only these are worth a push
|
||||
POLICY_FIELDS = (
|
||||
'odoosh_username', 'odoosh_role', 'odoosh_tenant', 'odoosh_tenant_role',
|
||||
'odoosh_shell_enabled', 'odoosh_can_use_enterprise', 'odoosh_auto_provision',
|
||||
'odoosh_max_envs', 'odoosh_max_dbs',
|
||||
'odoosh_menu_dashboard', 'odoosh_menu_repos', 'odoosh_menu_ai', 'odoosh_menu_backups',
|
||||
)
|
||||
WATCHED_FIELDS = {'login', 'name', 'active', 'odoosh_sync_enabled', *POLICY_FIELDS}
|
||||
# Platform username rule: starts alphanumeric, may contain . _ - , 2 to 64 characters
|
||||
USERNAME_SAFE = re.compile(r'[^A-Za-z0-9_.-]')
|
||||
# Built-in accounts that are never synced: OdooBot, the public user, the portal template
|
||||
# and the new-user default template. They are not people. Administrator is a real login
|
||||
# and is synced like anybody else.
|
||||
SYSTEM_XMLIDS = ('base.user_root', 'base.public_user', 'base.template_user', 'base.default_user')
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
# ------------------------------------------------------------------ sync control and status
|
||||
odoosh_sync_enabled = fields.Boolean(
|
||||
string='Sync to lab platform', default=True, index=True,
|
||||
help="When off, nothing about this user is pushed: no updates, and archiving or "
|
||||
"deleting leaves the platform account untouched.")
|
||||
odoosh_sync_state = fields.Selection([
|
||||
('none', 'Not synced'),
|
||||
('pending', 'Pending'),
|
||||
('synced', 'Synced'),
|
||||
('failed', 'Failed'),
|
||||
], string='Sync status', default='none', readonly=True, copy=False, index=True)
|
||||
odoosh_synced_at = fields.Datetime(string='Last synced', readonly=True, copy=False)
|
||||
odoosh_last_error = fields.Text(string='Sync error', readonly=True, copy=False)
|
||||
odoosh_payload_hash = fields.Char(string='Payload fingerprint', readonly=True, copy=False,
|
||||
help="Fingerprint of the last successful push; identical content is not resent.")
|
||||
odoosh_platform_user_id = fields.Integer(string='Platform user ID', readonly=True, copy=False)
|
||||
odoosh_platform_username = fields.Char(string='Platform username', readonly=True, copy=False,
|
||||
help="The username actually in use on the platform.")
|
||||
|
||||
# ------------------------------------------------------------------ platform permissions
|
||||
odoosh_username = fields.Char(
|
||||
string='Platform username override', copy=False,
|
||||
help="Leave empty to derive it from the login (the part before @, cleaned up). Must be unique on the platform.")
|
||||
odoosh_role = fields.Selection([
|
||||
('member', 'Regular user'),
|
||||
('admin', 'Platform administrator'),
|
||||
], string='Platform role', default='member', required=True,
|
||||
help="A platform administrator sees every organisation. Requires the access token to allow it.")
|
||||
odoosh_tenant = fields.Char(
|
||||
string='Organisation', copy=False,
|
||||
help="Slug of the platform organisation. Empty falls back to the default in the settings, "
|
||||
"then to the organisation the token is bound to. Another organisation requires the token to allow it.")
|
||||
odoosh_tenant_role = fields.Selection([
|
||||
('tester', 'Tester (open instances only)'),
|
||||
('developer', 'Developer (edit code, read logs, reset)'),
|
||||
('owner', 'Organisation manager (see every instance)'),
|
||||
], string='Role in organisation', default='developer', required=True,
|
||||
help="Must not exceed the access token's maximum grantable role.")
|
||||
odoosh_shell_enabled = fields.Boolean(string='Web Shell', default=True,
|
||||
help="Allow opening a terminal on the instance, needed for psql.")
|
||||
odoosh_can_use_enterprise = fields.Boolean(string='Enterprise edition', default=False,
|
||||
help="Allow creating enterprise edition instances.")
|
||||
odoosh_auto_provision = fields.Boolean(string='Auto-provision environment', default=True,
|
||||
help="On sync, create a lab environment if this user has none yet.")
|
||||
odoosh_max_envs = fields.Integer(string='Instance limit', default=1, help="0 means unlimited.")
|
||||
odoosh_max_dbs = fields.Integer(string='Extra database limit', default=2, help="0 means unlimited.")
|
||||
odoosh_menu_dashboard = fields.Boolean(string='Menu: Overview', default=False)
|
||||
odoosh_menu_repos = fields.Boolean(string='Menu: Git repositories', default=False)
|
||||
odoosh_menu_ai = fields.Boolean(string='Menu: AI assistant', default=True)
|
||||
odoosh_menu_backups = fields.Boolean(string='Menu: Backups', default=False)
|
||||
|
||||
# Readable by the user themselves on the preferences page, but not writable
|
||||
@property
|
||||
def SELF_READABLE_FIELDS(self):
|
||||
return super().SELF_READABLE_FIELDS + ['odoosh_sync_state', 'odoosh_synced_at', 'odoosh_platform_username']
|
||||
|
||||
# ------------------------------------------------------------------ scope
|
||||
@api.model
|
||||
def _odoosh_configured(self):
|
||||
"""Hooks stay silent until the URL, the token and the master switch are all set."""
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
return (icp.get_param('odoosh.enabled') in ('True', 'true', '1', True)
|
||||
and bool((icp.get_param('odoosh.base_url') or '').strip())
|
||||
and bool((icp.get_param('odoosh.token') or '').strip()))
|
||||
|
||||
@api.model
|
||||
def _odoosh_scope_ctx(self):
|
||||
"""Constants shared by a whole batch: the ids of the built-in accounts."""
|
||||
system_ids = set()
|
||||
for xmlid in SYSTEM_XMLIDS:
|
||||
rec = self.env.ref(xmlid, raise_if_not_found=False)
|
||||
if rec:
|
||||
system_ids.add(rec.id)
|
||||
return {'system_ids': system_ids}
|
||||
|
||||
def _odoosh_eligible(self, ctx=None):
|
||||
"""Accounts that may be synced: internal users that are not built-in (switch ignored)."""
|
||||
ctx = ctx or self._odoosh_scope_ctx()
|
||||
system_ids = ctx['system_ids']
|
||||
return self.sudo().filtered(lambda u: not u.share and u.id not in system_ids)
|
||||
|
||||
def _odoosh_filter_in_scope(self, ctx=None):
|
||||
"""Users to sync: eligible and switch on."""
|
||||
return self._odoosh_eligible(ctx).filtered('odoosh_sync_enabled')
|
||||
|
||||
def _odoosh_in_scope(self, ctx=None):
|
||||
self.ensure_one()
|
||||
return bool(self._odoosh_filter_in_scope(ctx))
|
||||
|
||||
# ------------------------------------------------------------------ payload
|
||||
def _odoosh_username_auto(self):
|
||||
"""Derive the platform username from the login: the part before @, cleaned up."""
|
||||
self.ensure_one()
|
||||
raw = (self.login or '').split('@')[0]
|
||||
cleaned = USERNAME_SAFE.sub('', raw) or ('u%s' % self.id)
|
||||
if not cleaned[0].isalnum():
|
||||
cleaned = 'u' + cleaned
|
||||
if len(cleaned) < 2:
|
||||
cleaned = 'u%s' % self.id
|
||||
return cleaned[:64]
|
||||
|
||||
def _odoosh_tenant_value(self):
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
return (self.odoosh_tenant or '').strip() or (icp.get_param('odoosh.tenant') or '').strip() or None
|
||||
|
||||
def _odoosh_payload(self, ctx=None):
|
||||
"""What is sent to the platform; every value comes from a field on this user."""
|
||||
self.ensure_one()
|
||||
payload = {
|
||||
'external_id': str(self.id),
|
||||
'username': (self.odoosh_username or '').strip() or self._odoosh_username_auto(),
|
||||
'display_name': self.name or self.login,
|
||||
'active': bool(self.active),
|
||||
'role': self.odoosh_role or 'member',
|
||||
'tenant': self._odoosh_tenant_value(),
|
||||
'tenant_role': self.odoosh_tenant_role or 'developer',
|
||||
'shell_enabled': bool(self.odoosh_shell_enabled),
|
||||
'can_use_enterprise': bool(self.odoosh_can_use_enterprise),
|
||||
'quota': {
|
||||
'max_envs': None if not self.odoosh_max_envs else int(self.odoosh_max_envs), # 0 = unlimited
|
||||
'max_dbs': None if not self.odoosh_max_dbs else int(self.odoosh_max_dbs),
|
||||
},
|
||||
'menus': {
|
||||
'dashboard': bool(self.odoosh_menu_dashboard),
|
||||
'repos': bool(self.odoosh_menu_repos),
|
||||
'ai': bool(self.odoosh_menu_ai),
|
||||
'backups': bool(self.odoosh_menu_backups),
|
||||
},
|
||||
}
|
||||
if self.odoosh_auto_provision and self.active:
|
||||
payload['provision'] = self._odoosh_env_spec()
|
||||
return payload
|
||||
|
||||
def _odoosh_env_spec(self):
|
||||
"""Specification of the auto-provisioned environment: named after the platform
|
||||
username, everything else from the settings."""
|
||||
self.ensure_one()
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
spec = {
|
||||
'name': (self.odoosh_username or '').strip() or self._odoosh_username_auto(),
|
||||
'tenant': self._odoosh_tenant_value(),
|
||||
'kind': 'dev',
|
||||
'odoo_version': icp.get_param('odoosh.default_version') or '18',
|
||||
'edition': 'community',
|
||||
'lang': icp.get_param('odoosh.default_lang') or 'en_US',
|
||||
}
|
||||
for key, param, cast in (('mem_mb', 'odoosh.default_mem_mb', int),
|
||||
('sleep_idle_hours', 'odoosh.default_sleep_hours', float)):
|
||||
raw = icp.get_param(param)
|
||||
if raw:
|
||||
try:
|
||||
spec[key] = cast(raw)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return spec
|
||||
|
||||
def _odoosh_payload_hash(self, payload=None, ctx=None):
|
||||
self.ensure_one()
|
||||
data = payload if payload is not None else self._odoosh_payload(ctx)
|
||||
raw = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(',', ':'))
|
||||
return hashlib.sha1(raw.encode('utf-8')).hexdigest()
|
||||
|
||||
# ------------------------------------------------------------------ hooks
|
||||
def _odoosh_kick(self):
|
||||
dbname = self.env.cr.dbname
|
||||
self.env.cr.postcommit.add(lambda: self.env['odoosh.sync.log'].kick_async(dbname))
|
||||
|
||||
def _odoosh_enqueue(self, operation):
|
||||
"""Queue and let the post-commit thread push. Local writes only; every exception is
|
||||
swallowed and logged, because syncing must never block user management."""
|
||||
if self.env.context.get('odoosh_no_sync'):
|
||||
return
|
||||
try:
|
||||
if not self._odoosh_configured():
|
||||
return
|
||||
targets = self._odoosh_filter_in_scope()
|
||||
if not targets:
|
||||
return
|
||||
self.env['odoosh.sync.log'].sudo().enqueue(targets, operation)
|
||||
targets.with_context(odoosh_no_sync=True).write({'odoosh_sync_state': 'pending'})
|
||||
self._odoosh_kick()
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("OdooshCN enqueue failed (ignored, user operation unaffected)")
|
||||
|
||||
def _odoosh_enqueue_known(self, operation):
|
||||
"""Deactivate or delete: only for users whose switch is on and that the platform knows."""
|
||||
if self.env.context.get('odoosh_no_sync'):
|
||||
return
|
||||
try:
|
||||
if not self._odoosh_configured():
|
||||
return
|
||||
known = self.sudo().filtered(
|
||||
lambda u: u.odoosh_sync_enabled and u.odoosh_sync_state in ('synced', 'pending', 'failed'))
|
||||
if not known:
|
||||
return
|
||||
queue = self.env['odoosh.sync.log'].sudo()
|
||||
if operation == 'delete':
|
||||
queue.enqueue_delete(known)
|
||||
else:
|
||||
queue.enqueue(known, operation)
|
||||
self._odoosh_kick()
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("OdooshCN %s enqueue failed (ignored)", operation)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
users = super().create(vals_list)
|
||||
if self._odoosh_configured():
|
||||
# Configured: sync immediately and switch the new users on
|
||||
off = users._odoosh_eligible().filtered(lambda u: not u.odoosh_sync_enabled)
|
||||
if off:
|
||||
off.with_context(odoosh_no_sync=True).write({'odoosh_sync_enabled': True})
|
||||
users._odoosh_enqueue('upsert')
|
||||
return users
|
||||
|
||||
def write(self, vals):
|
||||
result = super().write(vals)
|
||||
if self.env.context.get('odoosh_no_sync') or not (WATCHED_FIELDS & set(vals)):
|
||||
return result
|
||||
if 'odoosh_sync_enabled' in vals and not vals['odoosh_sync_enabled']:
|
||||
return result # just switched off: leave the platform as it is
|
||||
todo = self.sudo().filtered('odoosh_sync_enabled')
|
||||
if not todo:
|
||||
return result # switch off: nothing to do
|
||||
if 'active' in vals and not vals['active']:
|
||||
todo._odoosh_enqueue_known('deactivate') # archived -> deactivate on the platform
|
||||
else:
|
||||
todo._odoosh_enqueue('upsert') # edited, or restored from the archive
|
||||
return result
|
||||
|
||||
def unlink(self):
|
||||
# Deleted here means deleted there. The platform moves the user's environments to
|
||||
# its recycle bin first, so the data stays recoverable for a while.
|
||||
self.sudo().filtered('odoosh_sync_enabled')._odoosh_enqueue_known('delete')
|
||||
return super().unlink()
|
||||
|
||||
# ------------------------------------------------------------------ status write-back
|
||||
def _odoosh_mark(self, state, error=None, payload_hash=None, result=None):
|
||||
"""Called by the sync thread. The odoosh_no_sync context stops it from looping."""
|
||||
vals = {'odoosh_sync_state': state, 'odoosh_last_error': error or False}
|
||||
if state == 'synced':
|
||||
vals['odoosh_synced_at'] = fields.Datetime.now()
|
||||
if payload_hash:
|
||||
vals['odoosh_payload_hash'] = payload_hash
|
||||
if result:
|
||||
if result.get('user_id'):
|
||||
vals['odoosh_platform_user_id'] = int(result['user_id'])
|
||||
if result.get('username'):
|
||||
vals['odoosh_platform_username'] = result['username']
|
||||
self.sudo().with_context(odoosh_no_sync=True).write(vals)
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
def action_odoosh_sync_now(self):
|
||||
"""Sync the selected users straight away, without waiting for the queue."""
|
||||
queue = self.env['odoosh.sync.log'].sudo()
|
||||
if not self._odoosh_configured():
|
||||
return self._odoosh_notify(
|
||||
_("Not configured"),
|
||||
_("Set the platform URL and access token in Settings > OdooshCN Lab Environments first."),
|
||||
'warning')
|
||||
targets = self._odoosh_filter_in_scope()
|
||||
if not targets:
|
||||
ctx = self._odoosh_scope_ctx()
|
||||
reasons = []
|
||||
for u in self.sudo():
|
||||
if u.id in ctx['system_ids']:
|
||||
reasons.append(_("%s: built-in Odoo account, never synced", u.login))
|
||||
elif u.share:
|
||||
reasons.append(_("%s: portal user, never synced", u.login))
|
||||
elif not u.odoosh_sync_enabled:
|
||||
reasons.append(_("%s: sync switch is off", u.login))
|
||||
return self._odoosh_notify(_("Nothing to sync"),
|
||||
'\n'.join(reasons) or _("None of the selected users is in scope."),
|
||||
'warning')
|
||||
logs = queue.enqueue(targets, 'upsert', force=True)
|
||||
ok = sum(1 for log in logs if log._process_one())
|
||||
return self._odoosh_notify(
|
||||
_("Sync finished"),
|
||||
_("%(ok)s of %(total)s succeeded. Open the sync queue to see why the others failed.",
|
||||
ok=ok, total=len(logs)),
|
||||
'success' if ok == len(logs) else 'warning')
|
||||
|
||||
def action_odoosh_enable_sync(self):
|
||||
self.write({'odoosh_sync_enabled': True})
|
||||
return self._odoosh_notify(_("Sync enabled"),
|
||||
_("%s users will be pushed within a few seconds.", len(self)), 'success')
|
||||
|
||||
def action_odoosh_disable_sync(self):
|
||||
self.with_context(odoosh_no_sync=True).write({'odoosh_sync_enabled': False})
|
||||
return self._odoosh_notify(_("Sync disabled"),
|
||||
_("%s users will no longer be pushed; their platform accounts stay as they are.",
|
||||
len(self)), 'info')
|
||||
|
||||
def action_odoosh_envs(self):
|
||||
self.ensure_one()
|
||||
return self.env['odoosh.client'].list_envs(str(self.id)).get('envs', [])
|
||||
|
||||
@staticmethod
|
||||
def _odoosh_notify(title, message, kind='info'):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {'title': title, 'message': message, 'type': kind, 'sticky': False},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_odoosh_sync_log_manager,odoosh.sync.log.manager,model_odoosh_sync_log,group_odoosh_teacher,1,1,0,0
|
||||
access_odoosh_sync_log_system,odoosh.sync.log.system,model_odoosh_sync_log,base.group_system,1,1,1,1
|
||||
|
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="module_category_odoosh" model="ir.module.category">
|
||||
<field name="name">Lab Environments</field>
|
||||
<field name="description">OdooshCN lab environment integration</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager: can see the sync queue and every user's platform settings -->
|
||||
<record id="group_odoosh_teacher" model="res.groups">
|
||||
<field name="name">Lab Environment Manager</field>
|
||||
<field name="category_id" ref="module_category_odoosh"/>
|
||||
<field name="comment">Can inspect the sync queue and manage the platform settings of other users.</field>
|
||||
</record>
|
||||
|
||||
<!-- Only managers and system administrators may read the sync queue -->
|
||||
<record id="rule_sync_log_teacher" model="ir.rule">
|
||||
<field name="name">Sync queue: managers only</field>
|
||||
<field name="model_id" ref="model_odoosh_sync_log"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,158 @@
|
||||
/** @odoo-module **/
|
||||
/**
|
||||
* The "My Lab Environment" client action.
|
||||
*
|
||||
* Shows the current user's environments as cards, and opens the platform through a
|
||||
* passwordless URL fetched from the server.
|
||||
*
|
||||
* The console (instance list, instance detail) is embedded in an iframe on this page, so
|
||||
* students stay inside the course system; the frame keeps an "Open in a new tab" button
|
||||
* because it does stack two top bars.
|
||||
*
|
||||
* The instance's own Odoo opens in a real tab instead: it is a full Odoo interface, and
|
||||
* nesting it here would stack three navigation bars and two sidebars, leaving nobody sure
|
||||
* which layer they are in.
|
||||
*
|
||||
* The platform has to allow being framed. Its console answers with X-Frame-Options
|
||||
* SAMEORIGIN by default, and the frame then stays blank; the administrator names this Odoo
|
||||
* in the console frame-ancestors policy (see frontend/nginx-console.inc on the platform).
|
||||
*
|
||||
* The URL is short lived and single use, so it is fetched on click and never cached - the
|
||||
* "new tab" button fetches one of its own instead of reusing the framed one.
|
||||
*/
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
const STATUS_LABEL = {
|
||||
running: _t("Running"),
|
||||
sleeping: _t("Sleeping"),
|
||||
stopped: _t("Stopped"),
|
||||
waking: _t("Waking up"),
|
||||
starting: _t("Starting"),
|
||||
provisioning: _t("Creating"),
|
||||
restarting: _t("Restarting"),
|
||||
deploying: _t("Deploying"),
|
||||
failed: _t("Failed"),
|
||||
created: _t("Not created yet"),
|
||||
};
|
||||
|
||||
const STATUS_CLASS = {
|
||||
running: "text-bg-success",
|
||||
sleeping: "text-bg-warning",
|
||||
waking: "text-bg-warning",
|
||||
starting: "text-bg-info",
|
||||
provisioning: "text-bg-info",
|
||||
restarting: "text-bg-info",
|
||||
deploying: "text-bg-info",
|
||||
failed: "text-bg-danger",
|
||||
};
|
||||
|
||||
export class OdooshMyEnv extends Component {
|
||||
static template = "odoosh_connector.MyEnv";
|
||||
static props = { ...standardActionServiceProps };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
error: null,
|
||||
envs: [],
|
||||
username: null,
|
||||
opening: null,
|
||||
frame: null, // {url, target, env, title} while a page is embedded below
|
||||
});
|
||||
onWillStart(() => this.load());
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const data = await rpc("/odoosh/my/envs", {});
|
||||
this.state.error = data.error || null;
|
||||
this.state.envs = data.envs || [];
|
||||
this.state.username = data.username || null;
|
||||
} catch {
|
||||
this.state.error = _t("Cannot reach the lab platform. Try again later or ask your administrator.");
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
statusLabel(env) {
|
||||
return STATUS_LABEL[env.status] || env.status;
|
||||
}
|
||||
|
||||
statusClass(env) {
|
||||
return STATUS_CLASS[env.status] || "text-bg-secondary";
|
||||
}
|
||||
|
||||
isBusy(env) {
|
||||
return ["provisioning", "starting", "restarting", "deploying", "waking"].includes(env.status);
|
||||
}
|
||||
|
||||
/** Fetch a passwordless URL and show it. target: console or odoo.
|
||||
* newTab skips the embedded frame and opens a real tab instead. */
|
||||
async open(target, env, newTab = false) {
|
||||
if (this.state.opening) {
|
||||
return;
|
||||
}
|
||||
this.state.opening = `${target}:${env ? env.name : "-"}`;
|
||||
// Open the blank tab first: fetching the URL is async, and opening a tab inside the
|
||||
// callback would be treated as a pop-up and blocked.
|
||||
const tab = newTab ? window.open("", "_blank") : null;
|
||||
try {
|
||||
const data = await rpc("/odoosh/my/url", { target, env: env ? env.name : null });
|
||||
if (data.error) {
|
||||
if (tab) {
|
||||
tab.close();
|
||||
}
|
||||
this.notification.add(data.error, { type: "danger", title: _t("Cannot open") });
|
||||
return;
|
||||
}
|
||||
if (!newTab) {
|
||||
this.state.frame = {
|
||||
url: data.url,
|
||||
target,
|
||||
env: env || null,
|
||||
title: env ? env.name : _t("Developer console"),
|
||||
};
|
||||
} else if (tab) {
|
||||
tab.location.href = data.url;
|
||||
} else {
|
||||
window.location.href = data.url; // pop-up blocked: navigate this tab instead
|
||||
}
|
||||
} catch {
|
||||
if (tab) {
|
||||
tab.close();
|
||||
}
|
||||
this.notification.add(_t("Could not obtain the entry URL. Try again."), { type: "danger" });
|
||||
} finally {
|
||||
this.state.opening = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Leave the embedded page and go back to the cards. */
|
||||
closeFrame() {
|
||||
this.state.frame = null;
|
||||
}
|
||||
|
||||
/** Same destination as the embedded one, in a real tab. Fetches a fresh URL: the framed
|
||||
* one has already been redeemed. */
|
||||
openFramedInTab() {
|
||||
const f = this.state.frame;
|
||||
if (f) {
|
||||
this.open(f.target, f.env, true);
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("odoosh_my_env", OdooshMyEnv);
|
||||
@@ -0,0 +1,37 @@
|
||||
.o_odoosh_env {
|
||||
max-width: 1200px;
|
||||
|
||||
// 嵌入模式:铺满可用宽高,不然控制台的侧栏会被 1200px 挤成一条
|
||||
&.o_odoosh_env_framed {
|
||||
max-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.o_odoosh_frame {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 70vh; // 外层高度撑不开时的下限,免得 iframe 被压成 0
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: .5rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.o_odoosh_card {
|
||||
transition: box-shadow .15s ease, transform .15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, .08);
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: .85em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="odoosh_connector.MyEnv">
|
||||
<div class="o_odoosh_env o_action p-4" t-att-class="{'o_odoosh_env_framed': state.frame}">
|
||||
|
||||
<!-- Embedded platform page: instance list, instance detail, or the instance's Odoo -->
|
||||
<t t-if="state.frame">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<button class="btn btn-secondary" t-on-click="() => this.closeFrame()">
|
||||
<i class="fa fa-angle-left me-1"/>Back
|
||||
</button>
|
||||
<h4 class="mb-0 flex-grow-1 text-truncate" t-esc="state.frame.title"/>
|
||||
<button class="btn btn-light" t-att-disabled="state.opening"
|
||||
t-on-click="() => this.openFramedInTab()">
|
||||
<i class="fa fa-external-link me-1"/>Open in a new tab
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<iframe class="o_odoosh_frame" t-att-src="state.frame.url" t-att-title="state.frame.title"/>
|
||||
|
||||
<div class="text-muted small mt-2">
|
||||
Stays blank? The platform only lets the sites named in its console frame-ancestors
|
||||
policy embed it, and the address in your browser's bar has to match it exactly,
|
||||
scheme and port included. Ask your administrator, or use the button above.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">My Lab Environment</h2>
|
||||
<div class="text-muted small" t-if="state.username">
|
||||
Platform account: <code t-esc="state.username"/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" t-on-click="() => this.refresh()" t-att-disabled="state.loading">
|
||||
<i class="fa fa-refresh me-1"/>Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div t-if="state.loading" class="text-center text-muted py-5">
|
||||
<i class="fa fa-circle-o-notch fa-spin fa-2x"/>
|
||||
<div class="mt-3">Loading your environments...</div>
|
||||
</div>
|
||||
|
||||
<div t-elif="state.error" class="alert alert-warning">
|
||||
<t t-esc="state.error"/>
|
||||
</div>
|
||||
|
||||
<div t-elif="!state.envs.length" class="text-center text-muted py-5">
|
||||
<i class="fa fa-cube fa-3x mb-3 d-block opacity-50"/>
|
||||
<p class="mb-1">No lab environment yet.</p>
|
||||
<p class="small">
|
||||
An environment is created automatically once your account is synced, usually within
|
||||
a few minutes. Ask your administrator if it does not appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div t-else="" class="row g-3">
|
||||
<div t-foreach="state.envs" t-as="env" t-key="env.id" class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card h-100 o_odoosh_card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||
<h5 class="card-title mb-0 text-truncate" t-esc="env.name"/>
|
||||
<span class="badge ms-2 flex-shrink-0"
|
||||
t-att-class="statusClass(env)" t-esc="statusLabel(env)"/>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mb-3">
|
||||
Odoo <t t-esc="env.odoo_version"/>
|
||||
<t t-if="env.edition === 'enterprise'"> · Enterprise</t>
|
||||
<t t-if="env.kind !== 'dev'"> · <t t-esc="env.kind"/></t>
|
||||
</div>
|
||||
|
||||
<div t-if="isBusy(env) and env.status_message" class="small text-muted mb-3">
|
||||
<i class="fa fa-clock-o me-1"/><t t-esc="env.status_message"/>
|
||||
<t t-if="env.progress"> (<t t-esc="env.progress"/>%)</t>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
t-on-click="() => this.open('odoo', env, true)"
|
||||
t-att-disabled="state.opening or env.status === 'failed'">
|
||||
<i class="fa fa-external-link me-1"/>Open Odoo
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
t-on-click="() => this.open('console', env)"
|
||||
t-att-disabled="state.opening">
|
||||
<i class="fa fa-code me-1"/>Developer console
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div t-if="env.status === 'sleeping'" class="text-muted small mt-2">
|
||||
This environment is asleep to save resources. Opening it wakes it up,
|
||||
which takes a few seconds.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="!state.loading and !state.error" class="mt-4">
|
||||
<button class="btn btn-link px-0" t-on-click="() => this.open('console', null)">
|
||||
See everything in the developer console
|
||||
<i class="fa fa-angle-right ms-1"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_odoosh_my_env" model="ir.actions.client">
|
||||
<field name="name">My Lab Environment</field>
|
||||
<field name="tag">odoosh_my_env</field>
|
||||
</record>
|
||||
|
||||
<!-- Standalone error page for /odoosh/go, so the module only depends on base and web -->
|
||||
<template id="entry_error" name="Lab environment entry error">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Cannot open the lab environment</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,'Segoe UI',sans-serif">
|
||||
<div style="max-width:560px;margin:12vh auto;padding:0 20px">
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:32px 36px;box-shadow:0 1px 3px rgba(0,0,0,.05)">
|
||||
<h2 style="margin:0 0 12px;font-size:20px;color:#dc2626">Cannot open the lab environment</h2>
|
||||
<p style="color:#475569;line-height:1.8;margin:0 0 8px"><t t-esc="message"/></p>
|
||||
<p t-if="code" style="color:#94a3b8;font-size:12px;margin:0 0 20px">
|
||||
Error code: <code t-esc="code"/>
|
||||
</p>
|
||||
<a href="/odoo" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:8px 18px;border-radius:6px">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- 让「我的实训」直接打开「我的实验环境」(My Lab Environment),
|
||||
去掉中间的「实验环境」子菜单层。 -->
|
||||
<menuitem id="college.menu_yuthon_training"
|
||||
name="我的实训"
|
||||
action="action_odoosh_my_env"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="odoosh_sync_log_view_list" model="ir.ui.view">
|
||||
<field name="name">odoosh.sync.log.list</field>
|
||||
<field name="model">odoosh.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Sync queue" decoration-danger="state == 'failed'"
|
||||
decoration-muted="state in ('cancelled', 'skipped')" decoration-success="state == 'done'">
|
||||
<field name="login"/>
|
||||
<field name="external_id"/>
|
||||
<field name="operation"/>
|
||||
<field name="state"/>
|
||||
<field name="attempts"/>
|
||||
<field name="force" optional="hide"/>
|
||||
<field name="next_retry_at" optional="show"/>
|
||||
<field name="error_code" optional="show"/>
|
||||
<field name="last_error" optional="hide"/>
|
||||
<field name="synced_at" optional="hide"/>
|
||||
<button name="action_retry" type="object" string="Retry" icon="fa-refresh"
|
||||
invisible="state not in ('failed', 'cancelled')"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="odoosh_sync_log_view_form" model="ir.ui.view">
|
||||
<field name="name">odoosh.sync.log.form</field>
|
||||
<field name="model">odoosh.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Sync record">
|
||||
<header>
|
||||
<button name="action_retry" type="object" string="Retry now" class="btn-primary"
|
||||
invisible="state == 'done'"/>
|
||||
<button name="action_cancel" type="object" string="Cancel"
|
||||
invisible="state not in ('pending', 'failed')"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="pending,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="user_id"/>
|
||||
<field name="login"/>
|
||||
<field name="external_id"/>
|
||||
<field name="operation"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="attempts"/>
|
||||
<field name="next_retry_at"/>
|
||||
<field name="synced_at"/>
|
||||
<field name="error_code"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Last error" invisible="not last_error">
|
||||
<field name="last_error" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Payload">
|
||||
<field name="payload_preview" nolabel="1" readonly="1" widget="text"/>
|
||||
</page>
|
||||
<page string="Response">
|
||||
<field name="result" nolabel="1" readonly="1" widget="text"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="odoosh_sync_log_view_search" model="ir.ui.view">
|
||||
<field name="name">odoosh.sync.log.search</field>
|
||||
<field name="model">odoosh.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Sync queue">
|
||||
<field name="login"/>
|
||||
<field name="external_id"/>
|
||||
<field name="error_code"/>
|
||||
<filter name="f_pending" string="Pending" domain="[('state', '=', 'pending')]"/>
|
||||
<filter name="f_failed" string="Failed" domain="[('state', '=', 'failed')]"/>
|
||||
<filter name="f_done" string="Done" domain="[('state', '=', 'done')]"/>
|
||||
<filter name="f_skipped" string="No change" domain="[('state', '=', 'skipped')]"/>
|
||||
<separator/>
|
||||
<filter name="f_upsert" string="Create / Update" domain="[('operation', '=', 'upsert')]"/>
|
||||
<filter name="f_deactivate" string="Deactivate" domain="[('operation', '=', 'deactivate')]"/>
|
||||
<filter name="f_delete" string="Delete" domain="[('operation', '=', 'delete')]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter name="g_state" string="Status" context="{'group_by': 'state'}"/>
|
||||
<filter name="g_op" string="Operation" context="{'group_by': 'operation'}"/>
|
||||
<filter name="g_code" string="Error code" context="{'group_by': 'error_code'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_odoosh_sync_log" model="ir.actions.act_window">
|
||||
<field name="name">Sync queue</field>
|
||||
<field name="res_model">odoosh.sync.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_f_failed': 1, 'search_default_f_pending': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">The sync queue is empty</p>
|
||||
<p>Creating, updating, archiving or deleting a user adds a row here, processed in the background.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.odoosh</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//block[@name='integration']" position="after">
|
||||
<block title="OdooshCN Lab Environments" name="odoosh_settings">
|
||||
|
||||
<setting id="odoosh_enable" string="Enable sync"
|
||||
help="Once on, creating, updating, archiving and deleting users is pushed to OdooshCN in the background.">
|
||||
<field name="odoosh_enabled"/>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_connection" string="Platform connection" invisible="not odoosh_enabled"
|
||||
help="The token is created by an OdooshCN super administrator and stored on the server only. For local testing use http://127.0.0.1:port rather than localhost.">
|
||||
<div class="content-group">
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_base_url" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_base_url" placeholder="https://lab.school.edu"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_token" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_token" password="True" placeholder="Token starting with osh_"/>
|
||||
</div>
|
||||
<div class="mt8">
|
||||
<button name="action_odoosh_test" type="object"
|
||||
string="Test connection" class="btn-link" icon="fa-plug"/>
|
||||
</div>
|
||||
<field name="odoosh_token_set" invisible="1"/>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_scope" string="How syncing works" invisible="not odoosh_enabled"
|
||||
help="It takes effect as soon as the connection above is filled in; there is nothing to set up per user.">
|
||||
<div class="text-muted small">
|
||||
<div>New user: synced immediately, and its sync switch is turned on</div>
|
||||
<div>Updated user: synced when the switch is on, ignored when it is off</div>
|
||||
<div>Archived user: the platform account is deactivated</div>
|
||||
<div>Deleted user: the platform account is deleted, its environments go to the platform recycle bin</div>
|
||||
<div>Role, quotas and menus per user live on the Lab Environment tab of the user form</div>
|
||||
<div>Portal users and built-in Odoo accounts are never synced</div>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_env_defaults" string="Environment defaults" invisible="not odoosh_enabled"
|
||||
help="Used when an environment is provisioned automatically. Memory limit times the number of concurrent users should stay below the server memory.">
|
||||
<div class="content-group">
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_tenant" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_tenant" placeholder="Empty = the organisation bound to the token"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_version" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_version" placeholder="18"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_lang" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_lang" placeholder="en_US"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_mem_mb" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_mem_mb" placeholder="Empty = platform default"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_sleep_hours" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_sleep_hours" placeholder="0 = never sleep"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_actions" string="Maintenance" invisible="not odoosh_enabled"
|
||||
help="After the first setup, or after changing the defaults, push every existing user once.">
|
||||
<div class="mt8">
|
||||
<button name="action_odoosh_sync_all" type="object"
|
||||
string="Sync all users" class="btn-link" icon="fa-refresh"/>
|
||||
<button name="action_odoosh_open_logs" type="object"
|
||||
string="Open sync queue" class="btn-link" icon="fa-list"/>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
</block>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- User form: the Lab Environment tab holds every platform permission; edit and it syncs -->
|
||||
<record id="view_users_form_odoosh" model="ir.ui.view">
|
||||
<field name="name">res.users.form.inherit.odoosh</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Lab Environment" name="odoosh"
|
||||
groups="base.group_system,odoosh_connector.group_odoosh_teacher">
|
||||
<group>
|
||||
<group string="Synchronisation">
|
||||
<field name="odoosh_sync_enabled"/>
|
||||
<field name="odoosh_sync_state"/>
|
||||
<field name="odoosh_synced_at"/>
|
||||
<field name="odoosh_platform_username"/>
|
||||
</group>
|
||||
<group string="Actions">
|
||||
<div colspan="2" class="d-flex gap-2 flex-wrap">
|
||||
<button name="action_odoosh_sync_now" type="object" string="Sync now"
|
||||
class="btn-primary" icon="fa-refresh" invisible="not odoosh_sync_enabled"/>
|
||||
</div>
|
||||
<div colspan="2" class="text-muted small mt-2">
|
||||
New users are synced automatically. Switching this off stops every push for
|
||||
this user and leaves the platform account as it is.
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Last error" invisible="not odoosh_last_error">
|
||||
<field name="odoosh_last_error" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<group invisible="not odoosh_sync_enabled">
|
||||
<group string="Identity and roles">
|
||||
<field name="odoosh_username" placeholder="Empty = derived from the login"/>
|
||||
<field name="odoosh_tenant" placeholder="Empty = default organisation"/>
|
||||
<field name="odoosh_role"/>
|
||||
<field name="odoosh_tenant_role"/>
|
||||
</group>
|
||||
<group string="Permissions and quotas">
|
||||
<field name="odoosh_shell_enabled"/>
|
||||
<field name="odoosh_can_use_enterprise"/>
|
||||
<field name="odoosh_auto_provision"/>
|
||||
<field name="odoosh_max_envs"/>
|
||||
<field name="odoosh_max_dbs"/>
|
||||
</group>
|
||||
<group string="Platform menus">
|
||||
<field name="odoosh_menu_dashboard"/>
|
||||
<field name="odoosh_menu_repos"/>
|
||||
<field name="odoosh_menu_ai"/>
|
||||
<field name="odoosh_menu_backups"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_users_tree_odoosh" model="ir.ui.view">
|
||||
<field name="name">res.users.list.inherit.odoosh</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='login_date']" position="after">
|
||||
<field name="odoosh_sync_enabled" optional="hide"/>
|
||||
<field name="odoosh_sync_state" optional="hide"/>
|
||||
<field name="odoosh_tenant_role" optional="hide"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_users_search_odoosh" model="ir.ui.view">
|
||||
<field name="name">res.users.search.inherit.odoosh</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//filter[@name='Inactive']" position="after">
|
||||
<separator/>
|
||||
<filter name="odoosh_failed" string="Lab sync failed" domain="[('odoosh_sync_state', '=', 'failed')]"/>
|
||||
<filter name="odoosh_pending" string="Lab sync pending" domain="[('odoosh_sync_state', '=', 'pending')]"/>
|
||||
<filter name="odoosh_disabled" string="Lab sync disabled" domain="[('odoosh_sync_enabled', '=', False)]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Bulk actions from the user list -->
|
||||
<record id="action_server_odoosh_sync_now" model="ir.actions.server">
|
||||
<field name="name">Sync to lab platform</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list,form</field>
|
||||
<field name="groups_id" eval="[(4, ref('odoosh_connector.group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_odoosh_sync_now()</field>
|
||||
</record>
|
||||
|
||||
<record id="action_server_odoosh_enable" model="ir.actions.server">
|
||||
<field name="name">Enable lab sync</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="groups_id" eval="[(4, ref('odoosh_connector.group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_odoosh_enable_sync()</field>
|
||||
</record>
|
||||
|
||||
<record id="action_server_odoosh_disable" model="ir.actions.server">
|
||||
<field name="name">Disable lab sync</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="groups_id" eval="[(4, ref('odoosh_connector.group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_odoosh_disable_sync()</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,2 @@
|
||||
# recents_limit —— 纯前端补丁模块,无 Python 模型
|
||||
# 仅通过 JS patch 把导航栏「最近」分组上限从 50 改为 20。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
'name': 'Recents Limit (20)',
|
||||
'version': '18.0.1.0.0',
|
||||
'summary': '把导航栏「最近」分组上限从 50 改为 20(继承 udoo_om_ux,不改动线上源码)',
|
||||
'description': """
|
||||
通过前端 patch 继承 udoo_om_ux 的 OmuxNavBar,把「最近访问」的分组上限
|
||||
从默认的 50 截到 20。
|
||||
|
||||
实现方式(不改动 udoo_om_ux 与 yuthon_base 的任何源码):
|
||||
1. setup 时把历史 localStorage 中可能 >20 的 recents 截断到 20;
|
||||
2. 监听 UDOO:FRC 事件,在每次新增「最近」后把上限压回 20。
|
||||
由于整个会话期间 this.uo.recents 恒 <= 20,beforeunload 持久化到
|
||||
localStorage 时自然也 <= 20,无需再 patch beforeunload 逻辑。
|
||||
""",
|
||||
'category': 'Tools',
|
||||
'depends': ['udoo_om_ux', 'web'],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'recents_limit/static/src/js/recents_limit.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
'author': 'Yuthon',
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** @odoo-module **/
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { OmuxNavBar } from "@udoo_om_ux/webclient/navbar/navbar";
|
||||
import { useBus } from "@web/core/utils/hooks";
|
||||
|
||||
// 「最近访问」分组的目标上限(线上 udoo_om_ux 默认为 50)
|
||||
const MAX_RECENTS = 20;
|
||||
|
||||
patch(OmuxNavBar.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
// 1) 处理从历史 localStorage 加载进来的 >20 条「最近」
|
||||
if (this.uo?.recents?.length > MAX_RECENTS) {
|
||||
this.uo.recents.length = MAX_RECENTS;
|
||||
}
|
||||
|
||||
// 2) 实时:每次新增「最近」访问后,把上限压回 20。
|
||||
// 原 OmuxNavBar 在 UDOO:FRC 事件里会把上限维持在 50,
|
||||
// 这里在其之后(先注册者先触发)再截到 20,
|
||||
// 于是整个会话期间 this.uo.recents 恒 <= 20,
|
||||
// 连带 beforeunload 持久化到 localStorage 也自然 <= 20。
|
||||
useBus(this.env.bus, "UDOO:FRC", () => {
|
||||
if (this.uo?.recents?.length > MAX_RECENTS) {
|
||||
this.uo.recents.length = MAX_RECENTS;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
'name': 'student_organization',
|
||||
'name': '学生组织',
|
||||
'version': '1.3',
|
||||
'summary': '',
|
||||
'summary': '管理学生组织架构、班级、年级等基础信息',
|
||||
'sequence': 11,
|
||||
'depends': ['base','hr'],
|
||||
'data':[
|
||||
@@ -14,6 +14,5 @@
|
||||
|
||||
'installable': True,
|
||||
'application': True,
|
||||
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1 +0,0 @@
|
||||
from . import models
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
'name': 'Yuthon 员工管理',
|
||||
'version': '18.0.1.0.0',
|
||||
'summary': '大学人事基础:教职工档案(工号/部门/岗位/基本工资)',
|
||||
'description': """
|
||||
教学模块①(基础层):yuthon_employee
|
||||
======================================
|
||||
定义教职工(员工)主数据模型 yuthon.employee,是整个「出勤-薪资」体系的
|
||||
数据根(root)。后续 yuthon_attendance、yuthon_salary 都依赖本模块。
|
||||
|
||||
教学要点:
|
||||
- 模型定义:_name / _description
|
||||
- 字段类型:Char / Selection / Float / Date / Boolean
|
||||
- 唯一约束:_sql_constraints(工号不可重复)
|
||||
- 模块组合:被其它模块通过 _inherit 追加反向关联(attendance_ids / payslip_ids)
|
||||
- ACL:ir.model.access.csv 授权
|
||||
""",
|
||||
'author': 'Yuthon',
|
||||
'website': '',
|
||||
'category': 'Education',
|
||||
'depends': ['base', 'web'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/employee_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/demo.xml',
|
||||
],
|
||||
'license': 'LGPL-3',
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- 演示数据:3 名教职工(数据根)。出勤/薪资 demo 通过 ref 引用这里的 id。 -->
|
||||
<record id="employee_zhang" model="yuthon.employee">
|
||||
<field name="employee_no">T2024001</field>
|
||||
<field name="name">张三</field>
|
||||
<field name="gender">male</field>
|
||||
<field name="department">teaching</field>
|
||||
<field name="job_title">讲师</field>
|
||||
<field name="phone">13800000001</field>
|
||||
<field name="email">zhangsan@school.edu</field>
|
||||
<field name="base_salary">8000.0</field>
|
||||
<field name="hire_date">2024-09-01</field>
|
||||
</record>
|
||||
|
||||
<record id="employee_li" model="yuthon.employee">
|
||||
<field name="employee_no">A2024002</field>
|
||||
<field name="name">李四</field>
|
||||
<field name="gender">female</field>
|
||||
<field name="department">admin</field>
|
||||
<field name="job_title">教务员</field>
|
||||
<field name="phone">13800000002</field>
|
||||
<field name="email">lisi@school.edu</field>
|
||||
<field name="base_salary">6000.0</field>
|
||||
<field name="hire_date">2024-09-01</field>
|
||||
</record>
|
||||
|
||||
<record id="employee_wang" model="yuthon.employee">
|
||||
<field name="employee_no">T2024003</field>
|
||||
<field name="name">王五</field>
|
||||
<field name="gender">male</field>
|
||||
<field name="department">teaching</field>
|
||||
<field name="job_title">副教授</field>
|
||||
<field name="phone">13800000003</field>
|
||||
<field name="email">wangwu@school.edu</field>
|
||||
<field name="base_salary">12000.0</field>
|
||||
<field name="hire_date">2024-09-01</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1 +0,0 @@
|
||||
from . import employee
|
||||
@@ -1,74 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import fields, models, api
|
||||
|
||||
|
||||
class Employee(models.Model):
|
||||
"""教职工(员工)主数据模型。
|
||||
|
||||
这是「出勤-薪资」体系的**数据根**:出勤记录挂在员工下,
|
||||
工资条在月底把「员工基本工资 + 当月出勤聚合结果」join 在一起算出来。
|
||||
"""
|
||||
_name = 'yuthon.employee'
|
||||
_description = '教职工(员工)'
|
||||
_order = 'employee_no'
|
||||
|
||||
# ---- 基础信息 ----
|
||||
employee_no = fields.Char(
|
||||
string='工号',
|
||||
required=True,
|
||||
index=True,
|
||||
help='唯一工号,如 T2024001(教师)/ A2024002(行政)',
|
||||
)
|
||||
name = fields.Char(
|
||||
string='姓名',
|
||||
required=True,
|
||||
)
|
||||
gender = fields.Selection(
|
||||
[('male', '男'), ('female', '女')],
|
||||
string='性别',
|
||||
)
|
||||
department = fields.Selection(
|
||||
[
|
||||
('teaching', '教学部'),
|
||||
('admin', '行政部'),
|
||||
('logistics', '后勤部'),
|
||||
('support', '教辅部'),
|
||||
],
|
||||
string='部门',
|
||||
required=True,
|
||||
default='teaching',
|
||||
)
|
||||
job_title = fields.Char(
|
||||
string='岗位 / 职称',
|
||||
help='如:讲师、副教授、教务员',
|
||||
)
|
||||
phone = fields.Char(string='联系电话')
|
||||
email = fields.Char(string='邮箱')
|
||||
|
||||
# ---- 薪资相关(被 yuthon_salary 读取)----
|
||||
base_salary = fields.Float(
|
||||
string='基本工资',
|
||||
default=0.0,
|
||||
help='教学演示用 Float;生产环境建议改用 Monetary + currency_id',
|
||||
)
|
||||
hire_date = fields.Date(string='入职日期')
|
||||
|
||||
active = fields.Boolean(
|
||||
string='在职',
|
||||
default=True,
|
||||
)
|
||||
|
||||
# ---- 反向关联(由 yuthon_attendance / yuthon_salary 通过 _inherit 追加)----
|
||||
# attendance_ids / payslip_ids 不在此定义,避免基础模块反向依赖业务模块。
|
||||
# 这是「模块解耦」的核心:根模块不知道子模块的存在。
|
||||
|
||||
# ---- 约束 ----
|
||||
_sql_constraints = [
|
||||
('uniq_employee_no', 'unique(employee_no)', '工号已存在,不可重复!'),
|
||||
]
|
||||
|
||||
@api.constrains('base_salary')
|
||||
def _check_base_salary(self):
|
||||
for rec in self:
|
||||
if rec.base_salary < 0:
|
||||
raise models.ValidationError('基本工资不能为负数。')
|
||||
@@ -1,2 +0,0 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_yuthon_employee,yuthon.employee,yuthon_employee.model_yuthon_employee,base.group_user,1,1,1,1
|
||||
|
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,88 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============ 列表视图 ============ -->
|
||||
<record id="view_yuthon_employee_list" model="ir.ui.view">
|
||||
<field name="name">yuthon.employee.list</field>
|
||||
<field name="model">yuthon.employee</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="教职工">
|
||||
<field name="employee_no"/>
|
||||
<field name="name"/>
|
||||
<field name="gender"/>
|
||||
<field name="department"/>
|
||||
<field name="job_title"/>
|
||||
<field name="base_salary"/>
|
||||
<field name="active" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============ 表单视图 ============ -->
|
||||
<record id="view_yuthon_employee_form" model="ir.ui.view">
|
||||
<field name="name">yuthon.employee.form</field>
|
||||
<field name="model">yuthon.employee</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="教职工">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="employee_no"/>
|
||||
<field name="name"/>
|
||||
<field name="gender"/>
|
||||
<field name="department"/>
|
||||
<field name="job_title"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="phone"/>
|
||||
<field name="email"/>
|
||||
<field name="base_salary"/>
|
||||
<field name="hire_date"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============ 搜索视图 ============ -->
|
||||
<record id="view_yuthon_employee_search" model="ir.ui.view">
|
||||
<field name="name">yuthon.employee搜索</field>
|
||||
<field name="model">yuthon.employee</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="搜索教职工">
|
||||
<field name="employee_no"/>
|
||||
<field name="name"/>
|
||||
<field name="department"/>
|
||||
<separator/>
|
||||
<filter name="filter_teaching" string="教学部"
|
||||
domain="[('department', '=', 'teaching')]"/>
|
||||
<filter name="filter_active" string="在职"
|
||||
domain="[('active', '=', True)]"/>
|
||||
<separator/>
|
||||
<group expand="0" string="分组">
|
||||
<filter name="groupby_department" string="按部门"
|
||||
context="{'group_by': 'department'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============ 动作 ============ -->
|
||||
<record id="action_yuthon_employee" model="ir.actions.act_window">
|
||||
<field name="name">教职工</field>
|
||||
<field name="res_model">yuthon.employee</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_yuthon_employee_search"/>
|
||||
</record>
|
||||
|
||||
<!-- ============ 根菜单(人事管理) ============ -->
|
||||
<menuitem id="menu_yuthon_hr_root" name="人事管理"
|
||||
web_icon="yuthon_employee,static/description/icon.png"
|
||||
sequence="10"/>
|
||||
<menuitem id="menu_yuthon_employee" name="教职工"
|
||||
parent="menu_yuthon_hr_root"
|
||||
action="action_yuthon_employee" sequence="20"/>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user