57 Commits
Author SHA1 Message Date
qitangbo ec4c918899 Merge pull request '关于页' (#56) from qtb into master
Reviewed-on: #56
2026-10-09 15:03:20 +08:00
qitangbo 87e8f0c636 关于页 2026-10-09 15:02:41 +08:00
qitangbo 8ea8b089af Merge pull request '完善教育' (#55) from qtb into master
Reviewed-on: #55
2026-10-09 14:23:19 +08:00
qitangbo dfa665bbde 编辑 2026-10-09 14:14:14 +08:00
qitangbo 8939e61094 feat: 新增 recents_limit 模块,继承 patch OmuxNavBar 将导航栏「最近」上限从50改为20(不改动线上源码/yuthon_base) 2026-10-08 21:08:31 +08:00
李鹏宇 0a96d0e868 用户菜单调整 2026-10-08 20:30:38 +08:00
李鹏宇 ab62d0e79b 用户菜单调整 2026-10-08 20:18:01 +08:00
李鹏宇 d84c482c84 用户菜单调整 2026-10-08 19:31:22 +08:00
李鹏宇 7b393414b3 用户菜单调整 2026-10-08 18:55:12 +08:00
李鹏宇 fa5b2c649e 用户菜单调整 2026-10-08 18:07:03 +08:00
李鹏宇 27b17aeaa6 用户菜单调整 2026-10-08 17:35:32 +08:00
qitangbo 246326ed9c Merge pull request '完善教育' (#54) from qtb into master
Reviewed-on: #54
2026-09-29 17:29:48 +08:00
qitangbo c84da2ed10 完善教育 2026-09-29 16:39:32 +08:00
qitangbo 9d5a60af09 Merge pull request '完善教育' (#53) from qtb into master
Reviewed-on: #53
2026-09-23 15:33:46 +08:00
qitangbo d73bd82769 完善教育 2026-09-23 15:33:10 +08:00
qitangbo d1a217f8cf Merge pull request '当前用户' (#52) from qtb into master
Reviewed-on: #52
2026-09-22 16:40:27 +08:00
qitangbo 0d6d3e0bb9 右上角当前用户 2026-09-22 16:39:34 +08:00
qitangbo 4cdbaf9799 Merge pull request '翻译' (#51) from qtb into master
Reviewed-on: #51
2026-09-21 19:41:06 +08:00
qitangbo 84df154ce2 翻译 2026-09-21 19:40:38 +08:00
qitangbo 63c87c7e56 Merge pull request 'Qtb' (#50) from qtb into master
Reviewed-on: #50
2026-09-21 18:56:34 +08:00
qitangbo ba32af9e04 课程searchpanel 2026-09-21 18:56:10 +08:00
qitangbo 07c29e451d 课程searchpanel 2026-09-21 18:55:27 +08:00
qitangbo 194594ffa5 Merge pull request '课程searchpanel' (#49) from qtb into master
Reviewed-on: #49
2026-09-21 18:42:26 +08:00
qitangbo 2b89ca6cb5 课程searchpanel 2026-09-21 18:26:02 +08:00
qitangbo 013aa37ebc Merge pull request '课程searchpanel' (#48) from qtb into master
Reviewed-on: #48
2026-09-21 18:23:06 +08:00
qitangbo 9f909e4833 课程searchpanel 2026-09-21 18:22:48 +08:00
qitangbo 544de24893 Merge pull request '添加应用类别配置和隐藏my_school' (#47) from qtb into master
Reviewed-on: #47
2026-09-21 17:52:04 +08:00
qitangbo 1b6ea810b2 feat(college): 应用类别配置菜单+中文列名、课程大纲字段、隐藏公司切换器 2026-09-21 17:49:11 +08:00
qitangbo f75aca9fd5 fix(learning_center): 清理 course_homework 残留的 Git 合并冲突标记 2026-09-21 17:49:10 +08:00
qitangbo aa07c87bbb Merge pull request '课程searchpanel' (#46) from qtb into master
Reviewed-on: #46
2026-09-18 17:36:39 +08:00
qitangbo 0bcbba4383 课程searchpanel 2026-09-18 17:35:34 +08:00
zhouxufan ad7bc5877d Merge pull request '完善教育' (#45) from zhou into master
Reviewed-on: #45
2026-09-18 17:23:12 +08:00
李鹏宇 aa150974b5 完善教育 2026-09-18 17:22:16 +08:00
zhouxufan 2b186521ff Merge pull request '完善教育' (#44) from zhou into master
Reviewed-on: #44
2026-09-18 15:36:18 +08:00
李鹏宇 c02a4c0f51 完善教育 2026-09-18 15:35:12 +08:00
zhouxufan 47dc1c428f Merge pull request '完善教育' (#43) from zhou into master
Reviewed-on: #43
2026-09-17 18:02:38 +08:00
李鹏宇 5c8f8fa895 完善教育 2026-09-17 18:01:55 +08:00
zhouxufan f5967ae13d Merge pull request '完善教育' (#42) from zhou into master
Reviewed-on: #42
2026-09-17 17:23:19 +08:00
李鹏宇 285aeed475 完善教育 2026-09-17 17:20:16 +08:00
zhouxufan 40a9ed9bd1 Merge pull request '完善教育' (#41) from zhou into master
Reviewed-on: #41
2026-09-17 16:29:30 +08:00
李鹏宇 c471fe95bc 完善教育 2026-09-17 16:28:31 +08:00
qitangbo 2627d3e8de Merge pull request '优化菜单和默认值' (#40) from qtb into master
Reviewed-on: #40
2026-09-17 16:25:14 +08:00
qitangbo 5602975376 测试提交 2026-09-17 16:06:34 +08:00
zhouxufan 5c95a5852f Merge pull request '完善教育' (#39) from zhou into master
Reviewed-on: #39
2026-09-16 16:12:27 +08:00
李鹏宇 89b67f0e7f 完善教育 2026-09-16 15:34:00 +08:00
zhouxufan fa061bf754 Merge pull request '完善教育' (#38) from zhou into master
Reviewed-on: #38
2026-09-11 16:47:02 +08:00
李鹏宇 7601338f99 完善教育 2026-09-11 16:45:58 +08:00
zhouxufan 9d91137396 Merge pull request '完善教育' (#37) from zhou into master
Reviewed-on: #37
2026-09-10 19:36:56 +08:00
李鹏宇 ed36f20beb 完善教育 2026-09-10 19:36:08 +08:00
zhouxufan 70dece41ed Merge pull request 'Zhou' (#36) from zhou into master
Reviewed-on: #36
2026-09-10 15:56:04 +08:00
李鹏宇 102a8ca4e1 Merge remote-tracking branch 'origin/zhou' into zhou 2026-09-10 15:18:19 +08:00
李鹏宇 c3b994ca86 实训模块 2026-09-10 15:16:46 +08:00
zhouxufan b044ba3367 Merge pull request 'Zhou' (#35) from zhou into master
Reviewed-on: #35
2026-08-27 20:04:26 +08:00
李鹏宇 148ba8b6c0 Merge branch 'master' into zhou 2026-08-27 20:03:10 +08:00
zhouxufan cd68aa8c72 Merge pull request 'Master' (#34) from master into zhou
Reviewed-on: #34
2026-08-27 20:01:06 +08:00
李鹏宇 c60acb1984 完善教育 2026-08-27 19:53:56 +08:00
wanghaoyang 6bb6e7fc6d Merge pull request '翻译' (#32) from wanghy into master
Reviewed-on: #32
2026-08-24 18:39:31 +08:00
81 changed files with 6214 additions and 467 deletions
+1
View File
@@ -1,2 +1,3 @@
from . import controllers
from . import models
from . import patch_wkhtmltopdf # noqa: F401 应用 wkhtmltopdf 命令行猴子补丁
+10 -1
View File
@@ -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
View File
@@ -1 +1,2 @@
from . import exam_controller
from . import paper_pdf_reportlab
+187 -1
View File
@@ -1,10 +1,14 @@
import json
import logging
import urllib.parse
from datetime import timedelta
from odoo import http, fields
from odoo.http import request
from odoo.exceptions import UserError, AccessError
from ..controllers.paper_pdf_reportlab import render_paper_pdf
_logger = logging.getLogger(__name__)
@@ -58,7 +62,7 @@ class ExamController(http.Controller):
# 计算剩余时间(秒)
remaining_seconds = 0
if paper.start_time and exam.duration_minutes:
end_dt = paper.start_time + fields.Datetime.timedelta(minutes=exam.duration_minutes)
end_dt = paper.start_time + timedelta(minutes=exam.duration_minutes)
remaining_seconds = max(0, int((end_dt - fields.Datetime.now()).total_seconds()))
values = {
@@ -183,3 +187,185 @@ class ExamController(http.Controller):
'question_types': dict(request.env['yuthon.school.exam.question']._fields['question_type'].selection),
}
return request.render('college.exam_result_page', values)
# ------------------------------------------------------------------
# 6. 老师评阅页面:仿学生结果页,在详情页中给简答题打分
# ------------------------------------------------------------------
def _check_review_access(self, paper_id, allow_student_readonly=False):
"""校验当前登录用户是否为该答卷对应考试的发布老师或监督老师。
当 allow_student_readonly=True 时,也允许学生只读查看自己的已评阅卷子。
"""
paper = request.env['yuthon.school.exam.paper'].sudo().browse(paper_id)
if not paper.exists():
raise http.NotFound()
user = request.env.user
# 老师和系统管理员保持原有权限(可评阅/可查看)
if user.has_group('college.group_teacher') or user.has_group('base.group_system'):
exam = paper.exam_id
if user.id != exam.teacher_id.id and user.id not in exam.supervisor_teacher_ids.ids:
raise AccessError('您不是该考试的发布老师或监督老师,无权评阅此答卷。')
if paper.state not in ('submitted', 'graded'):
raise UserError('当前答卷状态为 %s,不能评阅。' % dict(paper._fields['state'].selection).get(paper.state))
return paper
# 学生只读访问:只能查看自己的已评阅卷子
if allow_student_readonly and user.has_group('college.group_student'):
if paper.user_id.id != user.id:
raise AccessError('您只能查看自己的答卷。')
if paper.state != 'graded':
raise UserError('当前答卷尚未评阅,无法查看结果。')
return paper
raise AccessError('仅老师可访问评阅页面。')
@http.route('/exam/review/<int:paper_id>', type='http', auth='user', website=False)
def exam_review(self, paper_id, **kw):
paper = self._check_review_access(paper_id, allow_student_readonly=(kw.get('mode') == 'readonly'))
readonly = kw.get('mode') == 'readonly'
answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id))
values = {
'paper': paper,
'exam': paper.exam_id,
'answers': answers,
'readonly': readonly,
'question_types': dict(request.env['yuthon.school.exam.question']._fields['question_type'].selection),
}
return request.render('college.exam_review_page', values)
@http.route('/exam/grade_answer', type='json', auth='user', methods=['POST'])
def exam_grade_answer(self, **kw):
params = request.params or {}
paper_id = params.get('paper_id')
answer_id = params.get('answer_id')
score = params.get('score')
comment = params.get('comment', '')
paper = self._check_review_access(paper_id)
answer = request.env['yuthon.school.exam.answer'].sudo().browse(int(answer_id))
if not answer.exists() or answer.paper_id.id != paper.id:
return {'status': 'error', 'message': '题目不属于当前答卷。'}
try:
score_val = float(score) if score is not None else 0.0
except (ValueError, TypeError):
return {'status': 'error', 'message': '分数必须是数字。'}
if score_val < 0 or score_val > answer.max_score:
return {'status': 'error', 'message': '分数必须在 0 到 %s 之间。' % answer.max_score}
answer.write({'score': score_val, 'teacher_comment': comment or ''})
return {'status': 'ok', 'paper_score': paper.score}
@http.route('/exam/finish_review', type='json', auth='user', methods=['POST'])
def exam_finish_review(self, **kw):
params = request.params or {}
paper_id = params.get('paper_id')
answer_items = params.get('answers', []) or []
paper = self._check_review_access(paper_id)
# 先批量保存简答题分数和评语
for item in answer_items:
answer_id = item.get('answer_id')
score = item.get('score')
comment = item.get('comment', '')
if score is None or score == '':
continue
answer = request.env['yuthon.school.exam.answer'].sudo().browse(int(answer_id))
if not answer.exists() or answer.paper_id.id != paper.id:
return {'status': 'error', 'message': '题目不属于当前答卷。'}
try:
score_val = float(score)
except (ValueError, TypeError):
return {'status': 'error', 'message': '分数必须是数字。'}
if score_val < 0 or score_val > answer.max_score:
return {'status': 'error', 'message': '第 %s 题分数必须在 0 到 %s 之间。' % (answer.sequence, answer.max_score)}
answer.write({'score': score_val, 'teacher_comment': comment or ''})
try:
paper.with_user(request.env.user).action_save_grade()
except UserError as e:
return {'status': 'error', 'message': str(e)}
return {'status': 'ok', 'state': paper.state, 'paper_score': paper.score}
# ------------------------------------------------------------------
# 7. 下载已批改答卷 PDF
# ------------------------------------------------------------------
def _render_paper_pdf(self, paper):
# 使用 reportlab 直接生成 PDF,绕过 Windows 版 wkhtmltopdf 无法加载中文字体的问题
pdf = render_paper_pdf(paper)
ascii_name = 'exam_paper_%s.pdf' % paper.id
cn_name = '答卷_%s_%s.pdf' % (paper.exam_id.name or 'exam', paper.student_id.name or 'student')
disposition = "attachment; filename=%s; filename*=UTF-8''%s" % (
ascii_name, urllib.parse.quote(cn_name))
return request.make_response(pdf, headers=[
('Content-Type', 'application/pdf'),
('Content-Length', str(len(pdf))),
('Content-Disposition', disposition),
])
@http.route('/exam/download/<int:paper_id>', type='http', auth='user')
def exam_download(self, paper_id, **kw):
paper = self._check_paper_access(paper_id)
if paper.state not in ('submitted', 'graded'):
return request.redirect('/exam/page/%s' % paper_id)
return self._render_paper_pdf(paper)
@http.route('/exam/review/download/<int:paper_id>', type='http', auth='user')
def exam_review_download(self, paper_id, **kw):
# 学生只读查看页里的「下载试卷 PDF」也走这里,需允许学生下载自己的已评阅卷子
paper = self._check_review_access(paper_id, allow_student_readonly=True)
return self._render_paper_pdf(paper)
@http.route('/exam/review/download/batch', type='http', auth='user')
def exam_review_download_batch(self, **kw):
"""批量下载引导页:根据 ids 逐份校验权限,列出有权下载的答卷,
前端依次触发每份独立 PDF 下载(浏览器单请求只能落一个文件,故不做合集)。
学生只能下载自己已评阅的答卷;老师/管理员只能下载自己任教/监督考试的答卷。
单份(ids 仅一个)时自动直接下载该份,无需二次点击。
"""
user = request.env.user
is_staff = user.has_group('college.group_teacher') or user.has_group('base.group_system')
ids_raw = (request.params.get('ids') or '').strip()
paper_ids = [int(x) for x in ids_raw.split(',') if x.strip().isdigit()]
if not paper_ids:
return request.render('college.exam_error_page',
{'message': '请先勾选要下载的答卷。'})
papers = request.env['yuthon.school.exam.paper'].sudo().browse(paper_ids)
papers = papers.filtered(lambda p: p.exists())
# 逐份校验权限,过滤掉无权的卷子
allowed = []
for p in papers:
if is_staff:
exam = p.exam_id
if user.id != exam.teacher_id.id and user.id not in exam.supervisor_teacher_ids.ids:
continue
if p.state not in ('submitted', 'graded'):
continue
else:
# 学生:只能下载自己的已评阅卷子
if p.user_id.id != user.id:
continue
if p.state != 'graded':
continue
allowed.append(p)
if not allowed:
return request.render('college.exam_error_page',
{'message': '您没有权限下载所选的答卷,或所选答卷尚未评阅。'})
if len(allowed) == 1:
# 单份:直接下载该份独立 PDF
return self._render_paper_pdf(allowed[0])
paper_infos = [{
'id': p.id,
'name': '%s - %s' % (p.exam_id.name or '考试', p.student_id.name or '学员'),
'url': '/exam/review/download/%s' % p.id,
} for p in allowed]
return request.render('college.exam_batch_download_page', {
'count': len(allowed),
'papers': paper_infos,
})
+395
View File
@@ -0,0 +1,395 @@
"""使用 reportlab 直接生成学生答卷 PDF,绕过 wkhtmltopdf 中文乱码问题。
Windows 环境的 wkhtmltopdf 0.12.6 (with patched qt) 无法正确加载 @font-face 或系统字体,
导致 PDF 中文渲染为乱码/方框。本模块使用 reportlab 在 Python 端直接绘制 PDF,
并注册 Windows 自带中文字体(SimHei / Microsoft YaHei),从根本上保证中文正常显示。
"""
import re
from datetime import datetime
from io import BytesIO
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (
Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, KeepTogether,
PageBreak,
)
# 尝试注册系统中文字体,按优先级尝试多个路径
_FONT_PATHS = [
('SimHei', r'C:\Windows\Fonts\simhei.ttf'),
('MicrosoftYaHei', r'C:\Windows\Fonts\msyh.ttc'),
('SimSun', r'C:\Windows\Fonts\simsun.ttc'),
]
_REGISTERED_FONTS = {}
def _register_chinese_fonts():
"""注册系统中文字体;返回成功注册的字体名列表。"""
if _REGISTERED_FONTS:
return list(_REGISTERED_FONTS.keys())
for name, path in _FONT_PATHS:
try:
pdfmetrics.registerFont(TTFont(name, path))
_REGISTERED_FONTS[name] = path
except Exception:
pass
return list(_REGISTERED_FONTS.keys())
def _get_font_name(preferred='SimHei'):
"""返回可用的中文字体名,优先使用指定字体。"""
fonts = _register_chinese_fonts()
if preferred in fonts:
return preferred
return fonts[0] if fonts else 'Helvetica'
def _strip_html(html):
"""将 HTML 片段转为纯文本(保留换行/空格的可读性)。"""
if not html:
return ''
text = str(html)
text = re.sub(r'\s+', ' ', text)
text = re.sub(r'</(p|div|br|li|h[1-6]|tr)>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<li>', '• ', text, flags=re.IGNORECASE)
text = re.sub(r'<[^>]+>', ' ', text)
lines = [re.sub(r'\s+', ' ', line).strip() for line in text.split('\n')]
return '\n'.join(line for line in lines if line)
def _escape_xml(text):
"""为 reportlab Paragraph 转义 XML 特殊字符。"""
if not text:
return ''
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def _p(text, style):
"""生成 Paragraph,并转义文本中的 XML 字符。"""
return Paragraph(_escape_xml(text), style)
def _build_styles():
"""构建 PDF 中使用的段落样式。"""
font_name = _get_font_name()
base = getSampleStyleSheet()
styles = {}
styles['title'] = ParagraphStyle(
'title', parent=base['Title'],
fontName=font_name, fontSize=18, leading=26,
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
spaceAfter=6,
)
styles['subtitle'] = ParagraphStyle(
'subtitle', parent=base['Normal'],
fontName=font_name, fontSize=10, leading=16,
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
spaceAfter=12,
)
styles['score_big'] = ParagraphStyle(
'score_big', parent=base['Normal'],
fontName=font_name, fontSize=26, leading=32,
textColor=colors.HexColor('#2563eb'), alignment=TA_LEFT,
)
styles['score_label'] = ParagraphStyle(
'score_label', parent=base['Normal'],
fontName=font_name, fontSize=10, leading=14,
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
)
styles['score_pass'] = ParagraphStyle(
'score_pass', parent=base['Normal'],
fontName=font_name, fontSize=10, leading=14,
textColor=colors.HexColor('#15803d'), alignment=TA_LEFT,
)
styles['score_fail'] = ParagraphStyle(
'score_fail', parent=base['Normal'],
fontName=font_name, fontSize=10, leading=14,
textColor=colors.HexColor('#b91c1c'), alignment=TA_LEFT,
)
styles['section_title'] = ParagraphStyle(
'section_title', parent=base['Normal'],
fontName=font_name, fontSize=12, leading=18,
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
spaceBefore=14, spaceAfter=8,
)
styles['question_title'] = ParagraphStyle(
'question_title', parent=base['Normal'],
fontName=font_name, fontSize=10, leading=16,
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
spaceAfter=4,
)
styles['body'] = ParagraphStyle(
'body', parent=base['Normal'],
fontName=font_name, fontSize=10, leading=16,
textColor=colors.HexColor('#374151'), alignment=TA_LEFT,
)
styles['body_green'] = ParagraphStyle(
'body_green', parent=base['Normal'],
fontName=font_name, fontSize=10, leading=16,
textColor=colors.HexColor('#15803d'), alignment=TA_LEFT,
)
styles['body_red'] = ParagraphStyle(
'body_red', parent=base['Normal'],
fontName=font_name, fontSize=10, leading=16,
textColor=colors.HexColor('#b91c1c'), alignment=TA_LEFT,
)
styles['body_gray'] = ParagraphStyle(
'body_gray', parent=base['Normal'],
fontName=font_name, fontSize=10, leading=16,
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
)
styles['body_orange'] = ParagraphStyle(
'body_orange', parent=base['Normal'],
fontName=font_name, fontSize=10, leading=16,
textColor=colors.HexColor('#b45309'), alignment=TA_LEFT,
)
styles['tag'] = ParagraphStyle(
'tag', parent=base['Normal'],
fontName=font_name, fontSize=9, leading=12,
textColor=colors.HexColor('#3730a3'), alignment=TA_CENTER,
)
styles['tag_green'] = ParagraphStyle(
'tag_green', parent=base['Normal'],
fontName=font_name, fontSize=9, leading=12,
textColor=colors.HexColor('#15803d'), alignment=TA_CENTER,
)
styles['tag_red'] = ParagraphStyle(
'tag_red', parent=base['Normal'],
fontName=font_name, fontSize=9, leading=12,
textColor=colors.HexColor('#b91c1c'), alignment=TA_CENTER,
)
styles['footer'] = ParagraphStyle(
'footer', parent=base['Normal'],
fontName=font_name, fontSize=9, leading=12,
textColor=colors.HexColor('#9ca3af'), alignment=TA_RIGHT,
)
return styles
def _question_type_label(qtype):
return {
'single_choice': '单选题',
'multiple_choice': '多选题',
'true_false': '判断题',
'fill_blank': '填空题',
'essay': '简答题',
}.get(qtype, qtype)
def _build_answer_block(ans, styles, question_number=None):
"""为一道题生成 reportlab 元素列表。
:param question_number: 在试卷中显示的题号;默认使用题目本身的 sequence。
"""
story = []
q = ans.question_id
# 题头:题号 + 题型 + 分数 + 对错
score_text = f'{ans.score or 0} / {ans.max_score or 0} 分'
status_style = styles['tag_green'] if ans.is_correct else styles['tag_red']
status_text = ''
if ans.question_type != 'essay':
status_text = '正确' if ans.is_correct else '错误'
display_number = question_number if question_number is not None else (ans.sequence or 0)
header_data = [
[
_p(f'第 {display_number} 题', styles['question_title']),
_p(_question_type_label(ans.question_type), styles['tag']),
_p(score_text, styles['body']),
],
]
if status_text:
header_data[0].append(_p(status_text, status_style))
col_count = len(header_data[0])
if col_count == 3:
widths = [None, 35 * mm, 35 * mm]
else:
widths = [None, 35 * mm, 35 * mm, 25 * mm]
header_table = Table(header_data, colWidths=widths)
header_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 6),
]))
story.append(header_table)
# 题干
story.append(_p(_strip_html(ans.question_text), styles['body']))
# 选择题选项
if ans.question_type in ('single_choice', 'multiple_choice') and q.option_ids:
for opt in q.option_ids.sorted(lambda o: (o.sequence, o.id)):
badges = []
if opt.is_correct:
badges.append('正确')
if opt in ans.selected_option_ids:
badges.append('你的选择')
badge_text = f' [{" · ".join(badges)}]' if badges else ''
option_text = f'{opt.label}. {_strip_html(opt.content)}{badge_text}'
story.append(_p(option_text, styles['body']))
# 学生作答
if ans.question_type == 'essay':
answer_text = _strip_html(ans.answer_text) or '未作答'
else:
answer_text = ans.answer_display or '未作答'
story.append(_p(f'学生作答:{answer_text}', styles['body']))
# 正确答案 / 参考答案
if ans.question_type == 'single_choice':
correct_labels = ', '.join(opt.label for opt in q._get_correct_options())
story.append(_p(f'正确答案:{correct_labels}', styles['body_green']))
elif ans.question_type == 'multiple_choice':
correct_labels = ', '.join(opt.label for opt in q._get_correct_options())
story.append(_p(f'正确答案:{correct_labels}', styles['body_green']))
elif ans.question_type == 'true_false':
text = '正确' if q.correct_answer == 'True' else '错误'
story.append(_p(f'正确答案:{text}', styles['body_green']))
elif ans.question_type == 'fill_blank':
story.append(_p(f'正确答案:{q.correct_answer or "-"}', styles['body_green']))
elif ans.question_type == 'essay':
ref = _strip_html(q.reference_answer) or '-'
story.append(_p(f'参考答案:{ref}', styles['body_gray']))
# 本题评语
if ans.teacher_comment:
story.append(_p(f'评语:{ans.teacher_comment}', styles['body_orange']))
return story
def _build_paper_story(paper, styles):
"""构建单份答卷的 PDF 流元素列表(不含 doc.build)。"""
story = []
# 表头:考试名 + 学员信息
story.append(_p(paper.exam_id.name or '考试', styles['title']))
info_parts = [f"学员:{paper.student_id.name or '-'}"]
if paper.course_id:
info_parts.append(f"课程:{paper.course_id.name}")
if paper.chapter_ids:
info_parts.append(f"章节:{', '.join(paper.chapter_ids.mapped('name'))}")
state_map = dict(paper._fields['state'].selection)
info_parts.append(f"状态:{state_map.get(paper.state, paper.state)}")
if paper.submit_time:
info_parts.append(f"交卷:{paper.submit_time.strftime('%Y-%m-%d %H:%M')}")
story.append(_p(' | '.join(info_parts), styles['subtitle']))
# 得分概览(仅显示分数数字 + 及格状态 + 及格线)
pass_style = styles['score_pass'] if paper.is_passed else styles['score_fail']
pass_text = '及格' if paper.is_passed else '不及格'
score_row = [
_p(f"{paper.score or 0} 分", styles['score_big']),
_p(pass_text, pass_style),
_p(f"及格分:{paper.pass_score or 0} 分", styles['score_label']),
]
score_table = Table([score_row], colWidths=[70 * mm, 35 * mm, 70 * mm])
score_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f8fafc')),
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e5e7eb')),
('LEFTPADDING', (0, 0), (-1, -1), 12),
('RIGHTPADDING', (0, 0), (-1, -1), 12),
('TOPPADDING', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 10),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
story.append(score_table)
story.append(Spacer(1, 8 * mm))
# 答题详情
story.append(_p('答题详情', styles['section_title']))
answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id))
for idx, ans in enumerate(answers):
block = _build_answer_block(ans, styles, question_number=idx + 1)
block_table = Table([[element] for element in block], colWidths=[170 * mm])
block_table.setStyle(TableStyle([
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e5e7eb')),
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#ffffff')),
('LEFTPADDING', (0, 0), (-1, -1), 10),
('RIGHTPADDING', (0, 0), (-1, -1), 10),
('TOPPADDING', (0, 0), (-1, -1), 8),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
]))
story.append(KeepTogether(block_table))
story.append(Spacer(1, 4 * mm))
# 整卷评语
if paper.teacher_comment:
story.append(Spacer(1, 6 * mm))
comment_table = Table(
[[_p(f"整卷评语:{paper.teacher_comment}", styles['body_orange'])]],
colWidths=[170 * mm]
)
comment_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#fffbeb')),
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#fde68a')),
('LEFTPADDING', (0, 0), (-1, -1), 10),
('RIGHTPADDING', (0, 0), (-1, -1), 10),
('TOPPADDING', (0, 0), (-1, -1), 8),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
]))
story.append(comment_table)
# 页脚生成时间
story.append(Spacer(1, 10 * mm))
story.append(_p(f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}", styles['footer']))
return story
def render_paper_pdf(paper):
"""渲染指定答卷的 PDF 字节流。"""
_register_chinese_fonts()
styles = _build_styles()
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer, pagesize=A4,
rightMargin=20 * mm, leftMargin=20 * mm,
topMargin=20 * mm, bottomMargin=18 * mm,
)
story = _build_paper_story(paper, styles)
doc.build(story)
pdf_bytes = buffer.getvalue()
buffer.close()
return pdf_bytes
def render_papers_pdf(papers):
"""将多份答卷合并渲染为一个 PDF 字节流(每份之间自动分页)。"""
_register_chinese_fonts()
styles = _build_styles()
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer, pagesize=A4,
rightMargin=20 * mm, leftMargin=20 * mm,
topMargin=20 * mm, bottomMargin=18 * mm,
)
story = []
papers_list = list(papers)
for idx, paper in enumerate(papers_list):
story.extend(_build_paper_story(paper, styles))
if idx < len(papers_list) - 1:
story.append(PageBreak())
doc.build(story)
pdf_bytes = buffer.getvalue()
buffer.close()
return pdf_bytes
+59
View File
@@ -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>
-3
View File
@@ -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>
+12 -3
View File
@@ -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>
+60
View File
@@ -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 "维修"
+2
View File
@@ -12,3 +12,5 @@ from . import training
from . import course_import_wizard
from . import ir_module_module_info
from . import exam
from . import exam_import_wizard
from . import question_import_wizard
+2
View File
@@ -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)
+16
View File
@@ -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')
+6 -3
View File
@@ -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):
+15 -1
View File
@@ -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:
+8 -8
View File
@@ -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
View File
@@ -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()
+128
View File
@@ -0,0 +1,128 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from .exam import _QUESTION_TYPES
class ExamImportWizard(models.TransientModel):
"""按课程/章节从题库批量导入题目到考试。"""
_name = 'yuthon.school.exam.import.wizard'
_description = '从题库批量导入题目'
exam_id = fields.Many2one(
'yuthon.school.exam', string='考试', required=True, readonly=True,
default=lambda self: self.env.context.get('active_id'))
course_id = fields.Many2one(
'yuthon.school.course', string='所属课程',
related='exam_id.course_id', readonly=True)
import_all_course = fields.Boolean(
string='导入整门课程题目', default=False,
help='勾选后,将忽略章节选择并导入本课程所有题目。')
chapter_ids = fields.Many2many(
'yuthon.school.course.chapter', 'yuthon_exam_import_wizard_chapter_rel',
string='所属章节',
domain="[('course_id', '=', course_id)]",
help='选择要导入的章节;不勾选“导入整门课程题目”时生效。')
question_type = fields.Selection(
_QUESTION_TYPES, string='题型筛选',
help='只导入指定题型;留空则导入全部题型。')
exclude_existing = fields.Boolean(
string='排除已存在题目', default=True,
help='试卷中已有的题目不会重复导入。')
line_ids = fields.One2many(
'yuthon.school.exam.import.wizard.line', 'wizard_id',
string='待导入题目')
@api.onchange('import_all_course', 'chapter_ids', 'question_type', 'exclude_existing')
def _onchange_load_questions(self):
"""根据筛选条件实时生成待导入题目列表。"""
self.ensure_one()
if not self.exam_id or not self.course_id:
self.line_ids = [(5, 0, 0)]
return
# 草稿状态的考试才允许重组题目;非草稿仅做预览,导入时会再次校验
question_obj = self.env['yuthon.school.exam.question']
domain = [
('course_id', '=', self.course_id.id),
('active', '=', True),
]
if not self.import_all_course and self.chapter_ids:
domain.append(('chapter_id', 'in', self.chapter_ids.ids))
if self.question_type:
domain.append(('question_type', '=', self.question_type))
existing_qids = set()
if self.exclude_existing:
existing_qids = set(self.exam_id.line_ids.mapped('question_id').ids)
questions = question_obj.search(domain, order='chapter_id, sequence, id')
questions = questions.filtered(lambda q: q.id not in existing_qids)
base_seq = 10
commands = [(5, 0, 0)]
for idx, question in enumerate(questions):
commands.append((0, 0, {
'sequence': base_seq + idx * 10,
'question_id': question.id,
'score': question.score,
'selected': True,
}))
self.line_ids = commands
def action_confirm_import(self):
"""将选中的题目写入考试组卷明细。"""
self.ensure_one()
exam = self.exam_id
if not exam:
raise UserError(_('未找到目标考试。'))
if exam.state != 'draft':
raise UserError(_('仅草稿状态的考试可以批量导入题目。'))
existing_qids = set(exam.line_ids.mapped('question_id').ids)
line_vals = []
for line in self.line_ids.filtered('selected'):
if line.question_id.id in existing_qids:
continue
line_vals.append({
'exam_id': exam.id,
'question_id': line.question_id.id,
'sequence': line.sequence,
'score': line.score,
})
if not line_vals:
raise UserError(_('没有需要导入的新题目。'))
self.env['yuthon.school.exam.line'].create(line_vals)
return {'type': 'ir.actions.act_window_close'}
class ExamImportWizardLine(models.TransientModel):
"""向导中的待导入题目明细行。"""
_name = 'yuthon.school.exam.import.wizard.line'
_description = '待导入题目明细'
_order = 'sequence, id'
wizard_id = fields.Many2one(
'yuthon.school.exam.import.wizard', string='向导',
required=True, ondelete='cascade')
selected = fields.Boolean(string='导入', default=True)
sequence = fields.Integer(string='排序', default=10)
question_id = fields.Many2one(
'yuthon.school.exam.question', string='题目', required=True, readonly=True)
question_type = fields.Selection(
related='question_id.question_type', string='题型', readonly=True)
question_text_short = fields.Char(
related='question_id.question_text_short', string='题干', readonly=True)
chapter_id = fields.Many2one(
related='question_id.chapter_id', string='所属章节', readonly=True)
visibility = fields.Selection(
related='question_id.visibility', string='可见范围', readonly=True)
score = fields.Float(string='本题分值', default=1.0)
+39 -16
View File
@@ -86,7 +86,7 @@ class ImportTeacherStudentWizard(models.TransientModel):
'<b>姓名</b>、<b>角色</b>(老师/学生)、<b>手机号</b> 为必填;<br/>'
'<b>班级、学院、性别</b> 为选填;</li>'
'<li>保存为 CSV(逗号分隔)后在此上传并点击「开始导入」;</li>'
'<li>系统将自动创建登录账号(账号=姓名拼音,密码=手机号)并关联师生档案;</li>'
'<li>系统将自动创建登录账号(账号=姓名拼音,<b>学生密码=学号、老师密码=工号</b>)并关联师生档案;</li>'
'<li>若账号已存在则跳过,不会重复创建。</li>'
'</ol>'
),
@@ -96,13 +96,16 @@ class ImportTeacherStudentWizard(models.TransientModel):
def action_download_template(self):
"""生成 CSV 模板并提供下载。"""
header = ['姓名', '角色(老师/学生)', '手机号', '班级', '学院', '性别(男/女)']
sample = ['张三', '学生', '13800138001', '计算机2024级1班', '计算机学院', '男']
header = ['姓名', '角色(老师/学生)', '手机号', '学号', '工号', '班级', '学院', '性别(男/女)']
sample = [
['张三', '学生', '13800138001', '2024001', '', '计算机2024级1班', '计算机学院', '男'],
['李老师', '老师', '13900139000', '', 'T1001', '', '', ''],
]
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
writer.writerow(sample)
writer.writerows(sample)
attachment = self.env['ir.attachment'].sudo().create({
'name': '师生导入模板.csv',
@@ -158,6 +161,8 @@ class ImportTeacherStudentWizard(models.TransientModel):
name = row.get('name', '')
role = row.get('role', '').strip()
phone = row.get('phone', '').strip()
student_number = row.get('student_number', '').strip()
teacher_no = row.get('teacher_no', '').strip()
class_name = row.get('class_name', '').strip()
college_name = row.get('college', '').strip()
gender_raw = row.get('gender', '').strip()
@@ -177,6 +182,16 @@ class ImportTeacherStudentWizard(models.TransientModel):
is_teacher = role_lower in ('老师', 'teacher')
# 学生必须有学号(作为登录密码),老师必须有工号(作为登录密码)
if is_teacher and not teacher_no:
skipped += 1
errors.append(_('第 %d 行:老师必须填写「工号」(作为登录密码),跳过。') % row_idx)
continue
if not is_teacher and not student_number:
skipped += 1
errors.append(_('第 %d 行:学生必须填写「学号」(作为登录密码),跳过。') % row_idx)
continue
try:
# 1. 创建/获取 res.users
login = _to_login(name)
@@ -185,7 +200,8 @@ class ImportTeacherStudentWizard(models.TransientModel):
errors.append(_('第 %d 行:无法生成登录账号,跳过。') % row_idx)
continue
user = self._get_or_create_user(login, name, phone, is_teacher)
password = teacher_no if is_teacher else student_number
user = self._get_or_create_user(login, name, password, is_teacher)
if user is None:
skipped += 1
errors.append(
@@ -194,11 +210,11 @@ class ImportTeacherStudentWizard(models.TransientModel):
# 2. 创建师生档案
if is_teacher:
self._create_teacher(user, name, phone)
self._create_teacher(user, name, phone, teacher_no)
created_teachers += 1
else:
self._create_student(
user, name, phone, class_name, college_name, gender_raw)
user, name, phone, class_name, college_name, gender_raw, student_number)
created_students += 1
created_users += 1
@@ -247,6 +263,10 @@ class ImportTeacherStudentWizard(models.TransientModel):
mapping[f] = 'role'
elif f_lower in ('手机号', 'phone', '电话', '手机', '联系电话'):
mapping[f] = 'phone'
elif f_lower in ('学号', 'student_number', '编号', 'xuehao', '学号/工号'):
mapping[f] = 'student_number'
elif f_lower in ('工号', 'teacher_no', '职工号', 'gonghao', '工号/学号'):
mapping[f] = 'teacher_no'
elif f_lower in ('班级', 'class', 'class_name', '所在班级'):
mapping[f] = 'class_name'
elif f_lower in ('学院', 'college', 'college_name', '所属学院'):
@@ -257,16 +277,15 @@ class ImportTeacherStudentWizard(models.TransientModel):
mapping[f] = f
return mapping
def _get_or_create_user(self, login, name, phone, is_teacher):
def _get_or_create_user(self, login, name, password, is_teacher):
"""获取或创建 res.users,返回 user 记录或 None(冲突时)。"""
group_teacher = self.env.ref('college.group_teacher', raise_if_not_found=False)
group_student = self.env.ref('college.group_student', raise_if_not_found=False)
Portal = self.env.ref('base.group_portal', raise_if_not_found=False)
existing = self.env['res.users'].sudo().search(
[('login', '=', login)], limit=1)
if existing:
# 已存在:检查角色兼容
# 已存在:补加对应角色组(不强行加 Portal,避免内部/外部用户类型冲突)
if is_teacher and group_teacher and group_teacher not in existing.groups_id:
existing.sudo().write({'groups_id': [(4, group_teacher.id)]})
elif not is_teacher and group_student and group_student not in existing.groups_id:
@@ -278,39 +297,43 @@ class ImportTeacherStudentWizard(models.TransientModel):
groups.append(group_teacher.id)
elif not is_teacher and group_student:
groups.append(group_student.id)
if Portal:
groups.append(Portal.id)
return self.env['res.users'].sudo().create({
'name': name,
'login': login,
'password': phone,
'password': password,
'groups_id': [(6, 0, groups)],
})
def _create_teacher(self, user, name, phone):
def _create_teacher(self, user, name, phone, teacher_no):
"""创建老师档案(去重)。"""
existing = self.env['yuthon.school.teacher'].sudo().search(
[('user_id', '=', user.id)], limit=1)
if existing:
if teacher_no and not existing.teacher_no:
existing.sudo().write({'teacher_no': teacher_no})
return existing
return self.env['yuthon.school.teacher'].sudo().create({
'name': name,
'user_id': user.id,
'phone': phone,
'teacher_no': teacher_no,
})
def _create_student(self, user, name, phone, class_name, college_name, gender_raw):
"""创建学生档案(去重)��"""
def _create_student(self, user, name, phone, class_name, college_name, gender_raw, student_number):
"""创建学生档案(去重)。"""
existing = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', user.id)], limit=1)
if existing:
if student_number and not existing.student_number:
existing.sudo().write({'student_number': student_number})
return existing
vals = {
'name': name,
'user_id': user.id,
'phone': phone,
'student_number': student_number,
}
if class_name:
cls = self.env['yuthon.school.class'].sudo().search(
+44
View File
@@ -1,10 +1,37 @@
# -*- coding: utf-8 -*-
from pathlib import Path
from odoo import api, fields, models
from odoo.modules.module import get_module_path
class IrModuleModule(models.Model):
_inherit = 'ir.module.module'
module_origin = fields.Selection(
[('official', '系统原生'), ('local', '本地模块'), ('third_party', '第三方/其它')],
string='模块来源',
compute='_compute_module_origin',
store=True,
index=True,
help='Odoo 官方模块标记为「系统原生」;放在 school 目录下的自建模块标记为「本地模块」;其余为「第三方/其它」。',
)
@api.depends('author', 'name')
def _compute_module_origin(self):
official_authors = {'Odoo', 'Odoo S.A.', 'Odoo S.A'}
for rec in self:
# 优先按物理路径判断: addons/school/ 下的模块视为本地自建
module_path = get_module_path(rec.name)
if module_path and Path(module_path).parent.name.lower() == 'school':
rec.module_origin = 'local'
continue
author = (rec.author or '').strip()
if author in official_authors:
rec.module_origin = 'official'
else:
rec.module_origin = 'third_party'
introduction = fields.Html(
string='模块介绍',
sanitize=True,
@@ -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()
+374
View File
@@ -0,0 +1,374 @@
"""题库批量导入向导 —— 按题型下载 CSV 模板,上传后批量创建题库题目。
每种题型对应一份专属模板:
- 单选 / 多选:题干 + 选项 A~E + 正确答案(单选填 A;多选填 A,B)
- 判断:题干 + 正确答案(正确 / 错误)
- 填空:题干 + 正确答案(标准答案,多空用 | 分隔)
- 简答:题干 + 参考答案
上传后按「所属课程 / 所属章节」定位,自动创建 yuthon.school.exam.question 与对应选项,
出题人默认为当前用户,可见范围默认私有(可在模板中覆盖)。
"""
import base64
import csv
import io
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# 题型 → 模板列与示例行
_TEMPLATE_SPECS = {
'single_choice': {
'label': '单选题',
'cols': [
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
'答案说明(可选)',
'选项A', '选项B', '选项C', '选项D', '选项E(可选)',
'正确答案(填A/B/C/D/E)',
],
'sample': [
'第一章练习-单选', '下列选项中正确的是?', 'Odoo 18 框架入门', '第1章 基础',
'中等', '2', '私有', '',
'选项一', '选项二', '选项三', '选项四', '',
'A',
],
},
'multiple_choice': {
'label': '多选题',
'cols': [
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
'答案说明(可选)',
'选项A', '选项B', '选项C', '选项D', '选项E(可选)',
'正确答案(填 A,B 逗号分隔)',
],
'sample': [
'第一章练习-多选', '以下属于可迭代对象的是?', 'Odoo 18 框架入门', '第1章 基础',
'中等', '3', '私有', '',
'列表', '字典', '字符串', '整数', '',
'A,B,C',
],
},
'true_false': {
'label': '判断题',
'cols': [
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
'答案说明(可选)',
'正确答案(正确/错误)',
],
'sample': [
'第一章练习-判断', 'Odoo 是一个开源 ERP 框架。', 'Odoo 18 框架入门', '第1章 基础',
'简单', '1', '私有', '',
'正确',
],
},
'fill_blank': {
'label': '填空题',
'cols': [
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
'答案说明(可选)',
'正确答案(标准答案,多个空用 | 分隔)',
],
'sample': [
'第一章练习-填空', 'Odoo 的 ORM 全称是 ____。', 'Odoo 18 框架入门', '第1章 基础',
'简单', '1', '私有', '',
'Object Relational Mapping',
],
},
'essay': {
'label': '简答题',
'cols': [
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
'答案说明(可选)',
'参考答案(可选)',
],
'sample': [
'第一章练习-简答', '简述 Odoo 中 Model 与 Recordset 的关系。', 'Odoo 18 框架入门', '第1章 基础',
'困难', '10', '私有', '',
'Model 定义数据结构与行为;Recordset 是某模型的记录集合,支持批量操作。',
],
},
}
_DIFFICULTY_MAP = {
'简单': 'easy', '中等': 'medium', '困难': 'hard',
'easy': 'easy', 'medium': 'medium', 'hard': 'hard',
}
_VISIBILITY_MAP = {
'公开': 'public', '私有': 'private',
'public': 'public', 'private': 'private',
}
class QuestionImportWizard(models.TransientModel):
"""题库批量导入向导。"""
_name = 'yuthon.school.question.import.wizard'
_description = '题库批量导入'
question_type = fields.Selection([
('single_choice', '单选题'),
('multiple_choice', '多选题'),
('true_false', '判断题'),
('fill_blank', '填空题'),
('essay', '简答题'),
], string='导入题型', required=True,
help='选择要导入的题目类型,系统会给出对应的模板。')
data_file = fields.Binary(string='上传文件')
file_name = fields.Char(string='文件名')
note = fields.Html(
string='操作说明', readonly=True,
default=lambda self: _(
'<p><b>使用步骤:</b></p>'
'<ol>'
'<li>先在上方选择「导入题型」;</li>'
'<li>点击「下载模板」获取该题型专属的 CSV 模板(含一列示例数据);</li>'
'<li>用 Excel / WPS 打开,按列填写:<b>题干</b>、<b>所属课程</b> 为必填;'
'选择题填选项 A~E 并在「正确答案」标出正确项(多选用逗号分隔);'
'判断填 正确/错误;填空填标准答案;简答填参考答案;</li>'
'<li>保存为 CSV(建议 UTF-8)后上传,点击「开始导入」;</li>'
'<li>题目会直接写入题库,出题人默认为当前登录老师,可见范围默认私有。</li>'
'</ol>'
),
)
# ─────────────────── 下载模板 ───────────────────
def action_download_template(self):
self.ensure_one()
if not self.question_type:
raise UserError(_('请先选择要导入的题型。'))
spec = _TEMPLATE_SPECS[self.question_type]
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(spec['cols'])
writer.writerow(spec['sample'])
attachment = self.env['ir.attachment'].sudo().create({
'name': '题库导入模板_%s.csv' % spec['label'],
'datas': base64.b64encode(buf.getvalue().encode('utf-8-sig')),
'res_model': self._name,
'res_id': self.id,
'type': 'binary',
'mimetype': 'text/csv',
})
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % attachment.id,
'target': 'self',
}
# ─────────────────── 导入 ───────────────────
def action_import(self):
self.ensure_one()
if not self.question_type:
raise UserError(_('请先选择要导入的题型。'))
if not self.data_file:
raise UserError(_('请先上传填写好的文件。'))
content = base64.b64decode(self.data_file)
for encoding in ('utf-8-sig', 'utf-8', 'gbk', 'gb2312'):
try:
text = content.decode(encoding)
break
except UnicodeDecodeError:
continue
else:
raise UserError(_('无法识别文件编码,请使用 UTF-8 或 GBK 编码的 CSV。'))
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames:
raise UserError(_('文件为空或格式不正确。'))
col_map = self._normalize_columns(reader.fieldnames)
qtype = self.question_type
created = 0
skipped = 0
errors = []
Question = self.env['yuthon.school.exam.question']
for row_idx, raw in enumerate(reader, start=2):
row = {col_map.get(k, k): (v.strip() if v else '') for k, v in raw.items()}
qtext = row.get('question_text', '').strip()
if not qtext:
skipped += 1
errors.append(_('第 %d 行:题干为必填,跳过。') % row_idx)
continue
course_name = row.get('course', '').strip()
if not course_name:
skipped += 1
errors.append(_('第 %d 行:所属课程为必填,跳过。') % row_idx)
continue
course = self.env['yuthon.school.course'].sudo().search(
[('name', '=', course_name)], limit=1)
if not course:
skipped += 1
errors.append(_('第 %d 行:找不到课程「%s」,跳过。') % (row_idx, course_name))
continue
vals = {
'question_text': qtext,
'question_type': qtype,
'course_id': course.id,
'teacher_id': self.env.user.id,
'score': self._parse_float(row.get('score', ''), 1.0),
'difficulty': _DIFFICULTY_MAP.get(row.get('difficulty', '').strip(), 'medium'),
'visibility': _VISIBILITY_MAP.get(row.get('visibility', '').strip(), 'private'),
}
title = row.get('title', '').strip()
if title:
vals['name'] = title
chapter_name = row.get('chapter', '').strip()
if chapter_name:
chapter = self.env['yuthon.school.course.chapter'].sudo().search([
('course_id', '=', course.id),
('name', '=', chapter_name),
], limit=1)
if chapter:
vals['chapter_id'] = chapter.id
explanation = row.get('explanation', '').strip()
if explanation:
vals['explanation'] = explanation
try:
if qtype in ('single_choice', 'multiple_choice'):
option_cmds, correct_set = self._build_options(row, qtype)
if not option_cmds:
skipped += 1
errors.append(_('第 %d 行:未填写任何选项,跳过。') % row_idx)
continue
vals['option_ids'] = option_cmds
elif qtype == 'true_false':
ans = row.get('correct', '').strip()
mapped = self._map_bool(ans)
if mapped is None:
skipped += 1
errors.append(_('第 %d 行:正确答案请填 正确/错误,跳过。') % row_idx)
continue
vals['correct_answer'] = mapped
elif qtype == 'fill_blank':
ans = row.get('correct', '').strip()
if not ans:
skipped += 1
errors.append(_('第 %d 行:正确答案(标准答案)为必填,跳过。') % row_idx)
continue
vals['correct_answer'] = ans
elif qtype == 'essay':
ref = row.get('reference', '').strip()
if ref:
vals['reference_answer'] = ref
Question.create(vals)
created += 1
except Exception as exc:
skipped += 1
errors.append(_('第 %d 行导入失败:%s') % (row_idx, str(exc)))
summary = _('导入完成!新建题目:%d,跳过:%d') % (created, skipped)
if errors:
summary += '\n' + _('注意事项:\n') + '\n'.join(errors[:10])
if len(errors) > 10:
summary += _('\n... 还有 %d 条,详见日志。') % (len(errors) - 10)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('导入结果'),
'message': summary,
'type': 'success' if not errors else 'warning',
'sticky': True,
'next': {'type': 'ir.actions.act_window_close'},
},
}
# ─────────────────── 辅助 ───────────────────
def _build_options(self, row, qtype):
"""根据选项列构造 option_ids 命令,并返回正确选项标签集合。"""
correct_raw = row.get('correct', '').strip().upper()
if qtype == 'single_choice':
# 单选只取第一个正确项
first = next((c for c in correct_raw.replace(',', ',').split(',') if c.strip()), None)
correct_set = {first} if first else set()
else:
correct_set = {c.strip() for c in correct_raw.replace(',', ',').split(',') if c.strip()}
cmds = []
for idx in range(5):
content = row.get('option_%s' % chr(97 + idx), '').strip()
if not content:
continue
label = chr(65 + idx) # A,B,C,D,E
cmds.append((0, 0, {
'label': label,
'content': content,
'is_correct': label in correct_set,
}))
return cmds, correct_set
def _parse_float(self, value, default):
try:
return float(str(value).strip() or default)
except (ValueError, TypeError):
return default
def _map_bool(self, value):
v = value.strip().lower()
if v in ('正确', 'true', 't', '1', '是', '对'):
return 'True'
if v in ('错误', 'false', 'f', '0', '否', '错'):
return 'False'
return None
def _normalize_columns(self, fieldnames):
"""列名 → 内部 key。模板表头常带括号说明(如「正确答案(填A/B/C/D/E)」),
故用「包含」匹配而非全等,兼容中英文与 Excel 自定义列名。"""
mapping = {}
for f in fieldnames:
fl = f.strip().lower()
if '题目标题' in fl or fl in ('title', 'name', '标题'):
mapping[f] = 'title'
elif '题干' in fl or fl in ('question', 'text', '题目'):
mapping[f] = 'question_text'
elif '所属课程' in fl or fl in ('course', '课程'):
mapping[f] = 'course'
elif '所属章节' in fl or fl in ('chapter', '章节'):
mapping[f] = 'chapter'
elif '难度' in fl or fl == 'difficulty':
mapping[f] = 'difficulty'
elif '分值' in fl or '分数' in fl or fl == 'score':
mapping[f] = 'score'
elif '可见范围' in fl or fl == 'visibility':
mapping[f] = 'visibility'
elif '参考答案' in fl or fl == 'reference':
mapping[f] = 'reference'
elif '正确答案' in fl or fl in ('correct', 'correct_answer', '答案'):
mapping[f] = 'correct'
elif '答案说明' in fl or '解析' in fl or fl == 'explanation':
mapping[f] = 'explanation'
elif '选项a' in fl or fl in ('a', 'option_a'):
mapping[f] = 'option_a'
elif '选项b' in fl or fl in ('b', 'option_b'):
mapping[f] = 'option_b'
elif '选项c' in fl or fl in ('c', 'option_c'):
mapping[f] = 'option_c'
elif '选项d' in fl or fl in ('d', 'option_d'):
mapping[f] = 'option_d'
elif '选项e' in fl or fl in ('e', 'option_e'):
mapping[f] = 'option_e'
else:
mapping[f] = f
return mapping
+26
View File
@@ -0,0 +1,26 @@
"""wkhtmltopdf 命令行猴子补丁。
Odoo 18 base 模块在 `ir.actions_report._build_wkhtmltopdf_args` 里
显式传入 `['--disable-local-file-access']`(见 odoo/addons/base/models/ir_actions_report.py 第 304 行),
导致 wkhtmltopdf 无法读取本地文件——既包括 @font-face 的 file://,也包括 Qt 从 `C:\Windows\Fonts`
读取系统字体(@font-face 用注册名找字体也走不到文件)。结果是 PDF 中文渲染成豆腐方框。
本补丁把 `--disable-local-file-access` 换成 `--enable-local-file-access`,让 wkhtmltopdf 能读到
中文字体文件,从而正确渲染 PDF 中文。
"""
from odoo.addons.base.models import ir_actions_report
_original_build_args = ir_actions_report.IrActionsReport._build_wkhtmltopdf_args
def _patched_build_wkhtmltopdf_args(self, *args, **kwargs):
cmd = _original_build_args(self, *args, **kwargs)
# 把 --disable-local-file-access 替换成 --enable-local-file-access
cmd = [a for a in cmd if a != '--disable-local-file-access']
if '--enable-local-file-access' not in cmd:
cmd.append('--enable-local-file-access')
return cmd
ir_actions_report.IrActionsReport._build_wkhtmltopdf_args = _patched_build_wkhtmltopdf_args
+72
View File
@@ -57,4 +57,76 @@
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- 题库:老师可见“公开”或“本人”的题目(只读他人公开题,不可改动) -->
<record id="rule_exam_question_teacher_visible" model="ir.rule">
<field name="name">题库:老师仅可读取公开或本人题目</field>
<field name="model_id" ref="model_yuthon_school_exam_question"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">['|', ('visibility', '=', 'public'), ('teacher_id', '=', user.id)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- 题库:老师仅能写/删自己出的题目 -->
<record id="rule_exam_question_teacher_own" model="ir.rule">
<field name="name">题库:老师仅可写/删本人题目</field>
<field name="model_id" ref="model_yuthon_school_exam_question"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">[('teacher_id', '=', user.id)]</field>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- 考试:老师可读取本人发布或担任监督的考试 -->
<record id="rule_exam_teacher_read" model="ir.rule">
<field name="name">考试:老师可读取本人发布或监督的考试</field>
<field name="model_id" ref="model_yuthon_school_exam"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">['|', ('teacher_id', '=', user.id), ('supervisor_teacher_ids', 'in', [user.id])]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- 考试:老师仅能写/删本人发布的考试 -->
<record id="rule_exam_teacher_own" model="ir.rule">
<field name="name">考试:老师仅可写/删本人发布的考试</field>
<field name="model_id" ref="model_yuthon_school_exam"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">[('teacher_id', '=', user.id)]</field>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- 答卷:老师可查看/批改本人发布或监督的考试答卷 -->
<record id="rule_exam_paper_teacher_visible" model="ir.rule">
<field name="name">考试答卷:老师可查看/批改本人发布或监督的答卷</field>
<field name="model_id" ref="model_yuthon_school_exam_paper"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">['|', ('exam_id.teacher_id', '=', user.id), ('exam_id.supervisor_teacher_ids', 'in', [user.id])]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- 答题记录:老师可查看/批改本人发布或监督的考试答题记录 -->
<record id="rule_exam_answer_teacher_visible" model="ir.rule">
<field name="name">考试答题:老师可查看/批改本人发布或监督的答题记录</field>
<field name="model_id" ref="model_yuthon_school_exam_answer"/>
<field name="groups" eval="[(4, ref('college.group_teacher'))]"/>
<field name="domain_force">['|', ('paper_id.exam_id.teacher_id', '=', user.id), ('paper_id.exam_id.supervisor_teacher_ids', 'in', [user.id])]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>
+6
View File
@@ -67,3 +67,9 @@ access_yuthon_school_exam_paper_student,yuthon.school.exam.paper.student,model_y
access_yuthon_school_exam_answer_admin,yuthon.school.exam.answer.admin,model_yuthon_school_exam_answer,base.group_system,1,1,1,1
access_yuthon_school_exam_answer_teacher,yuthon.school.exam.answer.teacher,model_yuthon_school_exam_answer,college.group_teacher,1,1,1,1
access_yuthon_school_exam_answer_student,yuthon.school.exam.answer.student,model_yuthon_school_exam_answer,college.group_student,1,1,1,0
access_yuthon_school_exam_import_wizard_admin,yuthon.school.exam.import.wizard.admin,model_yuthon_school_exam_import_wizard,base.group_system,1,1,1,1
access_yuthon_school_exam_import_wizard_teacher,yuthon.school.exam.import.wizard.teacher,model_yuthon_school_exam_import_wizard,college.group_teacher,1,1,1,1
access_yuthon_school_exam_import_wizard_line_admin,yuthon.school.exam.import.wizard.line.admin,model_yuthon_school_exam_import_wizard_line,base.group_system,1,1,1,1
access_yuthon_school_exam_import_wizard_line_teacher,yuthon.school.exam.import.wizard.line.teacher,model_yuthon_school_exam_import_wizard_line,college.group_teacher,1,1,1,1
access_yuthon_school_question_import_wizard_admin,yuthon.school.question.import.wizard.admin,model_yuthon_school_question_import_wizard,base.group_system,1,1,1,1
access_yuthon_school_question_import_wizard_teacher,yuthon.school.question.import.wizard.teacher,model_yuthon_school_question_import_wizard,college.group_teacher,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
67 access_yuthon_school_exam_answer_admin yuthon.school.exam.answer.admin model_yuthon_school_exam_answer base.group_system 1 1 1 1
68 access_yuthon_school_exam_answer_teacher yuthon.school.exam.answer.teacher model_yuthon_school_exam_answer college.group_teacher 1 1 1 1
69 access_yuthon_school_exam_answer_student yuthon.school.exam.answer.student model_yuthon_school_exam_answer college.group_student 1 1 1 0
70 access_yuthon_school_exam_import_wizard_admin yuthon.school.exam.import.wizard.admin model_yuthon_school_exam_import_wizard base.group_system 1 1 1 1
71 access_yuthon_school_exam_import_wizard_teacher yuthon.school.exam.import.wizard.teacher model_yuthon_school_exam_import_wizard college.group_teacher 1 1 1 1
72 access_yuthon_school_exam_import_wizard_line_admin yuthon.school.exam.import.wizard.line.admin model_yuthon_school_exam_import_wizard_line base.group_system 1 1 1 1
73 access_yuthon_school_exam_import_wizard_line_teacher yuthon.school.exam.import.wizard.line.teacher model_yuthon_school_exam_import_wizard_line college.group_teacher 1 1 1 1
74 access_yuthon_school_question_import_wizard_admin yuthon.school.question.import.wizard.admin model_yuthon_school_question_import_wizard base.group_system 1 1 1 1
75 access_yuthon_school_question_import_wizard_teacher yuthon.school.question.import.wizard.teacher model_yuthon_school_question_import_wizard college.group_teacher 1 1 1 1
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);
}
});
+31
View File
@@ -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 &lt; 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 &lt; 142 ? (currentApp.label or currentApp.name) : false</attribute>
<attribute name="data-tooltip-position">right</attribute>
</xpath>
</t>
</templates>
+2 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+24 -8
View File
@@ -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
+333 -30
View File
@@ -2,19 +2,15 @@
<odoo>
<!-- ============================================================
学生答题卡式考试页面模板
学生答题卡式考试页面模板(自包含 HTML,不依赖 website/portal)
============================================================ -->
<template id="exam_take_page" name="考试答题页面">
<t t-call="web.frontend_layout">
<t t-set="html_data" t-value="{'style': 'height: 100%;'}"/>
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<t t-set="head">
<title><t t-esc="exam.name"/> - 在线考试</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</t>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title><t t-esc="exam.name"/> - 在线考试</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>
:root {
--exam-primary: #2563eb;
@@ -68,6 +64,7 @@
border-radius: 8px;
}
.exam-timer.warning { color: var(--exam-danger); background: #fef2f2; }
.exam-timer-label { font-size: 13px; font-weight: 600; }
.exam-container {
display: flex;
gap: 16px;
@@ -277,7 +274,8 @@
.exam-header { flex-direction: column; align-items: flex-start; gap: 10px; }
}
</style>
</head>
<body>
<div class="exam-header">
<div class="exam-header-left">
<h1><t t-esc="exam.name"/></h1>
@@ -290,7 +288,7 @@
</div>
</div>
<div class="exam-timer" id="exam-timer" t-att-data-seconds="remaining_seconds">
<i class="fa fa-clock-o"/>
<span class="exam-timer-label">剩余时间</span>
<span id="timer-text">--:--:--</span>
</div>
</div>
@@ -368,7 +366,7 @@
if (ans.question_type === 'single_choice') return !!ans.selected_option_id;
if (ans.question_type === 'multiple_choice') return (ans.selected_option_ids || []).length > 0;
if (ans.question_type === 'true_false') return !!ans.answer_choice;
return !!(ans.answer_text &amp;&amp; stripHtml(ans.answer_text).trim());
return !!(ans.answer_text && stripHtml(ans.answer_text).trim());
}
function renderQuestion(idx) {
currentIdx = idx;
@@ -484,7 +482,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: payload, id: Math.random() })
}).then(function(r) { return r.json(); }).then(function(res) {
if (res.result &amp;&amp; res.result.status === 'ok') {
if (res.result && res.result.status === 'ok') {
// 静默保存,不打扰
}
}).catch(function() {
@@ -511,10 +509,10 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: { paper_id: paperId }, id: Math.random() })
}).then(function(r) { return r.json(); }).then(function(res) {
if (res.result &amp;&amp; res.result.status === 'ok') {
if (res.result && res.result.status === 'ok') {
window.location.href = res.result.redirect;
} else {
var err = (res.result &amp;&amp; res.result.message) || '提交失败';
var err = (res.result && res.result.message) || '提交失败';
alert(err);
}
}).catch(function() { alert('提交失败,请检查网络后重试。'); });
@@ -561,18 +559,17 @@
})();
//]]>
</script>
</t>
</body>
</html>
</template>
<!-- 结果页面 -->
<template id="exam_result_page" name="考试结果页面">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<t t-set="head">
<title><t t-esc="exam.name"/> - 考试结果</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</t>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title><t t-esc="exam.name"/> - 考试结果</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>
body { background:#f3f4f6; margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC","Microsoft YaHei",sans-serif; }
.result-wrap { max-width:900px; margin:40px auto; padding:0 20px; }
@@ -597,6 +594,8 @@
.result-pending { color:#b45309; font-weight:600; }
.result-btn { display:inline-block; margin-top:20px; padding:10px 24px; background:#2563eb; color:#fff; border-radius:8px; text-decoration:none; font-weight:600; }
</style>
</head>
<body>
<div class="result-wrap">
<div class="result-card">
<h1 class="result-title"><t t-esc="exam.name"/></h1>
@@ -642,18 +641,244 @@
</div>
</div>
</t>
<a t-att-href="'/exam/download/%s' % paper.id" class="result-btn" style="background:#16a34a;">下载试卷 PDF</a>
<a href="/web" class="result-btn">返回首页</a>
</div>
</div>
</t>
</body>
</html>
</template>
<!-- 老师评阅页面:仿学生结果页,在简答题上直接打分 -->
<template id="exam_review_page" name="老师评阅页面">
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title><t t-esc="exam.name"/> - 老师评阅</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>
body { background:#f3f4f6; margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC","Microsoft YaHei",sans-serif; }
.result-wrap { max-width:900px; margin:40px auto; padding:0 20px; }
.result-card { background:#fff; border-radius:16px; padding:32px; box-shadow:0 2px 8px rgba(0,0,0,0.06); margin-bottom:20px; }
.result-title { font-size:22px; font-weight:700; margin:0 0 8px; }
.result-sub { color:#6b7280; font-size:14px; margin-bottom:20px; }
.result-score { display:flex; align-items:center; gap:20px; }
.score-circle { width:110px; height:110px; border-radius:50%; display:flex; flex-direction:column; align-items:center; justify-content:center; font-weight:700; }
.score-circle.pass { background:#dcfce7; color:#15803d; }
.score-circle.fail { background:#fee2e2; color:#b91c1c; }
.score-circle .num { font-size:34px; line-height:1; }
.score-circle .label { font-size:13px; margin-top:4px; }
.result-meta { flex:1; }
.result-meta-row { display:flex; gap:30px; font-size:14px; margin-bottom:8px; }
.result-answer { border-top:1px solid #e5e7eb; padding-top:18px; margin-top:18px; }
.result-answer-title { font-weight:700; margin-bottom:12px; }
.result-question { background:#f9fafb; border-radius:10px; padding:16px; margin-bottom:12px; }
.result-question-header { display:flex; gap:10px; align-items:center; margin-bottom:8px; font-size:14px; flex-wrap:wrap; }
.result-tag { background:#e0e7ff; color:#3730a3; font-size:12px; padding:2px 8px; border-radius:10px; }
.result-correct { color:#15803d; font-weight:600; }
.result-wrong { color:#b91c1c; font-weight:600; }
.result-pending { color:#b45309; font-weight:600; }
.result-btn { display:inline-block; margin-top:20px; padding:10px 24px; background:#2563eb; color:#fff; border-radius:8px; text-decoration:none; font-weight:600; border:none; cursor:pointer; }
.review-badge { display:inline-block; background:#fef3c7; color:#92400e; font-size:12px; padding:4px 10px; border-radius:20px; margin-left:auto; font-weight:600; }
.review-grade-box { margin-top:14px; padding:14px; background:#fff; border:1px solid #e5e7eb; border-radius:8px; }
.review-grade-row { display:flex; align-items:center; gap:10px; margin-bottom:10px; flex-wrap:wrap; }
.review-grade-row label { font-weight:600; color:#374151; min-width:48px; }
.review-score-input { width:90px; padding:8px 10px; border:1px solid #d1d5db; border-radius:6px; font-size:14px; }
.review-comment-input { flex:1; min-width:200px; padding:8px 10px; border:1px solid #d1d5db; border-radius:6px; font-size:14px; font-family:inherit; }
.review-save-btn { padding:7px 14px; background:#2563eb; color:#fff; border:none; border-radius:6px; font-size:13px; cursor:pointer; }
.review-save-btn:hover { background:#1d4ed8; }
.review-save-btn:disabled { opacity:.6; cursor:default; }
.review-msg { margin-left:10px; font-size:13px; color:#15803d; }
.review-reference { margin-top:10px; padding:10px; background:#f3f4f6; border-radius:6px; font-size:13px; color:#374151; }
.review-readonly-box { margin-top:10px; padding:10px; background:#fffbeb; border:1px solid #fde68a; border-radius:6px; font-size:14px; color:#374151; }
.review-finish-area { text-align:center; margin-top:24px; }
.review-finish-btn { padding:12px 32px; background:#16a34a; color:#fff; border:none; border-radius:8px; font-size:15px; font-weight:600; cursor:pointer; }
.review-finish-btn:hover { background:#15803d; }
.review-finish-btn:disabled { opacity:.6; }
</style>
</head>
<body>
<div class="result-wrap">
<div class="result-card">
<h1 class="result-title"><t t-esc="exam.name"/></h1>
<div class="result-sub">
课程:<t t-esc="paper.course_id.name or '-'"/> | 学员:<t t-esc="paper.student_id.name or '-'"/>
</div>
<div class="result-score">
<div t-att-class="'score-circle ' + ('pass' if paper.is_passed else 'fail')">
<span class="num"><t t-esc="paper.score"/></span>
<span class="label">/<t t-esc="paper.total_score"/> 分</span>
</div>
<div class="result-meta">
<div class="result-meta-row"><strong>是否及格:</strong><t t-esc="'及格' if paper.is_passed else '不及格'"/></div>
<div class="result-meta-row"><strong>及格分:</strong><t t-esc="paper.pass_score"/> 分</div>
<div class="result-meta-row"><strong>状态:</strong><t t-esc="dict(paper._fields['state'].selection).get(paper.state)"/></div>
</div>
<span class="review-badge">
<t t-if="readonly">已评阅</t>
<t t-elif="paper.state == 'graded'">修改评阅</t>
<t t-else="">老师评阅中</t>
</span>
</div>
</div>
<div class="result-card">
<div class="result-answer-title">答题详情</div>
<t t-foreach="answers" t-as="ans">
<div class="result-question" t-att-data-answer-id="ans.id">
<div class="result-question-header">
<span>第 <t t-esc="ans_index + 1"/> 题</span>
<span class="result-tag"><t t-esc="question_types.get(ans.question_type, ans.question_type)"/></span>
<span class="answer-score"><t t-esc="ans.score"/> / <t t-esc="ans.max_score"/> 分</span>
<t t-if="ans.question_type != 'essay'">
<span t-att-class="'result-' + ('correct' if ans.is_correct else 'wrong')">
<t t-esc="'正确' if ans.is_correct else '错误'"/>
</span>
</t>
<t t-else="">
<span class="result-pending">简答题</span>
</t>
</div>
<div t-raw="ans.question_text"/>
<div style="margin-top:8px; color:#374151;">
<strong>学生作答:</strong><t t-esc="ans.answer_display or '未作答'"/>
</div>
<div t-if="ans.reference_answer" class="review-reference">
<strong>参考答案:</strong>
<div t-raw="ans.reference_answer"/>
</div>
<div t-if="ans.explanation" style="margin-top:10px; padding:10px; background:#fff; border-radius:6px;">
<strong>答案说明:</strong>
<div t-raw="ans.explanation"/>
</div>
<t t-if="ans.question_type == 'essay' and readonly">
<t t-if="ans.teacher_comment">
<div class="review-readonly-box">
<strong>评语:</strong><t t-esc="ans.teacher_comment"/>
</div>
</t>
</t>
<t t-if="ans.question_type == 'essay' and not readonly">
<div class="review-grade-box">
<div class="review-grade-row">
<label>得分</label>
<input type="number" class="review-score-input" min="0" step="0.1"
t-att-max="ans.max_score"
t-att-value="ans.score or 0"
t-att-data-answer-id="ans.id"/>
<span>/ <t t-esc="ans.max_score"/> 分</span>
</div>
<div class="review-grade-row">
<label>评语</label>
<textarea class="review-comment-input" rows="2" t-att-data-answer-id="ans.id"><t t-esc="ans.teacher_comment or ''"/></textarea>
</div>
</div>
</t>
</div>
</t>
<div class="review-finish-area" t-if="not readonly">
<button type="button" id="btn-finish-review" class="review-finish-btn"
t-att-data-paper-id="paper.id" t-att-data-paper-state="paper.state">
<t t-if="paper.state == 'graded'">保存修改</t>
<t t-else="">完成评阅</t>
</button>
<div id="review-finish-msg" style="margin-top:12px; font-size:14px; font-weight:600;"></div>
</div>
<div style="margin-top:20px;">
<a t-att-href="'/exam/review/download/%s' % paper.id" class="result-btn" style="background:#16a34a;">下载试卷 PDF</a>
<a href="/web" class="result-btn">返回首页</a>
</div>
</div>
</div>
<script type="text/javascript">
//<![CDATA[
(function() {
var finishBtn = document.getElementById('btn-finish-review');
if (!finishBtn) return; // 只读模式无此按钮,无需绑定事件
var paperId = parseInt(finishBtn.dataset.paperId, 10) || 0;
var originalBtnText = finishBtn.textContent || '完成评阅';
var readonlyAfterSubmit = finishBtn.dataset.paperState !== 'graded'; // 首次评阅完需跳只读页
var msgEl = document.getElementById('review-finish-msg');
function showMsg(text, color) {
if (!msgEl) return;
msgEl.textContent = text;
msgEl.style.color = color || '#15803d';
}
function postJson(url, payload, cb) {
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: payload, id: Math.random() })
}).then(function(r) { return r.json(); }).then(function(res) {
cb(res.result || res);
}).catch(function() { cb({status:'error', message:'网络异常'}); });
}
finishBtn.addEventListener('click', function() {
var items = [];
var hasError = false;
document.querySelectorAll('.result-question[data-answer-id]').forEach(function(box) {
if (hasError) return;
var scoreInput = box.querySelector('.review-score-input');
if (!scoreInput) return;
var answerId = parseInt(scoreInput.dataset.answerId, 10);
var scoreRaw = scoreInput.value.trim();
var score = scoreRaw === '' ? '' : parseFloat(scoreRaw);
var maxScore = parseFloat(scoreInput.max) || 0;
if (scoreRaw !== '' && (isNaN(score) || score < 0 || score > maxScore)) {
var hdr = box.querySelector('.result-question-header span');
showMsg((hdr ? hdr.textContent : ('题目 ' + answerId)) + ' 得分不合法,请输入 0 ~ ' + maxScore + ' 之间的数字', '#b91c1c');
hasError = true;
return;
}
var commentInput = box.querySelector('.review-comment-input');
items.push({
answer_id: answerId,
score: scoreRaw === '' ? '' : score,
comment: commentInput ? commentInput.value : ''
});
});
if (hasError) return;
finishBtn.disabled = true;
finishBtn.textContent = '保存中...';
showMsg('正在保存评分...', '#6b7280');
postJson('/exam/finish_review', { paper_id: paperId, answers: items }, function(res) {
finishBtn.disabled = false;
if (res.status === 'ok') {
if (readonlyAfterSubmit) {
// 首次评阅完成:提示后跳转到只读结果页
showMsg('评阅完成,总得分:' + res.paper_score + ' 分(已归档)', '#15803d');
finishBtn.textContent = '已评阅';
finishBtn.disabled = true;
setTimeout(function() {
window.location.href = '/exam/review/' + paperId + '?mode=readonly';
}, 900);
} else {
// 重新评阅(已评阅状态保存修改):不刷新、不禁用,保持「保存修改」入口,
// 老师可继续改分并再次保存;仅提示成功,避免按钮在「已评阅/保存修改」间闪烁
var total = (res.paper_score != null) ? res.paper_score : '';
showMsg('评阅修改已保存' + (total !== '' ? (',总得分:' + total + ' 分') : '') + '(已归档)', '#15803d');
finishBtn.textContent = '保存修改';
}
} else {
finishBtn.textContent = originalBtnText;
showMsg(res.message || '完成评阅失败', '#b91c1c');
}
});
});
})();
//]]>
</script>
</body>
</html>
</template>
<!-- 错误提示页 -->
<template id="exam_error_page" name="考试错误提示">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<t t-set="head"><title>提示</title></t>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>提示</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>
body { background:#f3f4f6; margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC","Microsoft YaHei",sans-serif; }
.err-wrap { max-width:480px; margin:80px auto; background:#fff; border-radius:16px; padding:40px; text-align:center; box-shadow:0 2px 8px rgba(0,0,0,0.06); }
@@ -661,12 +886,90 @@
.err-msg { font-size:16px; color:#374151; margin-bottom:24px; }
.err-btn { display:inline-block; padding:10px 24px; background:#2563eb; color:#fff; border-radius:8px; text-decoration:none; }
</style>
</head>
<body>
<div class="err-wrap">
<div class="err-icon">⚠️</div>
<div class="err-msg"><t t-esc="message"/></div>
<a href="/web" class="err-btn">返回首页</a>
</div>
</t>
</body>
</html>
</template>
<!-- 批量下载引导页:列出有权下载的答卷,前端依次触发每份独立 PDF 下载 -->
<template id="exam_batch_download_page" name="考试答卷批量下载">
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>正在下载答卷…</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>
body { background:#f3f4f6; margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC","Microsoft YaHei",sans-serif; }
.wrap { max-width:520px; margin:80px auto; background:#fff; border-radius:16px; padding:36px 40px; text-align:center; box-shadow:0 2px 8px rgba(0,0,0,0.06); }
.icon { font-size:44px; margin-bottom:12px; }
.title { font-size:18px; color:#111827; margin-bottom:8px; font-weight:600; }
.sub { font-size:14px; color:#6b7280; margin-bottom:20px; }
.bar { height:8px; background:#e5e7eb; border-radius:99px; overflow:hidden; }
.bar > i { display:block; height:100%; width:0; background:linear-gradient(90deg,#2563eb,#22d3ee); transition:width .25s ease; }
.tip { font-size:13px; color:#9ca3af; margin-top:14px; }
.fallback { display:none; margin-top:24px; text-align:left; }
.fallback h3 { font-size:15px; color:#111827; margin:0 0 12px; font-weight:600; }
.fallback ul { margin:0; padding-left:18px; line-height:2; }
.fallback li a { color:#2563eb; text-decoration:none; }
.fallback li a:hover { text-decoration:underline; }
</style>
</head>
<body>
<div class="wrap">
<div class="icon">📥</div>
<div class="title">正在逐份下载 <t t-esc="count"/> 份答卷</div>
<div class="sub">浏览器会依次弹出 <t t-esc="count"/> 个 PDF 文件,如未弹出请在下载日志中查看。</div>
<div class="bar"><i id="pg"></i></div>
<div class="tip" id="tip">准备中…</div>
<div class="fallback" id="fallback">
<h3>如果自动下载未开始,请逐份点击下方链接:</h3>
<ul>
<t t-foreach="papers" t-as="p">
<li><a t-att-href="p['url']" t-att-download="p['name'] + '.pdf'">
<t t-esc="p['name']"/> (#<t t-esc="p['id']"/>)
</a></li>
</t>
</ul>
</div>
</div>
<script>
(function () {
var papers = <t t-out="json.dumps(papers)"/>;
var i = 0;
var pg = document.getElementById('pg');
var tip = document.getElementById('tip');
var fallback = document.getElementById('fallback');
function next() {
if (i >= papers.length) {
pg.style.width = '100%';
tip.textContent = '已发送全部下载请求,请检查浏览器下载目录。';
fallback.style.display = 'block';
return;
}
tip.textContent = '正在下载第 ' + (i + 1) + ' / ' + papers.length + ' 份…';
// 用隐藏 iframe 触发 Content-Disposition: attachment 下载,避免被浏览器当弹窗拦截
var f = document.createElement('iframe');
f.style.display = 'none';
f.src = papers[i].url;
document.body.appendChild(f);
// 短暂保留后移除 iframe
setTimeout(function () { if (f.parentNode) f.parentNode.removeChild(f); }, 6000);
i++;
pg.style.width = Math.round((i / papers.length) * 100) + '%';
// 间隔触发,避免浏览器合并/拦截多文件下载
setTimeout(next, 1500);
}
next();
})();
</script>
</body>
</html>
</template>
</odoo>
+123 -31
View File
@@ -18,6 +18,7 @@
<field name="chapter_id" optional="show"/>
<field name="score"/>
<field name="teacher_id" optional="hide"/>
<field name="visibility" optional="show"/>
</list>
</field>
</record>
@@ -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>
+8 -2
View File
@@ -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
View File
@@ -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>
+18
View File
@@ -17,6 +17,7 @@
<field name="summary"/>
<field name="website"/>
<field name="application"/>
<field name="module_origin"/>
<templates>
<t t-name="menu">
<t t-set="installed" t-value="record.state.raw_value == 'installed'"/>
@@ -48,4 +49,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>
+1 -71
View File
@@ -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):
+149
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers
+68
View File
@@ -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,
}
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import main
+80
View File
@@ -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')}
+47
View File
@@ -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
+5
View File
@@ -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
+175
View File
@@ -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)
+458
View File
@@ -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',
}
+349
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_odoosh_sync_log_manager odoosh.sync.log.manager model_odoosh_sync_log group_odoosh_teacher 1 1 0 0
3 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>
+8
View File
@@ -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>
+118
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
# recents_limit —— 纯前端补丁模块,无 Python 模型
# 仅通过 JS patch 把导航栏「最近」分组上限从 50 改为 20。
+26
View File
@@ -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;
}
});
},
});
+2 -3
View File
@@ -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
View File
@@ -1 +0,0 @@
from . import models
-33
View File
@@ -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,
}
-39
View File
@@ -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
View File
@@ -1 +0,0 @@
from . import employee
-74
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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

-88
View File
@@ -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>