126 Commits
Author SHA1 Message Date
qitangbo 802b8efa39 完善教育 2026-10-09 19:19:05 +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
李鹏宇 e5c26c260d 翻译 2026-08-24 18:37:05 +08:00
zhouxufan 52f3a92f6b Merge pull request '完善教育' (#31) from zhou into master
Reviewed-on: #31
2026-08-21 19:31:14 +08:00
李鹏宇 4c03ef6a90 完善教育 2026-08-21 19:30:28 +08:00
zhouxufan 67aabfc2c1 Merge pull request '完善教育' (#30) from zhou into master
Reviewed-on: #30
2026-08-21 19:26:42 +08:00
李鹏宇 b072b7f973 完善教育 2026-08-21 19:25:35 +08:00
zhouxufan dc36d6f3c8 Merge pull request '完善教育' (#29) from zhou into master
Reviewed-on: #29
2026-08-14 17:18:35 +08:00
李鹏宇 87f2003caa 完善教育 2026-08-14 17:17:01 +08:00
zhouxufan 8a653407f2 Merge pull request '完善教育' (#28) from zhou into master
Reviewed-on: #28
2026-08-12 17:12:55 +08:00
李鹏宇 d08668ca8a 完善教育 2026-08-12 17:06:11 +08:00
zhouxufan 522562eaa0 Merge pull request '完善教育' (#26) from zhou into master
Reviewed-on: #26
2026-08-11 18:14:59 +08:00
李鹏宇 ba80a569e9 完善教育 2026-08-11 18:13:51 +08:00
zhouxufan 6452d6b8fc Merge pull request '完善教育' (#25) from zhou into master
Reviewed-on: #25
2026-08-10 15:38:40 +08:00
李鹏宇 9d3f94c966 完善教育 2026-08-10 15:36:57 +08:00
zhouxufan 7cdb9b6651 Merge pull request '完善教育' (#24) from zhou into master
Reviewed-on: #24
2026-08-10 10:49:21 +08:00
李鹏宇 651a7d94ba 完善教育 2026-08-10 10:48:26 +08:00
zhouxufan 10bc7f990e Merge pull request '完善教育' (#23) from zhou into master
Reviewed-on: #23
2026-08-07 19:00:34 +08:00
李鹏宇 df06c81be0 完善教育 2026-08-07 18:59:56 +08:00
zhouxufan 6b166d1f34 Merge pull request '完善教育' (#22) from zhou into master
Reviewed-on: #22
2026-08-07 18:53:46 +08:00
李鹏宇 2ba9f5297a 完善教育 2026-08-07 18:53:16 +08:00
zhouxufan 923a0d7e03 Merge pull request 'zhou' (#21) from zhou into master
Reviewed-on: #21
2026-08-07 18:46:03 +08:00
李鹏宇 64b0e18a74 Merge branch 'master' into zhou 2026-08-07 18:45:34 +08:00
李鹏宇 e98be48f2c 完善教育 2026-08-07 18:45:07 +08:00
wanghaoyang 49d4fb1c5c Merge pull request '菜单显示,登录卡片' (#20) from wanghy into master
Reviewed-on: #20
2026-08-07 18:17:17 +08:00
李鹏宇 0d2c9e890a 菜单显示,登录卡片 2026-08-07 18:16:09 +08:00
zhouxufan 809459e2eb Merge pull request '完善教育' (#19) from zhou into master
Reviewed-on: #19
2026-08-07 16:10:17 +08:00
李鹏宇 ca105fc4ea 完善教育 2026-08-07 16:09:46 +08:00
wanghaoyang 19a294bd2d Merge pull request '菜单显示' (#18) from wanghy into master
Reviewed-on: #18
2026-08-07 15:45:12 +08:00
李鹏宇 f42c3d13ee 菜单显示 2026-08-07 15:40:38 +08:00
wanghaoyang ca60dff018 Merge pull request '加载顺序问题' (#17) from wanghy into master
Reviewed-on: #17
2026-08-07 15:23:41 +08:00
李鹏宇 5ff714f1d6 加载顺序问题 2026-08-07 15:22:04 +08:00
zhouxufan eb7c87fad1 Merge pull request 'zhou' (#16) from zhou into master
Reviewed-on: #16
2026-08-07 12:14:40 +08:00
李鹏宇 9efb4bf916 Merge branch 'refs/heads/master' into zhou
# Conflicts:
#	college/__manifest__.py
#	college/models/__init__.py
#	college/security/ir.model.access.csv
#	college/static/src/img/about.png
2026-08-07 12:13:49 +08:00
李鹏宇 6fcad70326 模块迁移 2026-08-07 11:58:17 +08:00
wanghaoyang 1dd79a0411 Merge pull request '关于' (#14) from wanghy into master
Reviewed-on: #14
2026-08-07 11:50:41 +08:00
李鹏宇 ccdb5f7111 关于 2026-08-07 11:49:59 +08:00
wanghaoyang 068ff4c571 Merge pull request '关于' (#13) from wanghy into master
Reviewed-on: #13
2026-08-07 11:24:37 +08:00
李鹏宇 7adc6fc3c0 关于 2026-08-07 11:23:19 +08:00
wanghaoyang 54eb9c6ac9 Merge pull request '登录页面' (#12) from wanghy into master
Reviewed-on: #12
2026-08-07 10:35:09 +08:00
李鹏宇 ceb085c717 登录页面 2026-08-07 10:32:16 +08:00
wanghaoyang 8707f6d14d Merge pull request '菜单,登录图片,随堂笔记' (#11) from wanghy into master
Reviewed-on: #11
2026-08-06 19:15:35 +08:00
zhouxufan 2733d863be Merge pull request '新增功能' (#10) from zhou into master
Reviewed-on: #10
2026-08-06 19:15:05 +08:00
李鹏宇 a2bd6a5c78 模块迁移 2026-08-06 19:07:15 +08:00
李鹏宇 37d8d3facc 菜单,登录图片,随堂笔记 2026-08-06 19:05:26 +08:00
李鹏宇 dc8cea7659 模块迁移 2026-08-05 19:43:20 +08:00
李鹏宇 dc250f2ae2 模块迁移 2026-08-05 19:36:42 +08:00
李鹏宇 6b0e8fe493 模块迁移 2026-08-05 19:29:35 +08:00
李鹏宇 75c22679aa 模块迁移 2026-08-05 19:20:15 +08:00
李鹏宇 5c9cb7f8ff 模块迁移 2026-08-05 19:13:51 +08:00
李鹏宇 861144f615 模块迁移 2026-08-03 12:08:03 +08:00
李鹏宇 a7165e0ce6 模块迁移 2026-08-03 12:05:33 +08:00
李鹏宇 46c6e5af14 模块迁移 2026-08-03 12:04:46 +08:00
李鹏宇 3a26822e71 模块迁移 2026-08-03 12:03:31 +08:00
李鹏宇 eded38735a 模块迁移 2026-08-03 11:49:31 +08:00
李鹏宇 f453820775 模块迁移 2026-08-03 11:37:50 +08:00
李鹏宇 6efe2130b8 模块迁移 2026-08-03 11:07:03 +08:00
李鹏宇 72e9b84d56 模块迁移 2026-08-03 10:22:37 +08:00
李鹏宇 1a0d1c57e1 模块迁移 2026-08-03 10:19:39 +08:00
李鹏宇 67c61b7cd1 模块迁移 2026-08-03 10:17:53 +08:00
李鹏宇 8f6a9b3381 模块迁移 2026-08-03 10:00:02 +08:00
李鹏宇 59645cbed0 模块迁移 2026-08-03 09:59:10 +08:00
李鹏宇 84c8505cfb 模块迁移 2026-08-03 09:57:51 +08:00
李鹏宇 160c3dcb3a 模块迁移 2026-08-03 09:48:18 +08:00
李鹏宇 fe3585d589 模块迁移 2026-07-31 20:38:35 +08:00
李鹏宇 176da7546b 模块迁移 2026-07-31 20:38:10 +08:00
李鹏宇 cfe31fc044 模块迁移 2026-07-31 18:15:34 +08:00
李鹏宇 0d14744c15 模块迁移 2026-07-31 18:13:34 +08:00
李鹏宇 524aacbacb 模块迁移 2026-07-30 19:56:44 +08:00
李鹏宇 26c311d913 模块迁移 2026-07-28 18:50:48 +08:00
liuhaoran 36b6cb1e0e Merge pull request 'dev_haoran' (#9) from dev_haoran into master
Reviewed-on: #9
2026-06-20 20:04:34 +08:00
123 changed files with 14033 additions and 81 deletions
+3
View File
@@ -0,0 +1,3 @@
from . import controllers
from . import models
from . import patch_wkhtmltopdf # noqa: F401 应用 wkhtmltopdf 命令行猴子补丁
+79
View File
@@ -0,0 +1,79 @@
{
'name': '教育',
'version': '18.0.3.0.7',
'summary': 'Odoo 18 教学管理平台 — 师生管理 + 课程管理 + 作业/进展(老师·学生双角色)',
'description': """
Odoo 18 教学管理平台(面向老师与学生,双角色权限)
师生管理:
- 班级 / 老师 / 学生;支持批量导入(CSV 模板)
- 导入时自动创建登录账号(账号=姓名拼音,密码=手机号)
- 选课学员直接取自师���管理中的学生
课程管理:
- 由老师创建并独立管理(学生仅可读,不能新建)
- 课程形式支持「理论 / 实践」,含视频与 PPT 课件
- 每门课程含:课程介绍、课程目标、课程大纲、课程特色
- 列表 / 表单 / 看板(按状态分组)/ 搜索视图
角色权限:
- 老师:创建课程、布置与批改作业、查看全部学习进展、管理组织架构
- 学生:查看自己选课的课程、提交作业、查看自己的学习进展、参与学习交流
学习进展 / 作业习题:按角色区分菜单与数据可见范围。
""",
'category': 'Education',
'sequence': 20,
'depends': ['base', 'web', 'mail', 'base_install_request'],
'external_dependencies': {
'python': ['pypinyin'],
},
'data': [
'security/groups.xml',
'security/ir.model.access.csv',
'data/seed.xml',
'views/student_views.xml',
'views/org_views.xml',
'views/course_views.xml',
'views/chapter_views.xml',
'views/progress_views.xml',
'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',
'data/about_pages.xml',
'views/about_page_views.xml',
'views/menus.xml',
'views/shixun_kanban_view.xml',
'data/shixun_module_data.xml',
'data/module_info_data.xml',
'views/module_info_views.xml',
],
'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',
],
'web.assets_backend_lazy_dark': [
'college/static/src/scss/college_theme_dark.scss',
],
},
'application': True,
'installable': True,
'license': 'LGPL-3',
'author': 'Yuthon',
}
+2
View File
@@ -0,0 +1,2 @@
from . import exam_controller
from . import paper_pdf_reportlab
+371
View File
@@ -0,0 +1,371 @@
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__)
class ExamController(http.Controller):
"""学生答题卡式考试页面控制器。"""
def _check_paper_access(self, paper_id, states=None):
"""校验当前登录用户是否为该答卷的学员本人。"""
paper = request.env['yuthon.school.exam.paper'].sudo().browse(paper_id)
if not paper.exists():
raise http.NotFound()
if paper.user_id.id != request.env.user.id:
raise AccessError('您无权访问该答卷。')
if states and paper.state not in states:
raise UserError('当前答卷状态不允许该操作。')
return paper
# ------------------------------------------------------------------
# 1. 开始考试:初始化答题记录并跳转到答题页面
# ------------------------------------------------------------------
@http.route('/exam/start/<int:paper_id>', type='http', auth='user')
def exam_start(self, paper_id, **kw):
paper = self._check_paper_access(paper_id, states=('pending', 'in_progress'))
try:
paper.with_user(request.env.user).action_start()
except UserError as e:
return request.render('college.exam_error_page', {'message': str(e)})
return request.redirect('/exam/page/%s' % paper_id)
# ------------------------------------------------------------------
# 2. 答题主页面
# ------------------------------------------------------------------
@http.route('/exam/page/<int:paper_id>', type='http', auth='user', website=False)
def exam_page(self, paper_id, **kw):
paper = self._check_paper_access(paper_id)
if paper.state == 'pending':
# 未开始则自动开始
try:
paper.with_user(request.env.user).action_start()
except UserError as e:
return request.render('college.exam_error_page', {'message': str(e)})
if paper.state not in ('in_progress', 'submitted', 'graded'):
return request.render('college.exam_error_page', {
'message': '该答卷当前状态为 %s,无法进入答题页面。' % dict(paper._fields['state'].selection).get(paper.state)
})
answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id))
exam = paper.exam_id
# 计算剩余时间(秒)
remaining_seconds = 0
if paper.start_time and 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 = {
'paper': paper,
'exam': exam,
'course': paper.course_id,
'chapters': paper.chapter_ids,
'student': paper.student_id,
'answers': answers,
'answer_json': json.dumps([self._answer_to_dict(a) for a in answers]),
'remaining_seconds': remaining_seconds,
'duration_minutes': exam.duration_minutes or 0,
'question_types': dict(request.env['yuthon.school.exam.question']._fields['question_type'].selection),
}
return request.render('college.exam_take_page', values)
def _answer_to_dict(self, answer):
"""将答题记录转为前端 JSON 可用的字典。"""
q = answer.question_id
return {
'id': answer.id,
'sequence': answer.sequence or 0,
'question_type': answer.question_type,
'question_text': answer.question_text or '',
'question_text_short': answer.question_text_short or '',
'max_score': answer.max_score,
'options': [{
'id': opt.id,
'label': opt.label,
'content': opt.content or '',
'text': opt.name or '%s. %s' % (opt.label, opt._strip_html(opt.content or '')),
} for opt in q.option_ids.sorted(lambda o: (o.sequence, o.id))],
# 当前作答
'selected_option_id': answer.selected_option_id.id or False,
'selected_option_ids': answer.selected_option_ids.ids,
'answer_choice': answer.answer_choice or False,
'answer_text': answer.answer_text or '',
'is_answered': bool(
answer.selected_option_id or
answer.selected_option_ids or
answer.answer_choice or
(answer.answer_text and answer._strip_html(answer.answer_text))
),
}
# ------------------------------------------------------------------
# 3. AJAX 保存单题答案
# ------------------------------------------------------------------
@http.route('/exam/save', type='json', auth='user', methods=['POST'])
def exam_save_answer(self, **kw):
# 注意:type='json' 路由的参数在 request.params(JSON-RPC 内层 params),
# 而非 request.jsonrequest(那是 envelope {jsonrpc, method, params, id})。
params = request.params or {}
paper_id = params.get('paper_id')
answer_id = params.get('answer_id')
data = params.get('data', {})
paper = self._check_paper_access(paper_id, states=('in_progress',))
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': '题目不属于当前答卷。'}
qtype = answer.question_type
vals = {}
if qtype == 'single_choice':
vals['selected_option_id'] = int(data.get('selected_option_id') or 0) or False
if vals['selected_option_id']:
vals['selected_option_ids'] = [(6, 0, [vals['selected_option_id']])]
else:
vals['selected_option_ids'] = [(5,)]
elif qtype == 'multiple_choice':
opt_ids = [int(x) for x in data.get('selected_option_ids', []) if x]
vals['selected_option_ids'] = [(6, 0, opt_ids)]
vals['selected_option_id'] = opt_ids[0] if opt_ids else False
elif qtype == 'true_false':
vals['answer_choice'] = data.get('answer_choice') or False
elif qtype in ('fill_blank', 'essay'):
vals['answer_text'] = data.get('answer_text') or False
answer.write(vals)
return {
'status': 'ok',
'is_answered': bool(
answer.selected_option_id or
answer.selected_option_ids or
answer.answer_choice or
(answer.answer_text and answer._strip_html(answer.answer_text))
),
}
# ------------------------------------------------------------------
# 4. AJAX 提交整卷
# ------------------------------------------------------------------
@http.route('/exam/submit', type='json', auth='user', methods=['POST'])
def exam_submit(self, **kw):
params = request.params or {}
paper_id = params.get('paper_id')
paper = self._check_paper_access(paper_id, states=('in_progress',))
try:
paper.with_user(request.env.user).action_submit()
except UserError as e:
return {'status': 'error', 'message': str(e)}
return {
'status': 'ok',
'redirect': '/exam/result/%s' % paper.id,
}
# ------------------------------------------------------------------
# 5. 结果页面
# ------------------------------------------------------------------
@http.route('/exam/result/<int:paper_id>', type='http', auth='user', website=False)
def exam_result(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)
answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id))
values = {
'paper': paper,
'exam': paper.exam_id,
'answers': answers,
'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
+298
View File
@@ -0,0 +1,298 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===================== 系统说明 ===================== -->
<record id="about_page_system" model="yuthon.about.page">
<field name="name">系统说明</field>
<field name="category">system</field>
<field name="sequence">10</field>
<field name="active">True</field>
<field name="content"><![CDATA[
<div class="o_about_page">
<h2>系统说明</h2>
<p>本系统是基于 <b>Odoo 18</b> 构建的在线教学管理平台,面向「老师」与「学生」两类角色,覆盖课程管理、章节资料、作业批改、学习进展跟踪与随堂笔记等教学全流程。</p>
<h3>一、角色与权限</h3>
<ul>
<li><b>管理员</b>:拥有系统全部权限,可管理所有模块、用户与系统配置。</li>
<li><b>老师</b>:创建并管理课程、维护章节与资料、布置与批改作业、记录随堂笔记、查看学习进展、管理师生与组织架构。</li>
<li><b>学生</b>:学习已选课程、查看章节视频与资料、在线提交作业、查看个人学习进展、阅读随堂笔记与说明文档(只读)。</li>
</ul>
<h3>二、主要功能模块</h3>
<ul>
<li><b>课程管理</b>:老师创建课程,支持「理论 / 实践」形式,可上传视频与 PPT 课件,并维护课程介绍、目标、大纲与特色。</li>
<li><b>章节与资料</b>:每门课程包含若干章节,章节下可挂载视频、PPT 与附件,构成完整的学习目录。</li>
<li><b>作业习题</b>:老师布置作业,学生在线提交,老师可批量批改并反馈。</li>
<li><b>学习进展</b>:按章节统计完成情况,师生各自可见对应数据范围。</li>
<li><b>随堂笔记</b>:老师按章节记录富文本笔记,学生端只读。</li>
<li><b>关于</b>:系统说明与操作手册(本页)。</li>
</ul>
<h3>三、账号与登录</h3>
<ul>
<li>师生账号由管理员通过「导入师生」批量创建,登录账号为姓名拼音小写,初始密码为手机号。</li>
<li>首次登录后请尽快修改密码;忘记密码请联系管理员重置。</li>
</ul>
<h3>四、技术说明</h3>
<p>本系统基于 Odoo 18 框架开发,采用 PostgreSQL 数据库,通过 Nginx 反向代理对外提供服务。所有业务数据按角色进行访问控制,确保师生数据隔离。</p>
</div>
]]></field>
</record>
<!-- ===================== 操作手册 ===================== -->
<record id="about_page_operation" model="yuthon.about.page">
<field name="name">操作手册</field>
<field name="category">operation</field>
<field name="sequence">20</field>
<field name="active">True</field>
<field name="content"><![CDATA[
<div class="o_about_page">
<h2>操作手册</h2>
<h3>老师端操作</h3>
<h4>1. 创建课程</h4>
<p>进入「课程管理 → 我的课程」,点击「新建」,填写课程名称、形式(理论 / 实践)、课程介绍、课程目标、课程大纲与课程特色,保存后点击「发布」即可对学生可见。</p>
<h4>2. 维护章节与资料</h4>
<p>在课程表单中切换到「章节」页签,新增章节并填写章节说明;通过章节下的「资料」上传视频链接或 PPT 等附件,构成课程学习目录。</p>
<h4>3. 布置与批改作业</h4>
<p>进入「作业习题 → 作业布置」新建作业并选择对应课程 / 章节;学生在「作业提交」提交后,老师在「作业批改」中查看并提交批改结果,支持批量操作。</p>
<h4>4. 记录随堂笔记</h4>
<p>进入「作业习题 → 随堂笔记」,列表按课程分组,找到对应章节后点击打开,使用富文本编辑器记录课堂要点,保存即可。学生端对该内容只读。</p>
<h4>5. 查看学习进展</h4>
<p>进入「学习管理 → 学习进展」,查看所教课程的整体完成情况及每位学生的进度。</p>
<h3>学生端操作</h3>
<h4>1. 学习课程</h4>
<p>进入「课程学习」,选择已选课程,在左侧章节目录中点击章节,右侧查看视频与资料进行学习。</p>
<h4>2. 提交作业</h4>
<p>进入「作业习题 → 作业提交」,找到对应作业,上传附件或填写内容后点击「提交」。</p>
<h4>3. 查看进展与笔记</h4>
<p>在「学习进展」中查看个人章节完成度;在「随堂笔记」中阅读老师发布的章节笔记。</p>
<h3>通用说明</h3>
<ul>
<li>登录:请使用管理员分配的访问地址进入系统。</li>
<li>密码:首次登录请修改初始密码;忘记密码请联系管理员。</li>
<li>本手册内容由管理员维护,如需补充或更正请联系管理员。</li>
</ul>
</div>
]]></field>
</record>
<!-- ===================== 产品介绍 ===================== -->
<!-- content 为全内联 style 的自包含 H5 网页(只读展示),Html 字段净化器
默认保留 style 属性、剥离 <style> 标签,因此必须用内联 style 而非 class。 -->
<record id="about_page_product" model="yuthon.about.page">
<field name="name">产品介绍</field>
<field name="category">product</field>
<field name="sequence">30</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:#1e293b;line-height:1.65;">
<!-- Hero -->
<div style="position:relative;background:linear-gradient(135deg,#0b1120 0%,#0e3a5c 55%,#0ea5e9 100%);color:#fff;padding:52px 32px 60px;text-align:center;overflow:hidden;">
<div style="position:absolute;top:-60px;right:-40px;width:220px;height:220px;border-radius:50%;background:rgba(56,189,248,0.16);"></div>
<div style="position:absolute;bottom:-80px;left:-30px;width:260px;height:260px;border-radius:50%;background:rgba(250,204,21,0.12);"></div>
<div style="position:relative;z-index:1;display:inline-flex;align-items:center;gap:10px;padding:8px 18px;background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.30);border-radius:999px;font-size:15px;font-weight:600;margin-bottom:20px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:7px;background:#ffd43b;color:#0b1120;font-weight:800;font-family:monospace;">P</span>
python企业实训平台
</div>
<h1 style="position:relative;z-index:1;margin:0 0 12px;font-size:36px;font-weight:800;letter-spacing:1px;color:#fff !important;">从 Python 语法到企业级项目实战</h1>
<p style="position:relative;z-index:1;margin:0 0 22px;font-size:16px;opacity:0.92;font-weight:400;color:#fff !important;">面向高校与职业院校的工程化 Python 实训平台,把企业真实开发场景搬进课堂</p>
<div style="position:relative;z-index:1;display:flex;flex-wrap:wrap;justify-content:center;gap:10px;">
<span style="padding:6px 14px;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.22);border-radius:999px;font-size:13px;">语法基础</span>
<span style="padding:6px 14px;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.22);border-radius:999px;font-size:13px;">Web 开发</span>
<span style="padding:6px 14px;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.22);border-radius:999px;font-size:13px;">数据分析</span>
<span style="padding:6px 14px;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.22);border-radius:999px;font-size:13px;">自动化运维</span>
<span style="padding:6px 14px;background:rgba(255,255,255,0.10);border:1px solid rgba(255,255,255,0.22);border-radius:999px;font-size:13px;">企业项目</span>
</div>
</div>
<!-- 数据条 -->
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:16px;padding:28px 20px 8px;">
<div style="flex:1 1 140px;min-width:130px;text-align:center;background:#f1f5f9;border-radius:14px;padding:18px 12px;">
<div style="font-size:26px;font-weight:800;color:#0ea5e9;">6</div>
<div style="font-size:13px;color:#64748b;margin-top:4px;">阶段化实训体系</div>
</div>
<div style="flex:1 1 140px;min-width:130px;text-align:center;background:#f1f5f9;border-radius:14px;padding:18px 12px;">
<div style="font-size:26px;font-weight:800;color:#2563eb;">20+</div>
<div style="font-size:13px;color:#64748b;margin-top:4px;">企业级项目案例</div>
</div>
<div style="flex:1 1 140px;min-width:130px;text-align:center;background:#f1f5f9;border-radius:14px;padding:18px 12px;">
<div style="font-size:26px;font-weight:800;color:#7c3aed;">1:1</div>
<div style="font-size:13px;color:#64748b;margin-top:4px;">真实企业开发环境</div>
</div>
<div style="flex:1 1 140px;min-width:130px;text-align:center;background:#f1f5f9;border-radius:14px;padding:18px 12px;">
<div style="font-size:26px;font-weight:800;color:#16a34a;">Odoo 18</div>
<div style="font-size:13px;color:#64748b;margin-top:4px;">企业级技术底座</div>
</div>
</div>
<!-- 概述 -->
<div style="padding:18px 24px 0;">
<p style="margin:0;font-size:15px;color:#334155;">平台以 <b>Odoo 18</b> 为底座,围绕 Python 工程化实训重构教学链路:把企业的版本控制、代码评审、持续集成与云端编码环境原样搬进课堂。学员不再孤立地「学语法」,而是以<b>真实项目</b>为主线,在导师带领下走完「需求 → 编码 → 评审 → 部署」的完整闭环,让实训成果直接对接企业岗位能力要求。</p>
</div>
<!-- 核心功能卡片墙 -->
<div style="padding:20px 24px 4px;">
<h2 style="margin:0 0 14px;font-size:20px;font-weight:700;color:#0f172a;">核心功能</h2>
<div style="display:flex;flex-wrap:wrap;gap:14px;">
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#38bdf8,#0ea5e9);color:#fff;font-size:16px;">📚</span>
<b style="font-size:15px;color:#0f172a;">阶段化实训课程</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">语法基础 → Web 开发 → 数据分析 → 自动化运维,分阶课程搭配视频 / PPT / 案例,循序渐进。</p>
</div>
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#ffd43b,#f59e0b);color:#0b1120;font-size:16px;">⌨️</span>
<b style="font-size:15px;color:#0f172a;">在线编码沙箱</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">内置云端 Python 运行环境,免配置即可写代码、跑结果,练习与验证实时完成。</p>
</div>
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#fb923c,#f97316);color:#fff;font-size:16px;">🏗️</span>
<b style="font-size:15px;color:#0f172a;">企业项目库</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">汇集电商、数据看板、接口服务等 20+ 真实项目案例,学员按岗位方向挑选项目实战。</p>
</div>
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#a78bfa,#8b5cf6);color:#fff;font-size:16px;">✅</span>
<b style="font-size:15px;color:#0f172a;">作业评审闭环</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">学员提交代码 / 结果,导师在线批阅并给反馈,模拟企业 Code Review 流程。</p>
</div>
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#60a5fa,#3b82f6);color:#fff;font-size:16px;">📈</span>
<b style="font-size:15px;color:#0f172a;">学习进展看板</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">按项目 / 章节统计完成度与代码质量,师生各有视角,实训进度一目了然。</p>
</div>
<div style="flex:1 1 260px;min-width:240px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#4ade80,#22c55e);color:#fff;font-size:16px;">🎯</span>
<b style="font-size:15px;color:#0f172a;">岗位能力画像</b>
</div>
<p style="margin:0;font-size:14px;color:#475569;">沉淀学员项目经历与技能标签,生成能力画像,直接对标企业招聘需求。</p>
</div>
</div>
</div>
<!-- 流程 -->
<div style="padding:20px 24px 4px;">
<h2 style="margin:0 0 14px;font-size:20px;font-weight:700;color:#0f172a;">实训链路</h2>
<div style="display:flex;flex-wrap:wrap;gap:12px;">
<div style="flex:1 1 300px;min-width:260px;background:linear-gradient(135deg,#eff6ff,#f0f9ff);border:1px solid #bfdbfe;border-radius:14px;padding:16px 18px;">
<div style="font-size:14px;font-weight:700;color:#1d4ed8;margin-bottom:6px;">导师端</div>
<div style="font-size:14px;color:#334155;">配置阶段课程 → 派发企业项目 → 批阅代码与结果 → 沉淀项目笔记 → 跟踪班级实训进度</div>
</div>
<div style="flex:1 1 300px;min-width:260px;background:linear-gradient(135deg,#f0fdf4,#ecfdf5);border:1px solid #bbf7d0;border-radius:14px;padding:16px 18px;">
<div style="font-size:14px;font-weight:700;color:#15803d;margin-bottom:6px;">学员端</div>
<div style="font-size:14px;color:#334155;">进入项目实战 → 在线编码与运行 → 提交作业等待评审 → 查看能力画像与进阶建议</div>
</div>
</div>
</div>
<!-- 优势 -->
<div style="padding:20px 24px 8px;">
<h2 style="margin:0 0 14px;font-size:20px;font-weight:700;color:#0f172a;">为什么选我们</h2>
<div style="display:flex;flex-wrap:wrap;gap:14px;">
<div style="flex:1 1 200px;min-width:180px;background:#fff;border-left:4px solid #0ea5e9;border-radius:10px;padding:14px 16px;box-shadow:0 1px 6px rgba(15,23,42,0.05);">
<b style="font-size:14px;color:#0f172a;">真项目真环境</b>
<p style="margin:6px 0 0;font-size:13px;color:#475569;">复刻企业 Git / CI / 云编码流程,实训即上岗预演。</p>
</div>
<div style="flex:1 1 200px;min-width:180px;background:#fff;border-left:4px solid #2563eb;border-radius:10px;padding:14px 16px;box-shadow:0 1px 6px rgba(15,23,42,0.05);">
<b style="font-size:14px;color:#0f172a;">成果可量化</b>
<p style="margin:6px 0 0;font-size:13px;color:#475569;">代码提交、评审、完成度全程留痕,能力看得见。</p>
</div>
<div style="flex:1 1 200px;min-width:180px;background:#fff;border-left:4px solid #7c3aed;border-radius:10px;padding:14px 16px;box-shadow:0 1px 6px rgba(15,23,42,0.05);">
<b style="font-size:14px;color:#0f172a;">岗位对齐</b>
<p style="margin:6px 0 0;font-size:13px;color:#475569;">项目方向对标 Python 开发 / 数据分析 / 运维岗位。</p>
</div>
<div style="flex:1 1 200px;min-width:180px;background:#fff;border-left:4px solid #16a34a;border-radius:10px;padding:14px 16px;box-shadow:0 1px 6px rgba(15,23,42,0.05);">
<b style="font-size:14px;color:#0f172a;">开箱即用</b>
<p style="margin:6px 0 0;font-size:13px;color:#475569;">基于 Odoo 18,手机 / 平板 / 电脑多端随时实训。</p>
</div>
</div>
</div>
<!-- 页脚 -->
<div style="margin-top:24px;padding:18px 24px;background:#0f172a;color:#94a3b8;font-size:13px;text-align:center;">
python企业实训平台 · 基于 Odoo 18 构建 · 让每一次练习都更接近真实开发
</div>
</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>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- 为 school 自建模块填充「功能亮点 / 模块介绍」文案(幂等,每次升级执行) -->
<function model="ir.module.module" name="populate_module_info"/>
</odoo>
+182
View File
@@ -0,0 +1,182 @@
<odoo>
<!-- ===================== 登录账号(演示用,密码均为 123456) ===================== -->
<record id="user_teacher_wang" model="res.users">
<field name="name">王老师</field>
<field name="login">teacher_wang</field>
<field name="password">123456</field>
<field name="groups_id" eval="[(6, 0, [ref('base.group_user'), ref('group_teacher')])]"/>
</record>
<record id="user_student_wang" model="res.users">
<field name="name">小王</field>
<field name="login">student_wang</field>
<field name="password">123456</field>
<field name="groups_id" eval="[(6, 0, [ref('base.group_user'), ref('group_student')])]"/>
</record>
<!-- ===================== 老师 ===================== -->
<record id="teacher_wang" model="yuthon.school.teacher">
<field name="name">王老师</field>
<field name="user_id" ref="user_teacher_wang"/>
<field name="teacher_no">T2025001</field>
<field name="title">lecturer</field>
<field name="phone">13900000001</field>
</record>
<!-- ===================== 班级 ===================== -->
<record id="class_se_1" model="yuthon.school.class">
<field name="name">软件工程1班</field>
<field name="head_teacher_id" ref="teacher_wang"/>
</record>
<!-- ===================== 学生 ===================== -->
<record id="student_wang" model="yuthon.school.student">
<field name="name">小王</field>
<field name="user_id" ref="user_student_wang"/>
<field name="student_number">2025010100</field>
<field name="gender">male</field>
<field name="class_id" ref="class_se_1"/>
<field name="college">computer</field>
<field name="phone">13800000000</field>
</record>
<record id="student_01" model="yuthon.school.student">
<field name="name">陈小明</field>
<field name="student_number">2025010101</field>
<field name="gender">male</field>
<field name="class_id" ref="class_se_1"/>
<field name="college">computer</field>
<field name="phone">13800000001</field>
</record>
<record id="student_02" model="yuthon.school.student">
<field name="name">李华</field>
<field name="student_number">2025010102</field>
<field name="gender">female</field>
<field name="class_id" ref="class_se_1"/>
<field name="college">software</field>
<field name="phone">13800000002</field>
</record>
<record id="student_03" model="yuthon.school.student">
<field name="name">王芳</field>
<field name="student_number">2025010103</field>
<field name="gender">female</field>
<field name="class_id" ref="class_se_1"/>
<field name="college">computer</field>
<field name="phone">13800000003</field>
</record>
<record id="student_04" model="yuthon.school.student">
<field name="name">张伟</field>
<field name="student_number">2025010104</field>
<field name="gender">male</field>
<field name="class_id" ref="class_se_1"/>
<field name="college">software</field>
<field name="phone">13800000004</field>
</record>
<!-- ===================== 课程(老师:王老师) ===================== -->
<record id="course_odoo_basic" model="yuthon.school.course">
<field name="name">Odoo 18 框架入门</field>
<field name="code">ODOO-101</field>
<field name="teacher_id" ref="user_teacher_wang"/>
<field name="college">computer</field>
<field name="semester">2025_1</field>
<field name="course_type">theory</field>
<field name="hours">32</field>
<field name="start_date">2025-09-01</field>
<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="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>
</record>
<record id="course_odoo_views" model="yuthon.school.course">
<field name="name">Odoo 视图与前端进阶</field>
<field name="code">ODOO-201</field>
<field name="teacher_id" ref="user_teacher_wang"/>
<field name="college">software</field>
<field name="semester">2025_2</field>
<field name="course_type">practice</field>
<field name="hours">64</field>
<field name="start_date">2026-02-20</field>
<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="features"><![CDATA[<p>视频 + PPT 双形态,含实操演练。</p>]]></field>
<field name="student_ids" eval="[(6, 0, [ref('student_02'), ref('student_04')])]"/>
<field name="state">draft</field>
</record>
<record id="course_odoo_ppt_demo" model="yuthon.school.course">
<field name="name">Odoo 报表开发(PPT 讲义)</field>
<field name="code">ODOO-301</field>
<field name="teacher_id" ref="user_teacher_wang"/>
<field name="college">computer</field>
<field name="semester">2026_1</field>
<field name="course_type">theory</field>
<field name="hours">48</field>
<field name="start_date">2026-09-01</field>
<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="features"><![CDATA[<p>适合线下培训,讲师直接分发 PPT。</p>]]></field>
<field name="state">draft</field>
</record>
<!-- ===================== 课程章节(老师端维护,学生端作为学习目录,同源联动) ===================== -->
<record id="chapter_odoo_basic_1" model="yuthon.school.course.chapter">
<field name="course_id" ref="course_odoo_basic"/>
<field name="name">1. 开发环境搭建</field>
<field name="sequence">10</field>
<field name="description"><![CDATA[<p>安装 PostgreSQL 与 Python 虚拟环境,克隆 Odoo 18 源码并成功启动 <code>odoo-bin</code>。</p>]]></field>
<field name="completed_student_ids" eval="[(6, 0, [ref('student_wang'), ref('student_01')])]"/>
</record>
<record id="chapter_odoo_basic_2" model="yuthon.school.course.chapter">
<field name="course_id" ref="course_odoo_basic"/>
<field name="name">2. 模块清单与目录结构</field>
<field name="sequence">20</field>
<field name="description"><![CDATA[<p>认识 <code>__manifest__.py</code>,掌握 models / views / data 目录约定与加载顺序。</p>]]></field>
</record>
<!-- ===================== 学习进展(progress_rate / status 现由章节完成情况自动计算,不再手动写入) ===================== -->
<record id="progress_01" model="yuthon.school.progress">
<field name="course_id" ref="course_odoo_basic"/>
<field name="student_id" ref="student_wang"/>
<field name="last_study_date">2025-10-01</field>
<field name="score">85</field>
</record>
<record id="progress_02" model="yuthon.school.progress">
<field name="course_id" ref="course_odoo_basic"/>
<field name="student_id" ref="student_01"/>
<field name="score">92</field>
</record>
<record id="progress_03" model="yuthon.school.progress">
<field name="course_id" ref="course_odoo_basic"/>
<field name="student_id" ref="student_02"/>
</record>
<!-- ===================== 作业习题 ===================== -->
<record id="assignment_01" model="yuthon.school.assignment">
<field name="name">搭建第一个 Odoo 模块</field>
<field name="course_id" ref="course_odoo_basic"/>
<field name="teacher_id" ref="user_teacher_wang"/>
<field name="description"><![CDATA[<p>参考课程大纲第 3 节,创建一个名为 <code>my_first</code> 的模块,包含至少一个模型和菜单。</p>]]></field>
<field name="due_date">2025-10-15</field>
<field name="state">published</field>
</record>
<record id="submission_01" model="yuthon.school.assignment.submission">
<field name="name">陈小明 - 搭建第一个 Odoo 模块</field>
<field name="assignment_id" ref="assignment_01"/>
<field name="student_id" ref="student_01"/>
<field name="content"><![CDATA[<p>已完成模型定义与菜单,模块可正常安装。</p>]]></field>
<field name="score">90</field>
<field name="feedback">完成度很好,注意命名规范。</field>
<field name="state">graded</field>
</record>
<record id="submission_02" model="yuthon.school.assignment.submission">
<field name="name">李华 - 搭建第一个 Odoo 模块</field>
<field name="assignment_id" ref="assignment_01"/>
<field name="student_id" ref="student_02"/>
<field name="content"><![CDATA[<p>已提交模块压缩包。</p>]]></field>
<field name="state">submitted</field>
</record>
</odoo>
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===================== 动作:打开应用列表(ir.module.module) ===================== -->
<!-- kanban 是第一个 view_mode,直接用 view_id 指定实训模块专用看板视图;
list/form 走系统默认,无需额外的 ir.actions.act_window.view 记录 -->
<record id="action_shixun_module" model="ir.actions.act_window">
<field name="name">实训模块</field>
<field name="res_model">ir.module.module</field>
<field name="view_mode">kanban,list,form</field>
<field name="view_id" ref="shixun_module_kanban_view"/>
<!-- 过滤掉与教育/物管实训无关的类目,使应用总数收敛到 50(limit=50 时不再出现「加载更多」翻页)。
category_id 为 M2O,domain 在客户端用 Python eval 解析,无法使用 ref(),故此处用真实类目 id:
13=Education(教育) 54=Lucky Theme Backend(主题后端)
71=preview_attachment 15=Data Cleaning(数据清理)
94=Administration(后台) 6=Tools(工具)
10=Productivity(生产力) 11=Calendar(日历)
20=Uncategorized(未分类)
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>
<!-- ===================== 菜单:一级菜单,点进去显示 ir.module.module 的应用列表 ===================== -->
<menuitem id="menu_shixun_module"
name="实训模块"
web_icon="college,static/src/img/shixun_module.png"
action="action_shixun_module"
sequence="30"
groups="college.group_teacher,college.group_student,base.group_system"/>
</odoo>
+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 "维修"
+16
View File
@@ -0,0 +1,16 @@
from . import teacher
from . import school_class
from . import student
from . import course
from . import chapter
from . import progress
from . import assignment
from . import assignment_batch_wizard
from . import import_wizard
from . import about_page
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
+25
View File
@@ -0,0 +1,25 @@
from odoo import fields, models
class YuthonAboutPage(models.Model):
"""关于页:系统说明 / 操作手册等静态富文本内容。
每条记录对应一个说明页面,菜单通过 act_window 的 res_id 直接打开预置记录,
师生均可阅读;老师 / 管理员可编辑内容。
"""
_name = 'yuthon.about.page'
_description = '关于页(系统说明 / 操作手册)'
_order = 'sequence, id'
name = fields.Char(string='标题', required=True, translate=True)
category = fields.Selection([
('system', '系统说明'),
('operation', '操作手册'),
('product', '产品介绍'),
('import', '导入说明'),
('team', '团队介绍'),
], string='分类', default='system', required=True)
content = fields.Html(string='内容', sanitize=True, translate=True)
sequence = fields.Integer(string='排序', default=10)
active = fields.Boolean(string='已发布', default=True)
+261
View File
@@ -0,0 +1,261 @@
from odoo import fields, models, api, _
from odoo.exceptions import UserError
import io
import zipfile
import base64
class SchoolAssignment(models.Model):
"""作业习题——教师布置给某门课程的学习任务。"""
_name = 'yuthon.school.assignment'
_description = '作业习题'
_order = 'due_date, id'
name = fields.Char(string='作业标题', required=True)
course_id = fields.Many2one(
'yuthon.school.course',
string='所属课程',
required=True,
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)
description = fields.Html(string='作业说明', sanitize=True)
due_date = fields.Date(string='截止日期')
attachment = fields.Binary(string='附件', attachment=True)
attachment_filename = fields.Char(string='附件名')
state = fields.Selection(
[('draft', '草稿'), ('published', '已发布'), ('closed', '已截止')],
string='状态', default='draft', index=True)
submission_ids = fields.One2many(
'yuthon.school.assignment.submission', 'assignment_id', string='提交记录')
submission_count = fields.Integer(
string='提交数', compute='_compute_submission_count', store=True)
active = fields.Boolean(string='启用', default=True)
@api.depends('submission_ids.state')
def _compute_submission_count(self):
for r in self:
r.submission_count = len(r.submission_ids.filtered(
lambda s: s.state in ('submitted', 'graded')))
def action_publish(self):
"""发布作业,仅草稿状态可发布;发布后自动为选课学员创建待提交记录。"""
for record in self:
if record.state != 'draft':
raise UserError(_('作业「%s」当前状态为 %s,请勿重复发布。') % (record.name or '', record.state))
self.write({'state': 'published'})
self._create_pending_submissions()
return True
def action_publish_batch(self):
"""批量发布:仅处理草稿状态的作业。"""
for record in self:
record.action_publish()
def _create_pending_submissions(self):
"""为课程的每个选课学员创建一条待提交记录(去重)。"""
submission_env = self.env['yuthon.school.assignment.submission'].sudo()
for assignment in self:
course = assignment.course_id
if not course or not course.student_ids:
continue
existing_students = set(
submission_env.search([
('assignment_id', '=', assignment.id),
]).mapped('student_id').ids
)
for student in course.student_ids:
if student.id in existing_students:
continue
submission_env.create({
'assignment_id': assignment.id,
'student_id': student.id,
'state': 'pending',
})
@api.model_create_multi
def create(self, vals_list):
assignments = super().create(vals_list)
assignments._check_overdue()
return assignments
def write(self, vals):
res = super().write(vals)
if any(k in vals for k in ('due_date', 'state')):
self._check_overdue()
return res
def _check_overdue(self):
"""根据截止日期自动调整状态(仅对已发布/已截止生效):
- 截止日期早于今天 → 已截止
- 截止日期为今天或以后 → 已发布(支持把过期作业改回未来日期恢复发布)
草稿状态不受截止日期影响;未填写截止日期则保持已发布。
"""
today = fields.Date.context_today(self)
for record in self:
if record.state in ('published', 'closed') and record.due_date:
target = 'closed' if record.due_date < today else 'published'
if record.state != target:
record.write({'state': target})
class SchoolAssignmentSubmission(models.Model):
"""作业提交——学员针对某次作业的作答与教师批改记录。"""
_name = 'yuthon.school.assignment.submission'
_description = '作业提交'
_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')
student_id = fields.Many2one(
'yuthon.school.student', string='学员', required=True, ondelete='cascade',
default=lambda self: self._default_student())
@api.model
def _default_student(self):
return self.env['yuthon.school.student'].search(
[('user_id', '=', self.env.uid)], limit=1)
content = fields.Html(string='作答内容', sanitize=True)
attachment = fields.Binary(string='提交文件', attachment=True)
attachment_filename = fields.Char(string='文件名')
assignment_attachment = fields.Binary(
string='老师布置���附件', related='assignment_id.attachment',
readonly=True)
assignment_attachment_filename = fields.Char(
related='assignment_id.attachment_filename',
readonly=True)
submit_time = fields.Datetime(string='提交时间')
score = fields.Float(string='得分')
feedback = fields.Text(string='评语')
due_date = fields.Date(
string='截止日期',
related='assignment_id.due_date',
readonly=True, store=True)
state = fields.Selection(
[('pending', '待提交'), ('submitted', '待批改'), ('graded', '已批改')],
string='状态', default='pending', index=True)
teacher_id = fields.Many2one(
'res.users', string='批改人',
related='assignment_id.teacher_id',
readonly=True, store=False)
active = fields.Boolean(string='启用', default=True)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name'):
assignment = self.env['yuthon.school.assignment'].browse(
vals.get('assignment_id'))
student = self.env['yuthon.school.student'].browse(
vals.get('student_id'))
a_name = assignment.name or '作业'
s_name = student.name or '学员'
vals['name'] = f'{a_name} - {s_name}的提交'
return super().create(vals_list)
@api.onchange('assignment_id', 'student_id')
def _onchange_submission_name(self):
if self.assignment_id and self.student_id and not self.name:
self.name = f'{self.assignment_id.name} - {self.student_id.name}的提交'
def action_submit(self):
"""学生点击提交:待提交 → 待批改,并记录提交时间。"""
for record in self:
if record.state != 'pending':
raise UserError(_('只有待提交状态的作业可以执行提交。'))
self.write({
'state': 'submitted',
'submit_time': fields.Datetime.now(),
})
def action_open_batch_wizard(self):
"""老师批量批改:打开批量打分向导。"""
if not self.env.user.has_group('college.group_teacher'):
raise UserError(_('只有老师可以进行批量批改。'))
return {
'name': _('批量批改'),
'type': 'ir.actions.act_window',
'res_model': 'yuthon.school.assignment.submission.batch.wizard',
'view_mode': 'form',
'target': 'new',
'context': self.env.context,
}
def action_download_attachments(self):
"""老师批量下载提交文件:把选中的提交记录的附件打包为 zip,
zip 内文件名格式为「学员姓名 + 原文件名」,并触发下载。"""
if not self.env.user.has_group('college.group_teacher'):
raise UserError(_('只有老师可以批量下载提交文件。'))
submissions = self.filtered(lambda s: s.attachment)
if not submissions:
raise UserError(_('选中的提交记录中没有可下载的附件。'))
# 收集每个 zip 内的文件名,防止重复(同一学员多个提交同名文件)
used_names = set()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for sub in submissions:
student_name = sub.student_id.name or '未命名学员'
original_filename = sub.attachment_filename or '附件'
zip_name = f'{student_name}_{original_filename}'
# 防止 zip 内部重名
if zip_name in used_names:
counter = 2
while f'{student_name}_{counter}_{original_filename}' in used_names:
counter += 1
zip_name = f'{student_name}_{counter}_{original_filename}'
used_names.add(zip_name)
zf.writestr(zip_name, base64.b64decode(sub.attachment))
zip_buffer.seek(0)
zip_b64 = base64.b64encode(zip_buffer.read()).decode('ascii')
# 临时附件存下载 zip
attachment = self.env['ir.attachment'].sudo().create({
'name': '提交文件打包.zip',
'datas': zip_b64,
'res_model': 'yuthon.school.assignment.submission',
'res_id': self[0].id if self else 0,
'type': 'binary',
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=true',
'target': 'self',
}
def write(self, vals):
"""老师填写或修改分数时,自动标记为已批改。"""
if 'score' in vals and 'state' not in vals:
vals = dict(vals)
# 仅对待批改状态的记录,填分即自动变已批改
if any(r.state == 'submitted' for r in self):
vals['state'] = 'graded'
return super().write(vals)
@api.onchange('score')
def _onchange_score(self):
"""表单中填写分数后,未保存前状态栏即时显示已批改。"""
if self.state == 'submitted' and self.score:
self.state = 'graded'
+26
View File
@@ -0,0 +1,26 @@
from odoo import fields, models, api
class AssignmentSubmissionBatchWizard(models.TransientModel):
"""批量批改向导——老师选中多条提交后统一填写分数与评语。"""
_name = 'yuthon.school.assignment.submission.batch.wizard'
_description = '批量批改向导'
score = fields.Float(string='得分', required=True)
feedback = fields.Text(string='评语')
def action_apply(self):
active_ids = self.env.context.get('active_ids', [])
if not active_ids:
return {'type': 'ir.actions.act_window_close'}
submissions = self.env['yuthon.school.assignment.submission'].browse(active_ids)
# 批量批改只处理已提交(待批改)的记录
submissions = submissions.filtered(lambda s: s.state == 'submitted')
if submissions:
submissions.write({
'score': self.score,
'feedback': self.feedback,
'state': 'graded',
})
return {'type': 'ir.actions.act_window_close'}
+460
View File
@@ -0,0 +1,460 @@
import re
from odoo import api, fields, models
class CourseChapterAttachment(models.Model):
"""课程章节附件(每个章节可挂多个文件:PDF / PPT / 资料等)。"""
_name = 'yuthon.school.course.chapter.attachment'
_description = '课程章节附件'
_order = 'sequence, id'
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节', ondelete='cascade')
file = fields.Binary(string='文件', attachment=True)
name = fields.Char(string='文件名')
download_url = fields.Char(string='下载链接', compute='_compute_download_url')
download_link_html = fields.Html(
string='下载', compute='_compute_download_link_html', sanitize=False)
sequence = fields.Integer(string='排序', default=10)
@api.depends('file')
def _compute_download_url(self):
"""生成附件下载直链。"""
Attachment = self.env['ir.attachment'].sudo()
for rec in self:
if rec.file:
attachment = Attachment.search([
('res_model', '=', 'yuthon.school.course.chapter.attachment'),
('res_field', '=', 'file'),
('res_id', '=', rec.id)
], limit=1, order='id desc')
rec.download_url = (
f'/web/content/{attachment.id}?download=1'
if attachment else '')
else:
rec.download_url = ''
@api.depends('download_url', 'file')
def _compute_download_link_html(self):
for rec in self:
if rec.download_url:
rec.download_link_html = (
f'<a href="{rec.download_url}" target="_blank" '
f'class="btn btn-sm btn-primary">⬇ 下载</a>'
)
else:
rec.download_link_html = ''
@api.model_create_multi
def create(self, vals_list):
return super().create(vals_list)
def write(self, vals):
return super().write(vals)
class CourseChapterExercise(models.Model):
"""章节训练习题——老师按章节布置的随堂简答题。
一个章节可挂多道;学生可以自由作答(不计分、不强制),
学员的回答存于 `yuthon.school.course.chapter.exercise.answer`。
"""
_name = 'yuthon.school.course.chapter.exercise'
_description = '章节训练习题'
_order = 'chapter_id, sequence, id'
_rec_name = 'exercise_label'
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(string='排序', default=10)
question = fields.Html(string='题目', sanitize=True, required=True)
analysis = fields.Html(
string='参考解析', sanitize=True,
help='给学员的参考答案/思路(仅展示,不参与评分)')
exercise_label = fields.Char(
string='显示名', compute='_compute_exercise_label',
help='内嵌列表与表单标题用:第 N 题')
answer_ids = fields.One2many(
'yuthon.school.course.chapter.exercise.answer', 'exercise_id',
string='学员作答')
answer_count = fields.Integer(
string='作答人数', compute='_compute_answer_count', store=True)
course_id = fields.Many2one(
'yuthon.school.course', string='课程',
related='chapter_id.course_id', store=True, index=True)
# 当前学员的回答(depends_context('uid') 跟随当前登录用户切换)
my_answer_content = fields.Html(
string='我的回答(全文)',
compute='_compute_my_answer', depends_context='uid')
my_answer_preview = fields.Char(
string='我的回答',
compute='_compute_my_answer_preview', depends_context='uid',
help='去除 Html 标签后的纯文本预览,便于在列表里紧凑显示')
@api.depends('sequence')
def _compute_exercise_label(self):
for rec in self:
rec.exercise_label = f'第 {rec.sequence} 题'
@api.depends('answer_ids')
def _compute_answer_count(self):
for rec in self:
rec.answer_count = len(rec.answer_ids)
@api.depends_context('uid')
def _compute_my_answer(self):
"""当前登录学员对本习题的回答(未作答则为 False)。"""
Answer = self.env['yuthon.school.course.chapter.exercise.answer']
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
answers = {}
if student:
for a in Answer.search([
('student_id', '=', student.id),
('exercise_id', 'in', self.ids),
]):
answers[a.exercise_id.id] = a
for rec in self:
ans = answers.get(rec.id)
rec.my_answer_content = ans.content if ans else False
@api.depends_context('uid')
def _compute_my_answer_preview(self):
"""去除 Html 标签后的纯文本预览(80 字以内)。"""
for rec in self:
content = rec.my_answer_content or ''
text = re.sub(r'<[^>]+>', ' ', content)
text = re.sub(r'\s+', ' ', text).strip()
if not text:
rec.my_answer_preview = ''
elif len(text) > 80:
rec.my_answer_preview = text[:80] + '…'
else:
rec.my_answer_preview = text
def action_answer(self):
"""学生端:弹窗回答本题。
若当前用户已对本题作答过,则打开已有作答;否则新建一条空作答。
"""
self.ensure_one()
Answer = self.env['yuthon.school.course.chapter.exercise.answer']
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
if not student:
return {'type': 'ir.actions.act_window_close'}
answer = Answer.search([
('exercise_id', '=', self.id),
('student_id', '=', student.id),
], limit=1)
if not answer:
answer = Answer.create({
'exercise_id': self.id,
'student_id': student.id,
})
return {
'type': 'ir.actions.act_window',
'name': '作答',
'res_model': 'yuthon.school.course.chapter.exercise.answer',
'res_id': answer.id,
'view_mode': 'form',
'view_id': self.env.ref(
'college.yuthon_school_course_chapter_exercise_answer_form').id,
'views': [(self.env.ref(
'college.yuthon_school_course_chapter_exercise_answer_form').id,
'form')],
'target': 'new',
}
def action_view_answers(self):
"""老师端:弹窗查看本题所有学员作答。
弹出只读列表(学员 / 回答 / 作答时间),不可编辑(不参与评分)。
"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'学员作答 · {self.exercise_label}',
'res_model': 'yuthon.school.course.chapter.exercise.answer',
'view_mode': 'list,form',
'domain': [('exercise_id', '=', self.id)],
'views': [
(self.env.ref(
'college.yuthon_school_course_chapter_exercise_answer_view_tree_teacher').id,
'list'),
(False, 'form'),
],
'target': 'new',
'context': {'default_exercise_id': self.id},
}
class CourseChapterExerciseAnswer(models.Model):
"""学员对章节训练习题的回答(不计分、自由提交、可修改)。"""
_name = 'yuthon.school.course.chapter.exercise.answer'
_description = '习题作答'
_order = 'exercise_id, student_id, id'
_rec_name = 'exercise_id'
exercise_id = fields.Many2one(
'yuthon.school.course.chapter.exercise', string='习题',
required=True, ondelete='cascade', index=True)
student_id = fields.Many2one(
'yuthon.school.student', string='学员',
required=True, ondelete='cascade', index=True)
user_id = fields.Many2one(
'res.users', string='账号',
related='student_id.user_id', store=True, index=True)
content = fields.Html(
string='我的回答', sanitize=True,
help='学员的作答内容,可随时修改(不计分)')
submit_time = fields.Datetime(
string='最后作答时间', default=fields.Datetime.now)
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节',
related='exercise_id.chapter_id', store=True, index=True)
course_id = fields.Many2one(
'yuthon.school.course', string='课程',
related='exercise_id.course_id', store=True, index=True)
question_display = fields.Html(
string='题目', related='exercise_id.question', readonly=True)
@api.model_create_multi
def create(self, vals_list):
vals_list = [dict(v, submit_time=fields.Datetime.now())
for v in vals_list]
return super().create(vals_list)
def write(self, vals):
if 'content' in vals:
vals['submit_time'] = fields.Datetime.now()
return super().write(vals)
class CourseChapterTask(models.Model):
"""章节任务清单——老师按章节维护的学习任务,学生端可见但不可编辑。"""
_name = 'yuthon.school.course.chapter.task'
_description = '章节任务'
_order = 'chapter_id, sequence, id'
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(string='排序', default=10)
name = fields.Char(string='任务名称', required=True)
is_done = fields.Boolean(string='已完成', default=False)
class CourseChapter(models.Model):
"""课程章节——老师端在课程内容里维护,学生端作为学习目录。
章节与课程同源:老师填写的视频、附件直接驱动学生端「课程学习」页面,
天然联动,无需额外同步。
"""
_name = 'yuthon.school.course.chapter'
_description = '课程章节'
_order = 'course_id, sequence, id'
course_id = fields.Many2one(
'yuthon.school.course', string='课程', required=True, ondelete='cascade')
name = fields.Char(string='章节标题', required=True, index=True)
sequence = fields.Integer(string='排序', default=10)
description = fields.Html(string='章节说明', sanitize=True)
class_hours = fields.Integer(string='课时', default=1, help='本章所占课时数')
# 视频与附件
video_file = fields.Binary(string='视频文件', attachment=True)
video_filename = fields.Char(string='视频文件名')
video_filename_display = fields.Char(
string='视频文件名(显示)', compute='_compute_video_filename_display',
store=True)
video_embed_html = fields.Html(
string='视频播放', compute='_compute_video_embed_html', sanitize=False)
attachment_ids = fields.One2many(
'yuthon.school.course.chapter.attachment', 'chapter_id', string='附件')
# 训练习题:一个章节可挂多道简答题(学生可自由作答,不计分)
exercise_ids = fields.One2many(
'yuthon.school.course.chapter.exercise', 'chapter_id',
string='训练习题')
exercise_count = fields.Integer(
string='习题数', compute='_compute_exercise_count', store=True)
@api.depends('exercise_ids')
def _compute_exercise_count(self):
for chapter in self:
chapter.exercise_count = len(chapter.exercise_ids)
# 任务列表:老师按章节维护的任务清单
task_ids = fields.One2many(
'yuthon.school.course.chapter.task', 'chapter_id',
string='任务列表')
# 随堂笔记:老师按章节记录的富文本笔记(与章节编辑表单共用同一字段)
notes = fields.Html(string='随堂笔记', sanitize=True)
@api.depends('video_file', 'video_filename')
def _compute_video_filename_display(self):
"""显示用:从 ir.attachment 读取视频真实文件名并持久化。"""
for chapter in self:
chapter.video_filename_display = chapter.video_filename or ''
@api.depends('video_file')
def _compute_video_embed_html(self):
"""学生端:生成可直接播放的视频 HTML。"""
Attachment = self.env['ir.attachment'].sudo()
for chapter in self:
if not chapter.video_file:
chapter.video_embed_html = False
continue
attachment = Attachment.search([
('res_model', '=', 'yuthon.school.course.chapter'),
('res_field', '=', 'video_file'),
('res_id', '=', chapter.id)
], limit=1, order='id desc')
if attachment:
chapter.video_embed_html = (
f'<video controls style="width:100%;" '
f'preload="metadata">'
f'<source src="/web/content/{attachment.id}" '
f'type="video/mp4">'
f'</video>'
)
else:
chapter.video_embed_html = False
# 已完成学员(标记学习完成的学生)
completed_student_ids = fields.Many2many(
'yuthon.school.student', string='已完成学员')
# 当前用户是否已完成(仅用于列表/表单展示,不入库)
is_done_for_me = fields.Boolean(
string='我已学完', compute='_compute_is_done_for_me', store=False)
@api.depends_context('uid')
def _compute_is_done_for_me(self):
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
for r in self:
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')
return {
'type': 'ir.actions.act_window',
'name': '学习章节',
'res_model': 'yuthon.school.course.chapter',
'res_id': self.id,
'view_mode': 'form',
'views': [(study_form.id, 'form')],
'target': 'current',
}
def action_open_edit(self):
"""老师端:弹窗打开当前章节的编辑页。"""
self.ensure_one()
edit_form = self.env.ref(
'college.yuthon_school_course_chapter_form_view')
return {
'type': 'ir.actions.act_window',
'name': '编辑章节',
'res_model': 'yuthon.school.course.chapter',
'res_id': self.id,
'view_mode': 'form',
'view_id': edit_form.id,
'views': [(edit_form.id, 'form')],
'target': 'new',
}
def action_toggle_complete(self):
"""学生端:标记 / 取消标记本章节为已完成。"""
self.ensure_one()
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
if not student:
return {'type': 'ir.actions.act_window_close'}
if student in self.completed_student_ids:
self.sudo().write({'completed_student_ids': [(3, student.id)]})
else:
self.sudo().write({'completed_student_ids': [(4, student.id)]})
# 同步更新学习进展的最近学习日期与状态
progress = self.env['yuthon.school.progress'].sudo().search([
('course_id', '=', self.course_id.id),
('student_id', '=', student.id),
], limit=1)
if progress:
progress.sudo().write({'last_study_date': fields.Date.context_today(self)})
progress.sudo().recompute_status()
# 重新打开本章节表单,刷新已完成状态
return {
'type': 'ir.actions.act_window',
'res_model': 'yuthon.school.course.chapter',
'res_id': self.id,
'view_mode': 'form',
'target': 'current',
}
@api.model_create_multi
def create(self, vals_list):
chapters = super().create(vals_list)
chapters._sync_video_filename()
chapters._refresh_course_progress()
return chapters
def write(self, vals):
res = super().write(vals)
if 'video_file' in vals:
self._sync_video_filename()
return res
def _sync_video_filename(self):
"""保持 video_filename 与 ir.attachment.name 一致:
- 视频被清空:清空 video_filename
- 已存在 attachment 且 chapter.video_filename 与之不一致:把附件名同步为 chapter.video_filename
"""
Attachment = self.env['ir.attachment'].sudo()
for chapter in self:
if not chapter.video_file:
# 视频被清空,连带清空文件名
if chapter.video_filename:
chapter.video_filename = ''
continue
attachment = Attachment.search([
('res_model', '=', 'yuthon.school.course.chapter'),
('res_field', '=', 'video_file'),
('res_id', '=', chapter.id)
], limit=1, order='id desc')
if attachment and chapter.video_filename \
and attachment.name != chapter.video_filename:
attachment.sudo().write({'name': chapter.video_filename})
def unlink(self):
courses = self.mapped('course_id')
res = super().unlink()
Progress = self.env['yuthon.school.progress'].sudo()
for course in courses:
Progress.search([('course_id', '=', course.id)]).recompute_status()
return res
def _refresh_course_progress(self):
"""章节创建后,刷新所属课程所有学员的进展状态。"""
Progress = self.env['yuthon.school.progress'].sudo()
for course in self.mapped('course_id'):
Progress.search([('course_id', '=', course.id)]).recompute_status()
+186
View File
@@ -0,0 +1,186 @@
from odoo import api, fields, models
_COLLEGE_SELECTION = [
('computer', '计算机学院'),
('software', '软件学院'),
('business', '商学院'),
('foreign', '外国语学院'),
('mechanic', '机械工程学院'),
('electric', '电子信息学院'),
('math', '数学学院'),
('other', '其他'),
]
_SEMESTER_SELECTION = [
('2025_1', '2025-2026学年第一学期'),
('2025_2', '2025-2026学年第二学期'),
('2026_1', '2026-2027学年第一学期'),
('2026_2', '2026-2027学年第二学期'),
]
class SchoolCourse(models.Model):
"""课程——每位老师独立管理自己的课程。
学院 / 学期 作为课程上的字段(非独立模型)。
课程模块支持 理论 / 实践。
"""
_name = 'yuthon.school.course'
_description = '课程'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'semester, college, name'
name = fields.Char(string='课程名称', required=True, index=True)
code = fields.Char(string='课程编号', index=True)
teacher_id = fields.Many2one(
'res.users',
string='任课教师',
default=lambda self: self.env.user,
required=True,
index=True,
help='课程归属教师,默认带出当前登录用户;每位老师独立管理自己的课程。',
)
# 学院 / 学期:课程上的字段,不是独立模型
college = fields.Selection(_COLLEGE_SELECTION, string='学院', index=True)
semester = fields.Selection(_SEMESTER_SELECTION, string='学期', index=True)
# 课程模块(参考站:理论 / 实践)
course_type = fields.Selection(
[('theory', '理论'), ('practice', '实践')],
string='课程模块',
default='theory',
required=True,
index=True,
)
hours = fields.Integer(string='课时', required=True, default=32)
start_date = fields.Date(string='开始日期')
end_date = fields.Date(string='结束日期')
is_public = fields.Boolean(
string='公开',
default=False,
help='开启后可在高校优质课堂等公开渠道展示推广。',
)
# 视频 / 课件(PPT 等)
video_file = fields.Binary(string='视频文件', attachment=True)
video_filename = fields.Char(string='视频文件名')
ppt_file = fields.Binary(string='PPT / 课件', attachment=True)
ppt_filename = fields.Char(string='课件文件名')
# 课程详情
introduction = fields.Html(string='课程介绍', sanitize=True)
objectives = fields.Html(string='课程目标', sanitize=True)
features = fields.Html(string='课程特色', sanitize=True)
syllabus = fields.Html(string='课程大纲', sanitize=True)
business_scenario = fields.Html(string='业务情景', sanitize=True)
# 学员分配
student_ids = fields.Many2many(
'yuthon.school.student',
string='选课学员',
help='老师为课程分配的学员(选课名单)。',
)
student_count = fields.Integer(
string='学员人数',
compute='_compute_student_count',
store=True,
)
# 看板状态
state = fields.Selection(
[('draft', '草稿'), ('published', '已发布'), ('archived', '已归档')],
string='状态',
default='draft',
required=True,
index=True,
)
active = fields.Boolean(string='启用', default=True)
# 课程章节(老师端维护,学生端作为学习目录,同源联动)
chapter_ids = fields.One2many(
'yuthon.school.course.chapter', 'course_id', string='课程章节')
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:
record.student_count = len(record.student_ids)
@api.depends('chapter_ids')
def _compute_chapter_count(self):
for record in self:
record.chapter_count = len(record.chapter_ids)
@api.model_create_multi
def create(self, vals_list):
courses = super().create(vals_list)
for course in courses:
course._sync_progress_records()
return courses
def write(self, vals):
if 'student_ids' in vals:
# 先记录旧学员, super 写入后再按差异同步学习进展
old_students_map = {course.id: course.student_ids for course in self}
res = super().write(vals)
for course in self:
old_students = old_students_map.get(course.id, self.env['yuthon.school.student'])
course._sync_progress_records(old_students)
return res
return super().write(vals)
def _sync_progress_records(self, old_students=None):
"""根据课程选课学员变化,自动增删学习进展记录。"""
self.ensure_one()
Progress = self.env['yuthon.school.progress'].sudo()
old_students = old_students or self.env['yuthon.school.student']
new_students = self.student_ids
added = new_students - old_students
removed = old_students - new_students
if added:
existing = Progress.search([
('course_id', '=', self.id),
('student_id', 'in', added.ids),
]).mapped('student_id')
to_create = added - existing
Progress.create([{
'course_id': self.id,
'student_id': student.id,
} for student in to_create])
if removed:
Progress.search([
('course_id', '=', self.id),
('student_id', 'in', removed.ids),
]).unlink()
# 状态流转
def action_publish(self):
self.write({'state': 'published'})
def action_archive(self):
self.write({'state': 'archived'})
def action_to_draft(self):
self.write({'state': 'draft'})
+301
View File
@@ -0,0 +1,301 @@
"""课程批量导入向导 —— CSV 模板下载 + 批量创建课程(可选连带章节与章节任务)。
模板列(前 8 列为课程本体,后 5 列为可选章节/任务):
课程名称、课程编号、所属学院、学期、课程类型、课时、课程大纲、业务情景、
章节标题、章节说明、章节课时、任务名称、是否完成。
- 仅填前 8 列:行为与旧版一致,只创建课程;
- 填了「章节标题」:自动按 (课程, 章节标题) 找/建章节;
- 填了「任务名称」:在对应章节下创建任务列表明细行(is_done 由「是否完成」决定)。
同一课程 / 同一章节多行时自动复用,不重复创建。
"""
import base64
import csv
import io
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
COURSE_TYPE_MAP = {
'理论': 'theory',
'实践': 'practice',
'theory': 'theory',
'practice': 'practice',
}
COLLEGE_NAME_MAP = {
'计算机学院': 'computer',
'软件学院': 'software',
'商学院': 'business',
'外国语学院': 'foreign',
'机械工程学院': 'mechanic',
'电子信息学院': 'electric',
'数学学院': 'math',
'其他': 'other',
'computer': 'computer',
'software': 'software',
'business': 'business',
'foreign': 'foreign',
'mechanic': 'mechanic',
'electric': 'electric',
'math': 'math',
'other': 'other',
}
class CourseImportWizard(models.TransientModel):
"""课程批量导入向导。"""
_name = 'yuthon.school.course.import.wizard'
_description = '课程批量导入'
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>点击下方「下载模板」按钮下载 CSV 模板;</li>'
'<li>用 Excel / WPS 打开模板,按示例填写数据:<br/>'
'<b>课程名称</b>、<b>课程编号</b> 为必填;<br/>'
'<b>所属学院、学期、课程类型、课时、课程介绍、业务情景</b> 为选填;</li>'
'<li>课程类型可选:理论 / 实践</li>'
'<li><b>章节 / 任务(可选)</b>:填「章节标题」会自动建/复用章节,'
'填「任务名称」会在该章节下建一条任务列表明细(是否完成填 是/否);'
'同一课程、同一章节多行会自动合并,不会重复创建;</li>'
'<li>保存为 CSV 后上传并点击「开始导入」;</li>'
'<li>若课程名称已存在则复用,不会重复创建课程。</li>'
'</ol>'
),
)
# ─────────────────── 下载模板 ───────────────────
def action_download_template(self):
"""生成 CSV 模板并提供下载。"""
header = ['课程名称', '课程编号', '所属学院', '学期',
'课程类型(理论/实践)', '课时', '课程介绍', '业务情景',
'章节标题', '章节说明', '章节课时',
'任务名称', '是否完成']
sample = ['Python 编程基础', 'PY101', '计算机学院',
'2025-2026第1学期', '理论', '48',
'本课程面向零基础学员,以项目驱动方式讲解核心知识',
'通过电商订单管理场景贯穿课程',
'第1章 环境搭建', '本章介绍开发环境', '4',
'安装Python', '否']
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
writer.writerow(sample)
attachment = self.env['ir.attachment'].sudo().create({
'name': '课程导入模板.csv',
'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': f'/web/content/{attachment.id}?download=true',
'target': 'self',
}
# ─────────────────── 导入 ───────────────────
def action_import(self):
"""解析上传的 CSV 并批量创建课程。"""
self.ensure_one()
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(_('CSV 文件为空或格式不正确。'))
col_map = self._normalize_columns(reader.fieldnames)
created = 0
reused_course = 0
created_chapter = 0
reused_chapter = 0
created_task = 0
skipped = 0
errors = []
for row_idx, raw_row in enumerate(reader, start=2):
row = {col_map.get(k, k): v.strip() if v else '' for k, v in raw_row.items()}
name = row.get('name', '').strip()
code = row.get('code', '').strip()
if not name:
skipped += 1
errors.append(_('第 %d 行:课程名称为必填,跳过。') % row_idx)
continue
try:
# 课程:按名称优先复用,其次按编号;都不存在才新建
course = self.env['yuthon.school.course'].sudo().search(
[('name', '=', name)], limit=1)
if not course and code:
course = self.env['yuthon.school.course'].sudo().search(
[('code', '=', code)], limit=1)
if not course:
vals = {
'name': name,
'code': code,
'state': 'draft',
}
college_raw = row.get('college', '')
if college_raw:
vals['college'] = COLLEGE_NAME_MAP.get(college_raw, 'other')
semester = row.get('semester', '')
if semester:
vals['semester'] = semester
course_type_raw = row.get('course_type', '')
if course_type_raw:
vals['course_type'] = COURSE_TYPE_MAP.get(
course_type_raw, 'theory')
hours_raw = row.get('hours', '')
if hours_raw:
try:
vals['hours'] = int(float(hours_raw))
except ValueError:
pass
introduction = row.get('introduction', '')
if introduction:
vals['introduction'] = introduction
business_scenario = row.get('business_scenario', '')
if business_scenario:
vals['business_scenario'] = business_scenario
course = self.env['yuthon.school.course'].sudo().create(vals)
created += 1
else:
reused_course += 1
# 章节:填了章节标题才处理(按 课程+标题 复用)
chapter_title = row.get('chapter', '').strip()
chapter = None
if chapter_title:
chapter = self.env['yuthon.school.course.chapter'].sudo().search([
('course_id', '=', course.id),
('name', '=', chapter_title),
], limit=1)
if not chapter:
chapter_vals = {'course_id': course.id, 'name': chapter_title}
chapter_desc = row.get('chapter_desc', '').strip()
if chapter_desc:
chapter_vals['description'] = chapter_desc
chapter_hours_raw = row.get('chapter_hours', '').strip()
if chapter_hours_raw:
try:
chapter_vals['class_hours'] = int(float(chapter_hours_raw))
except ValueError:
pass
chapter = self.env['yuthon.school.course.chapter'].sudo().create(
chapter_vals)
created_chapter += 1
else:
reused_chapter += 1
# 任务列表:填了任务名称才在对应章节下创建明细行
task_name = row.get('task_name', '').strip()
if task_name and chapter:
is_done = row.get('is_done', '').strip() in (
'是', 'y', 'yes', 'true', '1', '完成')
task_vals = {
'chapter_id': chapter.id,
'name': task_name,
'is_done': is_done,
'sequence': 10,
}
self.env['yuthon.school.course.chapter.task'].sudo().create(
task_vals)
created_task += 1
except Exception as exc:
skipped += 1
errors.append(
_('第 %d 行「%s」导入失败:%s') % (row_idx, name, str(exc)))
summary = _(
'导入完成!\n'
'新建课程:%d(复用 %d)\n'
'新建章节:%d(复用 %d)\n'
'新建任务:%d\n'
'跳过:%d'
) % (created, reused_course, created_chapter, reused_chapter,
created_task, 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': 'info' if not errors else 'warning',
'sticky': True,
'next': {'type': 'ir.actions.act_window_close'},
},
}
def _normalize_columns(self, fieldnames):
"""列名 → 内部 key 映射。"""
mapping = {}
for f in fieldnames:
f_lower = f.strip().lower()
if f_lower in ('课程名称', 'name', '课程名'):
mapping[f] = 'name'
elif f_lower in ('课程编号', 'code', '课程号', '编号'):
mapping[f] = 'code'
elif f_lower in ('所属学院', 'college', '学院'):
mapping[f] = 'college'
elif f_lower in ('学期', 'semester'):
mapping[f] = 'semester'
elif f_lower in ('课程类型', '课程类型(理论/实践)', 'course_type', '类型'):
mapping[f] = 'course_type'
elif f_lower in ('课时', 'hours'):
mapping[f] = 'hours'
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'):
mapping[f] = 'chapter'
elif f_lower in ('章节说明', 'chapter_desc', 'chapter_description'):
mapping[f] = 'chapter_desc'
elif f_lower in ('章节课时', 'chapter_hours', 'chapter_class_hours'):
mapping[f] = 'chapter_hours'
elif f_lower in ('任务名称', 'task_name', '任务', 'task'):
mapping[f] = 'task_name'
elif f_lower in ('是否完成', 'is_done', '完成', 'done'):
mapping[f] = 'is_done'
else:
mapping[f] = f # 保留原名
return mapping
+744
View File
@@ -0,0 +1,744 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from datetime import datetime, timedelta
_QUESTION_TYPES = [
('single_choice', '单选题'),
('multiple_choice', '多选题'),
('true_false', '判断题'),
('fill_blank', '填空题'),
('essay', '简答题'),
]
_DIFFICULTY_LEVELS = [
('easy', '简单'),
('medium', '中等'),
('hard', '困难'),
]
class ExamQuestion(models.Model):
"""题库——老师出题,支持单选/多选/判断/填空/简答。
每道题可挂正确答案与答案说明(解析),并关联到课程/章节,
后续组卷时按课程或章节快速筛选。
"""
_name = 'yuthon.school.exam.question'
_description = '题库'
_order = 'course_id, chapter_id, sequence, id'
_rec_name = 'question_text_short'
sequence = fields.Integer(string='排序', default=10)
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='题型', default='single_choice', index=True)
difficulty = fields.Selection(
_DIFFICULTY_LEVELS, string='难度', default='medium')
course_id = fields.Many2one(
'yuthon.school.course', string='所属课程', ondelete='restrict', index=True)
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='所属章节', ondelete='restrict', index=True,
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='简答题的参考答案,供老师评阅与学生查看解析时参考。')
explanation = fields.Html(
string='答案说明', sanitize=True,
help='答案解析:学生交卷后可查看本题解析。')
score = fields.Float(string='默认分值', default=1.0,)
active = fields.Boolean(string='启用', default=True)
@api.depends('question_text')
def _compute_question_text_short(self):
import re
for q in self:
text = re.sub(r'<[^>]+>', ' ', q.question_text or '')
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):
"""返回本题的正确选项记录(仅选择题有效)。"""
self.ensure_one()
return self.option_ids.filtered(lambda o: o.is_correct)
class ExamQuestionOption(models.Model):
"""题目选项——用于单选题/多选题。"""
_name = 'yuthon.school.exam.question.option'
_description = '题目选项'
_order = 'sequence, id'
_rec_name = 'name'
question_id = fields.Many2one(
'yuthon.school.exam.question', string='题目', required=True, ondelete='cascade')
sequence = fields.Integer(string='排序', default=10)
label = fields.Char(string='选项标签', default='A', required=True)
content = fields.Html(string='选项内容', sanitize=True, required=True)
name = fields.Char(string='选项', compute='_compute_name', store=True)
is_correct = fields.Boolean(string='正确答案', default=False)
@api.depends('label', 'content')
def _compute_name(self):
for opt in self:
text = opt._strip_html(opt.content or '')
opt.name = f"{opt.label}. {text[:60]}"
def _strip_html(self, html):
import re
text = re.sub(r'<[^>]+>', ' ', html)
return re.sub(r'\s+', ' ', text).strip()
class Exam(models.Model):
"""考试发布——老师从题库组卷并发布给课程学员。"""
_name = 'yuthon.school.exam'
_description = '考试'
_order = 'start_time desc, id desc'
_inherit = ['mail.thread', 'mail.activity.mixin']
name = fields.Char(string='考试名称', required=True, index=True)
course_id = fields.Many2one(
'yuthon.school.course', string='所属课程', required=True, ondelete='cascade', index=True,
domain="[('state', '=', 'published'), ('teacher_id', '=', uid)]")
chapter_ids = fields.Many2many(
'yuthon.school.course.chapter', 'yuthon_exam_chapter_rel',
string='关联章节',
domain="[('course_id', '=', course_id)]",
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='开始时间')
end_time = fields.Datetime(string='结束时间')
duration_minutes = fields.Integer(
string='考试时长(分钟)', default=60,
help='0 表示不限制时长。')
pass_score = fields.Float(string='及格分', default=60.0)
state = fields.Selection(
[('draft', '草稿'), ('published', '已发布'), ('closed', '已截止')],
string='状态', default='draft', index=True)
active = fields.Boolean(string='启用', default=True)
line_ids = fields.One2many(
'yuthon.school.exam.line', 'exam_id', string='试卷题目')
total_score = fields.Float(
string='总分', compute='_compute_total_score', store=True)
paper_ids = fields.One2many(
'yuthon.school.exam.paper', 'exam_id', string='答卷')
paper_count = fields.Integer(
string='答卷数', compute='_compute_paper_count', store=True)
submitted_count = fields.Integer(
string='已交卷数', compute='_compute_paper_count', store=True)
graded_count = fields.Integer(
string='已评阅数', compute='_compute_paper_count', store=True)
@api.depends('line_ids.score')
def _compute_total_score(self):
for exam in self:
exam.total_score = sum(exam.line_ids.mapped('score'))
@api.depends('paper_ids.state')
def _compute_paper_count(self):
for exam in self:
papers = exam.paper_ids
exam.paper_count = len(papers)
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:
if exam.state != 'draft':
raise UserError(_('考试「%s」当前状态为 %s,请勿重复发布。') % (exam.name or '', exam.state))
if not exam.line_ids:
raise UserError(_('考试「%s」尚未添加题目,不能发布。') % (exam.name or ''))
self.write({'state': 'published'})
self._create_papers()
return True
def action_close(self):
"""手动截止考试。"""
return self.write({'state': 'closed'})
def action_reopen(self):
"""重新开放:已截止 → 已发布(已有答卷保留)。"""
return self.write({'state': 'published'})
def action_draft(self):
"""撤回草稿:已发布但未产生答卷时可撤回;有答卷则禁止。"""
for exam in self:
if exam.paper_ids.filtered(lambda p: p.state != 'pending'):
raise UserError(_('考试已有学员开始作答或提交,不能撤回草稿。'))
self.mapped('paper_ids').unlink()
return self.write({'state': 'draft'})
def _create_papers(self):
"""为课程的每个选课学员创建一张答卷(去重)。"""
Paper = self.env['yuthon.school.exam.paper'].sudo()
for exam in self:
course = exam.course_id
if not course or not course.student_ids:
continue
existing_students = set(Paper.search([
('exam_id', '=', exam.id),
]).mapped('student_id').ids)
for student in course.student_ids:
if student.id in existing_students:
continue
Paper.create({
'exam_id': exam.id,
'student_id': student.id,
'state': 'pending',
})
@api.model_create_multi
def create(self, vals_list):
exams = super().create(vals_list)
exams._check_time_window()
return exams
def write(self, vals):
res = super().write(vals)
if any(k in vals for k in ('start_time', 'end_time', 'state')):
self._check_time_window()
return res
def _check_time_window(self):
"""根据时间窗口自动切换已发布考试的状态(仅对 published/closed 生效)。"""
now = fields.Datetime.now()
for exam in self:
if exam.state == 'published' and exam.end_time and exam.end_time < now:
exam.write({'state': 'closed'})
elif exam.state == 'closed' and exam.end_time and exam.end_time >= now:
exam.write({'state': 'published'})
class ExamLine(models.Model):
"""考试-题目关联(组卷明细)。"""
_name = 'yuthon.school.exam.line'
_description = '考试题目'
_order = 'sequence, id'
exam_id = fields.Many2one(
'yuthon.school.exam', string='考试', required=True, ondelete='cascade')
sequence = fields.Integer(string='排序', default=10)
question_id = fields.Many2one(
'yuthon.school.exam.question', string='题目', required=True, ondelete='restrict')
question_type = fields.Selection(
related='question_id.question_type', string='题型', store=True, readonly=True)
question_text = fields.Html(
related='question_id.question_text', string='题干', readonly=True)
score = fields.Float(string='本题分值', required=True, default=1.0)
@api.onchange('question_id')
def _onchange_question_id(self):
if self.question_id:
self.score = self.question_id.score
class ExamPaper(models.Model):
"""学生答卷——一次考试对应一名学员的一份答卷。"""
_name = 'yuthon.school.exam.paper'
_description = '答卷'
_order = 'submit_time desc, id desc'
_rec_name = 'name'
name = fields.Char(string='答卷标题', compute='_compute_name', store=True)
exam_id = fields.Many2one(
'yuthon.school.exam', string='考试', required=True, ondelete='cascade', index=True)
course_id = fields.Many2one(
'yuthon.school.course', string='所属课程', related='exam_id.course_id', store=True, readonly=True)
chapter_ids = fields.Many2many(
'yuthon.school.course.chapter', string='关联章节', related='exam_id.chapter_ids', readonly=True)
description = fields.Html(string='考试说明', related='exam_id.description', readonly=True)
student_id = fields.Many2one(
'yuthon.school.student', string='学员', required=True, ondelete='cascade', index=True)
user_id = fields.Many2one(
'res.users', string='账号', related='student_id.user_id', store=True)
state = fields.Selection(
[('pending', '待考试'), ('in_progress', '考试中'), ('submitted', '待评阅'), ('graded', '已评阅')],
string='状态', default='pending', index=True)
start_time = fields.Datetime(string='开始考试时间')
submit_time = fields.Datetime(string='交卷时间')
score = fields.Float(string='得分', compute='_compute_score', store=True)
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='答题记录')
@api.depends('exam_id', 'student_id')
def _compute_name(self):
for paper in self:
exam_name = paper.exam_id.name or '考试'
student_name = paper.student_id.name or '学员'
paper.name = f'{exam_name} - {student_name}'
@api.depends('answer_ids.score')
def _compute_score(self):
for paper in self:
total = sum(paper.answer_ids.mapped('score'))
paper.score = total
paper.is_passed = total >= (paper.pass_score or 0)
def action_start(self):
"""学生开始考试:生成答题记录(尚未答题)并记录开始时间。"""
Answer = self.env['yuthon.school.exam.answer'].sudo()
for paper in self:
if paper.state not in ('pending', 'in_progress'):
raise UserError(_('当前答卷状态为 %s,不能开始考试。') % paper.state)
# 检查时间窗口
exam = paper.exam_id
now = fields.Datetime.now()
if exam.start_time and now < exam.start_time:
raise UserError(_('考试尚未开始。'))
if exam.end_time and now > exam.end_time:
raise UserError(_('考试已结束。'))
if not paper.start_time:
paper.start_time = now
if paper.state == 'pending':
paper.write({'state': 'in_progress'})
# 为每道题目创建空答题记录
existing_qids = set(paper.answer_ids.mapped('question_id').ids)
for line in exam.line_ids:
if line.question_id.id in existing_qids:
continue
Answer.create({
'paper_id': paper.id,
'line_id': line.id,
'question_id': line.question_id.id,
})
return True
def action_submit(self):
"""学生交卷。"""
for paper in self:
if paper.state not in ('in_progress', 'pending'):
raise UserError(_('只有进行中的考试可以交卷。'))
self.write({
'state': 'submitted',
'submit_time': fields.Datetime.now() + timedelta(hours=8),
})
self._auto_grade()
# 纯客观题(无简答题)交卷即自动判分完毕,直接标记已评阅;
# 含简答题的卷子保留「待评阅」,等老师评阅后标记。
for paper in self:
if not any(a.question_type == 'essay' for a in paper.answer_ids):
paper.write({'state': 'graded'})
return True
def action_grade(self):
"""老师评阅:客观题自动判分,简答题未打分则按 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':
# 老师未给分时按 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()
return {
'type': 'ir.actions.act_url',
'name': '开始考试',
'url': '/exam/start/%s' % self.id,
'target': 'self',
}
def action_view_answers(self):
"""老师/学生打开答卷详情。"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': '答卷详情',
'res_model': 'yuthon.school.exam.paper',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref('college.yuthon_school_exam_paper_form').id,
'views': [(self.env.ref('college.yuthon_school_exam_paper_form').id, 'form')],
'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:
for answer in paper.answer_ids:
answer._auto_grade()
class ExamAnswer(models.Model):
"""学生对一道题的作答与评分结果。"""
_name = 'yuthon.school.exam.answer'
_description = '答题记录'
_order = 'line_id, id'
paper_id = fields.Many2one(
'yuthon.school.exam.paper', string='答卷', required=True, ondelete='cascade')
line_id = fields.Many2one(
'yuthon.school.exam.line', string='考试题目', required=True, ondelete='cascade')
question_id = fields.Many2one(
'yuthon.school.exam.question', string='题目', required=True, ondelete='cascade')
sequence = fields.Integer(string='排序', related='line_id.sequence', store=True)
question_type = fields.Selection(
related='question_id.question_type', string='题型', store=True, readonly=True)
question_text = fields.Html(
related='question_id.question_text', string='题干', readonly=True)
question_text_short = fields.Char(
related='question_id.question_text_short', string='题干摘要', store=True, readonly=True)
max_score = fields.Float(string='本题满分', related='line_id.score', readonly=True)
explanation = fields.Html(
string='答案说明', related='question_id.explanation', readonly=True)
reference_answer = fields.Html(
string='参考答案', related='question_id.reference_answer', readonly=True)
# 选择题作答:单选用 Many2one,多选用 Many2many
selected_option_id = fields.Many2one(
'yuthon.school.exam.question.option', string='所选选项(单选)',
domain="[('question_id', '=', question_id)]")
selected_option_ids = fields.Many2many(
'yuthon.school.exam.question.option', 'yuthon_exam_answer_option_rel',
string='所选选项(多选)',
domain="[('question_id', '=', question_id)]")
answer_choice = fields.Selection(
[('True', '正确'), ('False', '错误')],
string='判断作答')
answer_text = fields.Html(string='作答内容', sanitize=True)
is_correct = fields.Boolean(string='是否正确', compute='_compute_is_correct', store=True)
score = fields.Float(string='得分')
is_graded = fields.Boolean(string='是否已评阅本题', default=False, readonly=True,
help='客观题自动判分后置 True;简答题需老师评阅后置 True。'
'整张卷子仅当所有题目均 is_graded 才标记「已评阅」。')
teacher_comment = fields.Text(string='评语')
answer_display = fields.Char(
string='作答摘要', compute='_compute_answer_display', store=True)
@api.depends('selected_option_id', 'selected_option_ids', 'answer_choice', 'answer_text')
def _compute_answer_display(self):
for ans in self:
qtype = ans.question_type
if qtype == 'single_choice':
opt = ans.selected_option_id
ans.answer_display = f"{opt.label}. {opt.content}" if opt else ''
elif qtype == 'multiple_choice':
labels = sorted(ans.selected_option_ids.mapped('label'))
ans.answer_display = ', '.join(labels) if labels else ''
elif qtype == 'true_false':
ans.answer_display = dict(self._fields['answer_choice'].selection).get(ans.answer_choice, '')
else:
text = ans._strip_html(ans.answer_text or '')
ans.answer_display = (text[:60] + '…') if len(text) > 60 else text
@api.depends('selected_option_id', 'selected_option_ids', 'answer_choice', 'answer_text', 'question_id')
def _compute_is_correct(self):
for ans in self:
ans.is_correct = ans._check_correct()
@api.onchange('selected_option_id')
def _onchange_selected_option_id(self):
"""单选变更时同步到多选字段,方便统一显示/评分。"""
if self.selected_option_id:
self.selected_option_ids = [(6, 0, [self.selected_option_id.id])]
else:
self.selected_option_ids = [(5,)]
def _check_correct(self):
"""检查当前作答是否正确(仅客观题)。"""
self.ensure_one()
q = self.question_id
qtype = q.question_type
if qtype == 'single_choice':
correct = q._get_correct_options()
if not correct or not self.selected_option_id:
return False
return self.selected_option_id in correct
if qtype == 'multiple_choice':
correct = q._get_correct_options()
chosen = self.selected_option_ids
if not chosen:
return False
return set(chosen.ids) == set(correct.ids)
if qtype == 'true_false':
return (self.answer_choice or '').strip().lower() == (q.correct_answer or '').strip().lower()
if qtype == 'fill_blank':
return self._strip_html(self.answer_text or '').lower() == (q.correct_answer or '').strip().lower()
# 简答题默认不正确,需老师打分
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)
return re.sub(r'\s+', ' ', text).strip()
def _auto_grade(self):
"""客观题自动给分;简答题留给老师手动评分。"""
self.ensure_one()
if self.question_type == 'essay':
return
self.is_graded = True
if self._check_correct():
self.score = self.max_score
else:
self.score = 0.0
@api.onchange('score')
def _onchange_score(self):
"""老师打分后,若得分大于等于满分则标记为正确。"""
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()
return {
'type': 'ir.actions.act_window',
'name': '答题详情',
'res_model': 'yuthon.school.exam.answer',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref('college.yuthon_school_exam_answer_view_form').id,
'views': [(self.env.ref('college.yuthon_school_exam_answer_view_form').id, 'form')],
'target': 'new',
}
+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)
+354
View File
@@ -0,0 +1,354 @@
"""师生批量导入向导 —— Excel/CSV 模板下载 + 解析导入。
模板列:(必填)姓名、角色、手机号;(可选)班级、��院、性别。
导入时自动创建 res.users(登录账号 = 姓名拼音,密码 = 手机号)
并关联到 yuthon.school.teacher / yuthon.school.student。
"""
import base64
import csv
import io
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
try:
from pypinyin import lazy_pinyin, Style
HAS_PYPINYIN = True
except ImportError:
HAS_PYPINYIN = False
_logger.warning(
"pypinyin 未安装,导入时账号将使用姓名全拼小写。"
"请在服务器执行: pip install pypinyin"
)
def _to_login(name):
"""把中文姓名转成拼音登录账号。
- 有 pypinyin: 张三 → zhangsan
- 无 pypinyin: 张三 → 张三(保留中文,不做转拼音)
"""
if not name:
return ''
if HAS_PYPINYIN:
return ''.join(lazy_pinyin(name, style=Style.NORMAL)).lower().replace(' ', '')
# 回退:去空格 + 小写(纯英文名也能用)
return name.strip().lower().replace(' ', '')
# 角色可选值
ROLE_SELECTION = [
('teacher', '老师'),
('student', '学生'),
]
# 性别可选值
GENDER_MAP = {
'男': 'male',
'male': 'male',
'女': 'female',
'female': 'female',
}
# 学院中文→代码映射
COLLEGE_MAP = {
'计算机学院': 'computer',
'软件学院': 'software',
'商学院': 'business',
'外国语学院': 'foreign',
'机械工程学院': 'mechanic',
'电子信息学院': 'electric',
'数学学院': 'math',
'其他': 'other',
}
class ImportTeacherStudentWizard(models.TransientModel):
"""师生批量导入向导。"""
_name = 'yuthon.school.import.wizard'
_description = '师生批量导入'
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>点击下方「下载模板」按钮下载 CSV 模板;</li>'
'<li>用 Excel / WPS 打开模板,按示例填写数据:<br/>'
'<b>姓名</b>、<b>角色</b>(老师/学生)、<b>手机号</b> 为必填;<br/>'
'<b>班级、学院、性别</b> 为选填;</li>'
'<li>保存为 CSV(逗号分隔)后在此上传并点击「开始导入」;</li>'
'<li>系统将自动创建登录账号(账号=姓名拼音,<b>学生密码=学号、老师密码=工号</b>)并关联师生档案;</li>'
'<li>若账号已存在则跳过,不会重复创建。</li>'
'</ol>'
),
)
# ─────────────────── 下载模板 ───────────────────
def action_download_template(self):
"""生成 CSV 模板并提供下载。"""
header = ['姓名', '角色(老师/学生)', '手机号', '学号', '工号', '班级', '学院', '性别(男/女)']
sample = [
['张三', '学生', '13800138001', '2024001', '', '计算机2024级1班', '计算机学院', '男'],
['李老师', '老师', '13900139000', '', 'T1001', '', '', ''],
]
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
writer.writerows(sample)
attachment = self.env['ir.attachment'].sudo().create({
'name': '师生导入模板.csv',
'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': f'/web/content/{attachment.id}?download=true',
'target': 'self',
}
# ─────────────────── 导入 ───────────────────
def action_import(self):
"""解析上传的 CSV 并批量创建师生。"""
self.ensure_one()
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(_('CSV 文件为空或格式不正确。'))
# 规范化列名(兼容中英文列头)
col_map = self._normalize_columns(reader.fieldnames)
created_users = 0
created_teachers = 0
created_students = 0
skipped = 0
errors = []
for row_idx, raw_row in enumerate(reader, start=2): # 第1行是表头
row = {col_map.get(k, k): v.strip() if v else '' for k, v in raw_row.items()}
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()
# 校验必填
if not name or not role or not phone:
skipped += 1
errors.append(_('第 %d 行:姓名/角色/手机号为必填,跳过。') % row_idx)
continue
role_lower = role.lower()
if role_lower not in ('老师', 'teacher', '学生', 'student'):
skipped += 1
errors.append(
_('第 %d 行:角色「%s」无效,应为 老师/学生,跳过。') % (row_idx, role))
continue
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)
if not login:
skipped += 1
errors.append(_('第 %d 行:无法生成登录账号,跳过。') % row_idx)
continue
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(
_('第 %d 行:账号 %s 已存在且类型冲突,跳过。') % (row_idx, login))
continue
# 2. 创建师生档案
if is_teacher:
self._create_teacher(user, name, phone, teacher_no)
created_teachers += 1
else:
self._create_student(
user, name, phone, class_name, college_name, gender_raw, student_number)
created_students += 1
created_users += 1
except Exception as exc:
skipped += 1
errors.append(
_('第 %d 行「%s」导入失败:%s') % (row_idx, name, str(exc)))
# 汇总消息
summary = _(
'导入完成!\n'
'新建用户:%d\n'
'老师:%d\n'
'学生:%d\n'
'跳过:%d\n'
) % (created_users, created_teachers, created_students, 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': 'info' if not errors else 'warning',
'sticky': True,
'next': {'type': 'ir.actions.act_window_close'},
},
}
# ───────────── 辅助方法 ─────────────
def _normalize_columns(self, fieldnames):
"""把各种可能的列名映射到标准 key。"""
mapping = {}
for f in fieldnames:
f_lower = f.strip().lower()
if f_lower in ('姓名', 'name', '名字'):
mapping[f] = 'name'
elif f_lower in ('角色', 'role', '角色(老师/学生)', '身份'):
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', '所属学院'):
mapping[f] = 'college'
elif f_lower in ('性别', '性别(男/女)', 'gender'):
mapping[f] = 'gender'
else:
mapping[f] = f
return mapping
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)
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:
existing.sudo().write({'groups_id': [(4, group_student.id)]})
return existing
groups = []
if is_teacher and group_teacher:
groups.append(group_teacher.id)
elif not is_teacher and group_student:
groups.append(group_student.id)
return self.env['res.users'].sudo().create({
'name': name,
'login': login,
'password': password,
'groups_id': [(6, 0, groups)],
})
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, 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(
[('name', 'ilike', class_name)], limit=1)
if cls:
vals['class_id'] = cls.id
else:
# 自动创建班级
cls = self.env['yuthon.school.class'].sudo().create({
'name': class_name,
})
vals['class_id'] = cls.id
if college_name:
vals['college'] = COLLEGE_MAP.get(college_name, 'other')
if gender_raw:
vals['gender'] = GENDER_MAP.get(gender_raw, 'male')
return self.env['yuthon.school.student'].sudo().create(vals)
+277
View File
@@ -0,0 +1,277 @@
# -*- 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,
help='在「模块信息 / 功能与介绍」页展示的模块说明(由 college 维护)',
)
features = fields.Html(
string='功能亮点',
sanitize=True,
help='在「模块信息 / 功能与介绍」页展示的功能清单(由 college 维护)',
)
knowledge_points = fields.Html(
string='知识点',
sanitize=True,
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 自建模块写入「功能亮点 / 模块介绍」文案。
由 college 的 data 文件通过 <function> 调用,每次升级幂等执行。
"""
data = {
'college': {
'introduction': (
"<p>「教育」是面向高校教学的 Odoo 18 综合管理模块,围绕<strong>老师</strong>与"
"<strong>学生</strong>双角色设计,打通从师生档案、课程建设到作业与学习进展的全流程。</p>"
"<p>它既是师生管理后台,也是学生端的学习入口,所有数据按角色自动隔离可见范围。</p>"
),
'features': (
"<ul>"
"<li><strong>师生管理</strong>:班级 / 老师 / 学生档案,支持 CSV 模板批量导入,"
"导入时自动创建登录账号;</li>"
"<li><strong>课程管理</strong>:老师创建并独立管理课程,支持理论 / 实践两种形式,"
"含视频与 PPT 课件,以及课程介绍、目标、大纲、特色;</li>"
"<li><strong>作业习题</strong>:老师布置与批改,学生在线提交;</li>"
"<li><strong>学习进展</strong>:按角色区分的进展看板,老师看全局、学生看自己;</li>"
"<li><strong>双角色权限</strong>:老师可管理,学生仅可读与提交。</li>"
"</ul>"
),
'knowledge_points': (
"<ul>"
"<li><strong>角色隔离</strong>:老师与学生看到的数据范围由权限组控制,"
"同一菜单、同一视图会根据登录角色自动过滤;</li>"
"<li><strong>CSV 导入规则</strong>:导入学生/老师时会按模板字段自动创建用户并绑定 partner,"
"确保账号与档案一一对应;</li>"
"<li><strong>课程归属</strong>:课程由创建老师负责维护,其他老师默认不可编辑;</li>"
"<li><strong>进展看板</strong>:学生的提交、成绩、学习行为会汇总到进展视图,"
"老师可查看全班统计。</li>"
"</ul>"
),
},
'edu_base': {
'introduction': (
"<p>「教育基础权限」是整套教学体系的权限基座,统一管理跨模块复用的通用角色权限组。</p>"
),
'features': (
"<ul>"
"<li>预置<strong>学生 / 教师 / 教务管理员</strong>三组权限;</li>"
"<li>作为基础依赖,供 college、learning_center 等模块直接引用;</li>"
"<li>不绑定具体业务,只负责角色与授权。</li>"
"</ul>"
),
'knowledge_points': (
"<ul>"
"<li><strong>权限组继承</strong>:edu_base 只定义基础组,业务模块通过 group_id 字段"
"或菜单 access 引用,实现权限分层;</li>"
"<li><strong>角色与用户的区别</strong>:组(Group)是权限集合,用户通过分配到组获得权限;</li>"
"<li><strong>安装顺序</strong>:依赖 edu_base 的模块会自动触发安装,通常无需单独启用。</li>"
"</ul>"
),
},
'learning_center': {
'introduction': (
"<p>「学习中心」提供课程的基础信息结构与完整教学链路,是课程内容建设的核心。</p>"
),
'features': (
"<ul>"
"<li><strong>课程 / 章节</strong>:课程下挂载章节,章节可挂资源;</li>"
"<li><strong>章节资源</strong>:视频、文档等课件管理;</li>"
"<li><strong>课程作业</strong>:布置作业与学员提交;</li>"
"<li><strong>成绩管理</strong>:成绩项定义与学员成绩;</li>"
"<li><strong>课程大纲 / 教学班</strong>:syllabus 与 teaching class 组织。</li>"
"</ul>"
),
'knowledge_points': (
"<ul>"
"<li><strong>课程 ≠ 教学班</strong>:课程是内容模板,教学班是学期/班级维度的开课实例;</li>"
"<li><strong>章节资源类型</strong>:支持视频、文档等多种资源,资源通过关系模型挂到章节下;</li>"
"<li><strong>作业与成绩联动</strong>:作业提交状态会作为成绩项的数据来源之一;</li>"
"<li><strong>大纲复用</strong>:课程大纲(syllabus)可被多个教学班引用,减少重复录入。</li>"
"</ul>"
),
},
'permission_manager': {
'introduction': (
"<p>「权限管理」提供可视化的角色与权限配置界面。</p>"
),
'features': (
"<ul>"
"<li><strong>角色(Role)</strong>定义与维护;</li>"
"<li>基于角色的权限分配视图;</li>"
"<li>与 edu_base 权限基座配合,细化授权。</li>"
"</ul>"
),
'knowledge_points': (
"<ul>"
"<li><strong>Role 模型</strong>:本模块新增独立的 Role 业务对象,可与 Odoo 原生组做映射;</li>"
"<li><strong>权限分配入口</strong>:通过表单/列表直接为角色勾选模型级 CRUD 权限;</li>"
"<li><strong>与 edu_base 的关系</strong>:edu_base 提供组定义,permission_manager 负责"
"把这些组分配给具体用户或角色。</li>"
"</ul>"
),
},
'student_organization': {
'introduction': (
"<p>「学生组织」是学生档案与年级 / 班级结构的基础模块,依赖 hr,"
"为教学体系提供组织骨架。</p>"
),
'features': (
"<ul>"
"<li><strong>学生组织</strong>:年级、班级等层级管理;</li>"
"<li><strong>学生信息</strong>:学生基本档案;</li>"
"<li><strong>基础年级</strong>:年级基础数据初始化。</li>"
"</ul>"
),
'knowledge_points': (
"<ul>"
"<li><strong>与 hr 的关系</strong>:学生档案复用 employee 模型扩展字段,"
"因此依赖 hr 模块;</li>"
"<li><strong>年级 → 班级 → 学生</strong>:组织层级自上而下引用,删除上级会受外键约束;</li>"
"<li><strong>基础数据</strong>:「基础年级」用于初始化常见年级字典,后续可在学生组织中引用。</li>"
"</ul>"
),
},
'yuthon_attendance': {
'introduction': (
"<p>「Yuthon 出勤管理」面向教职工的打卡出勤模块,自动计算工时、迟到与出勤状态。</p>"
),
'features': (
"<ul>"
"<li><strong>签到 / 签退</strong>:基于员工记录出勤;</li>"
"<li><strong>自动计算</strong>:工时、迟到分钟、出勤状态由计算字段得出;</li>"
"<li><strong>约束校验</strong>:一人一天一条,签到早于签退;</li>"
"<li><strong>出勤分析</strong>:graph / pivot 统计。</li>"
"</ul>"
),
'knowledge_points': (
"<ul>"
"<li><strong>唯一约束</strong>:同一员工同一天只能有一条出勤记录,避免重复打卡;</li>"
"<li><strong>状态计算逻辑</strong>:出勤状态由签到/签退时间与阈值比较得出,无需手动选择;</li>"
"<li><strong>工时公式</strong>:实际工时 = 签退时间 − 签到时间 − 扣除规则;</li>"
"<li><strong>与薪资联动</strong>:出勤结果会作为 yuthon_wage 计算月度工资的依据。</li>"
"</ul>"
),
},
'yuthon_employee': {
'introduction': (
"<p>「Yuthon 员工管理」是大学人事基础,管理教职工主数据,"
"是整个出勤—薪资体系的数据根。</p>"
),
'features': (
"<ul>"
"<li><strong>教职工档案</strong>:工号 / 部门 / 岗位 / 基本工资;</li>"
"<li><strong>唯一约束</strong>:工号不可重复;</li>"
"<li><strong>模块组合</strong>:被出勤、薪资模块通过 _inherit 扩展反向关联。</li>"
"</ul>"
),
'knowledge_points': (
"<ul>"
"<li><strong>工号是核心键</strong>:工号全局唯一,是出勤、薪资多条记录关联员工的基础;</li>"
"<li><strong>基本工资用途</strong>:基本工资字段写入员工档案,薪资模块每月读取并聚合;</li>"
"<li><strong>反向扩展模式</strong>:出勤、薪资模块通过 _inherit 员工模型添加字段,"
"保持主数据单一来源。</li>"
"</ul>"
),
},
'yuthon_login_theme': {
'introduction': (
"<p>「校园登录主题」是纯前端外观模块,将 Odoo 登录页美化为教育网站风格,"
"不新增任何业务数据。</p>"
),
'features': (
"<ul>"
"<li><strong>渐变背景</strong>:教育紫整页渐变;</li>"
"<li><strong>玻璃质感</strong>:登录卡片 + 品牌标题;</li>"
"<li><strong>主色统一</strong>:登录按钮与主题色一致。</li>"
"</ul>"
),
'knowledge_points': (
"<ul>"
"<li><strong>纯前端模块</strong>:只包含 SCSS / XML 视图资源,没有模型或业务逻辑;</li>"
"<li><strong>登录页覆盖</strong>:通过继承 Odoo 登录模板并覆盖样式实现;</li>"
"<li><strong>主题色变量</strong>:主色在 SCSS 变量中统一定义,修改一处即可全局生效。</li>"
"</ul>"
),
},
'yuthon_wage': {
'introduction': (
"<p>「Yuthon 薪资管理」在月底把员工基本工资与当月出勤结果聚合,"
"自动计算扣款与实发工资。</p>"
),
'features': (
"<ul>"
"<li><strong>月度工资条</strong>:基本工资 + 出勤聚合;</li>"
"<li><strong>自动扣款</strong>:迟到 / 缺勤按规则计算;</li>"
"<li><strong>批量生成</strong>:向导批量生成工资条;</li>"
"<li><strong>状态机</strong>:草稿 → 完成。</li>"
"</ul>"
),
'knowledge_points': (
"<ul>"
"<li><strong>工资条生成时机</strong>:通常每月月底运行向导,批量生成上月工资条;</li>"
"<li><strong>聚合逻辑</strong>:系统按员工汇总当月出勤记录的迟到、缺勤数据,"
"再与基本工资做扣款计算;</li>"
"<li><strong>状态流转</strong>:草稿状态可重新计算,完成后建议锁定,防止误改;</li>"
"<li><strong>与出勤模块的关系</strong>:yuthon_wage 依赖 yuthon_attendance,"
"考勤数据是薪资计算的关键输入。</li>"
"</ul>"
),
},
}
for name, vals in data.items():
rec = self.search([('name', '=', name)], limit=1)
if rec:
rec.write(vals)
# 确保来源字段对所有模块记录完成计算(覆盖历史模块记录)
self.search([])._compute_module_origin()
+69
View File
@@ -0,0 +1,69 @@
from odoo import fields, models, api
class SchoolProgress(models.Model):
"""学习进展——记录每位学员在每门课程中的学习情况。"""
_name = 'yuthon.school.progress'
_description = '学习进展'
_order = 'course_id, student_id'
_rec_name = 'name'
name = fields.Char(string='名称', compute='_compute_name', store=True)
course_id = fields.Many2one(
'yuthon.school.course', string='课程', required=True, ondelete='cascade')
student_id = fields.Many2one(
'yuthon.school.student', string='学员', required=True, ondelete='cascade')
teacher_id = fields.Many2one(
'res.users', string='任课教师',
related='course_id.teacher_id', readonly=True, store=True)
# 章节进度(基于课程章节完成情况动态计算,仅用于展示,不入库)
chapter_total = fields.Integer(string='章节总数', compute='_compute_chapter_progress')
chapter_done = fields.Integer(string='已完成章节', compute='_compute_chapter_progress')
progress_rate = fields.Float(string='学习进度(%)', compute='_compute_chapter_progress')
# 状态:普通可写字段(搜索/过滤需用 stored 字段),由章节完成情况触发更新
status = fields.Selection(
[('not_started', '未开始'), ('in_progress', '进行中'), ('completed', '已完成')],
string='状态', default='not_started', index=True, required=True)
last_study_date = fields.Date(string='最近学习日期')
score = fields.Float(string='综合得分')
note = fields.Text(string='备注')
active = fields.Boolean(string='启用', default=True)
def _compute_chapter_progress(self):
"""根据课程章节的「已完成学员」计算该学员的进度(展示用)。"""
for r in self:
chapters = r.course_id.chapter_ids if r.course_id else self.env['yuthon.school.course.chapter']
total = len(chapters)
done = sum(1 for c in chapters if r.student_id in c.completed_student_ids)
r.chapter_total = total
r.chapter_done = done
r.progress_rate = (done / total * 100.0) if total else 0.0
def recompute_status(self):
"""依据章节完成情况刷新状态(在章节完成状态变化时调用)。"""
for r in self:
total = r.chapter_total
done = r.chapter_done
if total == 0 or done == 0:
new_status = 'not_started'
elif done >= total:
new_status = 'completed'
else:
new_status = 'in_progress'
if r.status != new_status:
r.write({'status': new_status})
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
recs.recompute_status()
return recs
@api.depends('course_id', 'student_id')
def _compute_name(self):
for r in self:
r.name = '%s · %s' % (r.course_id.name or '', r.student_id.name or '')
+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
+23
View File
@@ -0,0 +1,23 @@
from odoo import api, fields, models
class SchoolClass(models.Model):
"""班级——归属一名班主任(老师),包含若干学生。"""
_name = 'yuthon.school.class'
_description = '班级'
_order = 'name'
name = fields.Char(string='班级名称', required=True, index=True)
head_teacher_id = fields.Many2one(
'yuthon.school.teacher', string='班主任', ondelete='set null')
student_ids = fields.One2many(
'yuthon.school.student', 'class_id', string='学生')
student_count = fields.Integer(
string='学生人数', compute='_compute_student_count', store=True)
active = fields.Boolean(string='启用', default=True)
@api.depends('student_ids')
def _compute_student_count(self):
for record in self:
record.student_count = len(record.student_ids)
+35
View File
@@ -0,0 +1,35 @@
from odoo import fields, models
_COLLEGE_SELECTION = [
('computer', '计算机学院'),
('software', '软件学院'),
('business', '商学院'),
('foreign', '外国语学院'),
('mechanic', '机械工程学院'),
('electric', '电子信息学院'),
('math', '数学学院'),
('other', '其他'),
]
class SchoolStudent(models.Model):
"""学生档案——来自组织架构,可关联登录账号用于学生端提交/查看。"""
_name = 'yuthon.school.student'
_description = '学生'
_order = 'student_number, name'
name = fields.Char(string='姓名', required=True, index=True)
user_id = fields.Many2one(
'res.users', string='登录账号', ondelete='restrict',
help='关联该学生的 Odoo 登录账号;不填则仅作为选课名单,无法独立登录。')
student_number = fields.Char(string='学号', index=True)
gender = fields.Selection(
[('male', '男'), ('female', '女')], string='性别')
class_id = fields.Many2one(
'yuthon.school.class', string='班级', ondelete='set null')
college = fields.Selection(_COLLEGE_SELECTION, string='学院')
phone = fields.Char(string='联系电话')
email = fields.Char(string='邮箱')
note = fields.Text(string='备注')
active = fields.Boolean(string='启用', default=True)
+24
View File
@@ -0,0 +1,24 @@
from odoo import fields, models
class SchoolTeacher(models.Model):
"""老师档案——关联 Odoo 登录账号,老师可创建课程、批改作业。"""
_name = 'yuthon.school.teacher'
_description = '老师'
_order = 'teacher_no, name'
name = fields.Char(string='姓名', required=True, index=True)
user_id = fields.Many2one(
'res.users', string='登录账号', required=True,
ondelete='restrict', unique=True,
help='关联该老师的 Odoo 登录账号,用于角色权限判断。')
teacher_no = fields.Char(string='工号', index=True)
title = fields.Selection(
[('lecturer', '讲师'), ('associate', '副教授'), ('professor', '教授')],
string='职称')
phone = fields.Char(string='联系电话')
email = fields.Char(string='邮箱')
class_ids = fields.One2many(
'yuthon.school.class', 'head_teacher_id', string='担任班主任的班级')
active = fields.Boolean(string='启用', default=True)
+11
View File
@@ -0,0 +1,11 @@
from odoo import fields, models
class SchoolTraining(models.Model):
"""我的实训(占位模型)。"""
_name = 'yuthon.school.training'
_description = '我的实训'
name = fields.Char(string='实训名称', required=True)
note = fields.Text(string='备注')
+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
+132
View File
@@ -0,0 +1,132 @@
<odoo>
<record id="module_category_education" model="ir.module.category">
<field name="name">教学管理</field>
<field name="sequence">20</field>
</record>
<record id="group_teacher" model="res.groups">
<field name="name">老师</field>
<field name="category_id" ref="module_category_education"/>
<field name="comment">可创建/管理课程、布置与批改作业、查看全部学习进展。</field>
<!-- 让老师拥有「访问权限」级权限(隐含 base.group_erp_manager)。
模块安装的 Python 层 is_admin() 检查 base.group_erp_manager,
因此老师可安装/升级/卸载模块;但老师不是 base.group_system,
所以看不到「设置」菜单。 -->
<field name="implied_ids" eval="[(4, ref('base.group_erp_manager'))]"/>
</record>
<record id="group_student" model="res.groups">
<field name="name">学生</field>
<field name="category_id" ref="module_category_education"/>
<field name="comment">可查看自己选课的课程、提交作业、查看自己的学习进展,不能创建课程。</field>
<!-- 学生仅内部用户,可登录但无模块管理权限 -->
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- 学生只能访问自己的习题作答 -->
<record id="rule_exercise_answer_student_own" model="ir.rule">
<field name="name">习题作答:学生仅能访问自己的回答</field>
<field name="model_id" ref="model_yuthon_school_course_chapter_exercise_answer"/>
<field name="groups" eval="[(4, ref('college.group_student'))]"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- 考试模块:学生仅能访问自己的答卷与答题记录 -->
<record id="rule_exam_paper_student_own" 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_student'))]"/>
<field name="domain_force">[('user_id', '=', 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_student_own" 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_student'))]"/>
<field name="domain_force">[('paper_id.user_id', '=', user.id)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<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>
+75
View File
@@ -0,0 +1,75 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_yuthon_school_course_admin,yuthon.school.course.admin,model_yuthon_school_course,base.group_system,1,1,1,1
access_yuthon_school_course_teacher,yuthon.school.course.teacher,model_yuthon_school_course,college.group_teacher,1,1,1,1
access_yuthon_school_course_student,yuthon.school.course.student,model_yuthon_school_course,college.group_student,1,1,0,0
access_yuthon_school_student_admin,yuthon.school.student.admin,model_yuthon_school_student,base.group_system,1,1,1,1
access_yuthon_school_student_teacher,yuthon.school.student.teacher,model_yuthon_school_student,college.group_teacher,1,1,1,1
access_yuthon_school_student_student,yuthon.school.student.student,model_yuthon_school_student,college.group_student,1,0,0,0
access_yuthon_school_teacher_admin,yuthon.school.teacher.admin,model_yuthon_school_teacher,base.group_system,1,1,1,1
access_yuthon_school_teacher_teacher,yuthon.school.teacher.teacher,model_yuthon_school_teacher,college.group_teacher,1,1,1,1
access_yuthon_school_teacher_student,yuthon.school.teacher.student,model_yuthon_school_teacher,college.group_student,1,0,0,0
access_yuthon_school_class_admin,yuthon.school.class.admin,model_yuthon_school_class,base.group_system,1,1,1,1
access_yuthon_school_class_teacher,yuthon.school.class.teacher,model_yuthon_school_class,college.group_teacher,1,1,1,1
access_yuthon_school_class_student,yuthon.school.class.student,model_yuthon_school_class,college.group_student,1,0,0,0
access_yuthon_school_progress_admin,yuthon.school.progress.admin,model_yuthon_school_progress,base.group_system,1,1,1,1
access_yuthon_school_progress_teacher,yuthon.school.progress.teacher,model_yuthon_school_progress,college.group_teacher,1,1,1,1
access_yuthon_school_progress_student,yuthon.school.progress.student,model_yuthon_school_progress,college.group_student,1,1,0,0
access_yuthon_school_assignment_admin,yuthon.school.assignment.admin,model_yuthon_school_assignment,base.group_system,1,1,1,1
access_yuthon_school_assignment_teacher,yuthon.school.assignment.teacher,model_yuthon_school_assignment,college.group_teacher,1,1,1,1
access_yuthon_school_assignment_student,yuthon.school.assignment.student,model_yuthon_school_assignment,college.group_student,1,0,0,0
access_yuthon_school_assignment_submission_admin,yuthon.school.assignment.submission.admin,model_yuthon_school_assignment_submission,base.group_system,1,1,1,1
access_yuthon_school_assignment_submission_teacher,yuthon.school.assignment.submission.teacher,model_yuthon_school_assignment_submission,college.group_teacher,1,1,1,1
access_yuthon_school_assignment_submission_student,yuthon.school.assignment.submission.student,model_yuthon_school_assignment_submission,college.group_student,1,1,1,0
access_yuthon_school_assignment_submission_batch_wizard_admin,yuthon.school.assignment.submission.batch.wizard.admin,model_yuthon_school_assignment_submission_batch_wizard,base.group_system,1,1,1,1
access_yuthon_school_assignment_submission_batch_wizard_teacher,yuthon.school.assignment.submission.batch.wizard.teacher,model_yuthon_school_assignment_submission_batch_wizard,college.group_teacher,1,1,1,1
access_yuthon_school_import_wizard_admin,yuthon.school.import.wizard.admin,model_yuthon_school_import_wizard,base.group_system,1,1,1,1
access_yuthon_school_import_wizard_teacher,yuthon.school.import.wizard.teacher,model_yuthon_school_import_wizard,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_admin,yuthon.school.course.chapter.admin,model_yuthon_school_course_chapter,base.group_system,1,1,1,1
access_yuthon_school_course_chapter_teacher,yuthon.school.course.chapter.teacher,model_yuthon_school_course_chapter,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_student,yuthon.school.course.chapter.student,model_yuthon_school_course_chapter,college.group_student,1,0,0,0
access_yuthon_school_course_chapter_attachment_admin,yuthon.school.course.chapter.attachment.admin,model_yuthon_school_course_chapter_attachment,base.group_system,1,1,1,1
access_yuthon_school_course_chapter_attachment_teacher,yuthon.school.course.chapter.attachment.teacher,model_yuthon_school_course_chapter_attachment,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_attachment_student,yuthon.school.course.chapter.attachment.student,model_yuthon_school_course_chapter_attachment,college.group_student,1,0,0,0
access_yuthon_school_course_chapter_exercise_admin,yuthon.school.course.chapter.exercise.admin,model_yuthon_school_course_chapter_exercise,base.group_system,1,1,1,1
access_yuthon_school_course_chapter_exercise_teacher,yuthon.school.course.chapter.exercise.teacher,model_yuthon_school_course_chapter_exercise,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_exercise_student,yuthon.school.course.chapter.exercise.student,model_yuthon_school_course_chapter_exercise,college.group_student,1,0,0,0
access_yuthon_school_course_import_wizard_admin,yuthon.school.course.import.wizard.admin,model_yuthon_school_course_import_wizard,base.group_system,1,1,1,1
access_yuthon_school_course_import_wizard_teacher,yuthon.school.course.import.wizard.teacher,model_yuthon_school_course_import_wizard,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_exercise_answer_admin,yuthon.school.course.chapter.exercise.answer.admin,model_yuthon_school_course_chapter_exercise_answer,base.group_system,1,1,1,1
access_yuthon_school_course_chapter_exercise_answer_teacher,yuthon.school.course.chapter.exercise.answer.teacher,model_yuthon_school_course_chapter_exercise_answer,college.group_teacher,1,0,0,0
access_yuthon_school_course_chapter_exercise_answer_student,yuthon.school.course.chapter.exercise.answer.student,model_yuthon_school_course_chapter_exercise_answer,college.group_student,1,1,1,0
access_yuthon_school_course_chapter_task_admin,yuthon.school.course.chapter.task.admin,model_yuthon_school_course_chapter_task,base.group_system,1,1,1,1
access_yuthon_school_course_chapter_task_teacher,yuthon.school.course.chapter.task.teacher,model_yuthon_school_course_chapter_task,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_task_student,yuthon.school.course.chapter.task.student,model_yuthon_school_course_chapter_task,college.group_student,1,0,0,0
access_yuthon_school_training_admin,yuthon.school.training.admin,model_yuthon_school_training,base.group_system,1,1,1,1
access_yuthon_school_training_teacher,yuthon.school.training.teacher,model_yuthon_school_training,college.group_teacher,1,1,1,1
access_yuthon_school_training_student,yuthon.school.training.student,model_yuthon_school_training,college.group_student,1,1,1,1
access_yuthon_about_page_admin,yuthon.about.page.admin,model_yuthon_about_page,base.group_system,1,1,1,1
access_yuthon_about_page_teacher,yuthon.about.page.teacher,model_yuthon_about_page,college.group_teacher,1,1,1,1
access_yuthon_about_page_student,yuthon.about.page.student,model_yuthon_about_page,college.group_student,1,0,0,0
access_ir_module_module_teacher,ir.module.module.teacher,base.model_ir_module_module,college.group_teacher,1,1,1,1
access_ir_module_module_student,ir.module.module.student.read,base.model_ir_module_module,college.group_student,1,0,0,0
access_yuthon_school_exam_question_admin,yuthon.school.exam.question.admin,model_yuthon_school_exam_question,base.group_system,1,1,1,1
access_yuthon_school_exam_question_teacher,yuthon.school.exam.question.teacher,model_yuthon_school_exam_question,college.group_teacher,1,1,1,1
access_yuthon_school_exam_question_student,yuthon.school.exam.question.student,model_yuthon_school_exam_question,college.group_student,1,0,0,0
access_yuthon_school_exam_question_option_admin,yuthon.school.exam.question.option.admin,model_yuthon_school_exam_question_option,base.group_system,1,1,1,1
access_yuthon_school_exam_question_option_teacher,yuthon.school.exam.question.option.teacher,model_yuthon_school_exam_question_option,college.group_teacher,1,1,1,1
access_yuthon_school_exam_question_option_student,yuthon.school.exam.question.option.student,model_yuthon_school_exam_question_option,college.group_student,1,0,0,0
access_yuthon_school_exam_admin,yuthon.school.exam.admin,model_yuthon_school_exam,base.group_system,1,1,1,1
access_yuthon_school_exam_teacher,yuthon.school.exam.teacher,model_yuthon_school_exam,college.group_teacher,1,1,1,1
access_yuthon_school_exam_student,yuthon.school.exam.student,model_yuthon_school_exam,college.group_student,1,0,0,0
access_yuthon_school_exam_line_admin,yuthon.school.exam.line.admin,model_yuthon_school_exam_line,base.group_system,1,1,1,1
access_yuthon_school_exam_line_teacher,yuthon.school.exam.line.teacher,model_yuthon_school_exam_line,college.group_teacher,1,1,1,1
access_yuthon_school_exam_line_student,yuthon.school.exam.line.student,model_yuthon_school_exam_line,college.group_student,1,0,0,0
access_yuthon_school_exam_paper_admin,yuthon.school.exam.paper.admin,model_yuthon_school_exam_paper,base.group_system,1,1,1,1
access_yuthon_school_exam_paper_teacher,yuthon.school.exam.paper.teacher,model_yuthon_school_exam_paper,college.group_teacher,1,1,1,1
access_yuthon_school_exam_paper_student,yuthon.school.exam.paper.student,model_yuthon_school_exam_paper,college.group_student,1,1,1,0
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
2 access_yuthon_school_course_admin yuthon.school.course.admin model_yuthon_school_course base.group_system 1 1 1 1
3 access_yuthon_school_course_teacher yuthon.school.course.teacher model_yuthon_school_course college.group_teacher 1 1 1 1
4 access_yuthon_school_course_student yuthon.school.course.student model_yuthon_school_course college.group_student 1 1 0 0
5 access_yuthon_school_student_admin yuthon.school.student.admin model_yuthon_school_student base.group_system 1 1 1 1
6 access_yuthon_school_student_teacher yuthon.school.student.teacher model_yuthon_school_student college.group_teacher 1 1 1 1
7 access_yuthon_school_student_student yuthon.school.student.student model_yuthon_school_student college.group_student 1 0 0 0
8 access_yuthon_school_teacher_admin yuthon.school.teacher.admin model_yuthon_school_teacher base.group_system 1 1 1 1
9 access_yuthon_school_teacher_teacher yuthon.school.teacher.teacher model_yuthon_school_teacher college.group_teacher 1 1 1 1
10 access_yuthon_school_teacher_student yuthon.school.teacher.student model_yuthon_school_teacher college.group_student 1 0 0 0
11 access_yuthon_school_class_admin yuthon.school.class.admin model_yuthon_school_class base.group_system 1 1 1 1
12 access_yuthon_school_class_teacher yuthon.school.class.teacher model_yuthon_school_class college.group_teacher 1 1 1 1
13 access_yuthon_school_class_student yuthon.school.class.student model_yuthon_school_class college.group_student 1 0 0 0
14 access_yuthon_school_progress_admin yuthon.school.progress.admin model_yuthon_school_progress base.group_system 1 1 1 1
15 access_yuthon_school_progress_teacher yuthon.school.progress.teacher model_yuthon_school_progress college.group_teacher 1 1 1 1
16 access_yuthon_school_progress_student yuthon.school.progress.student model_yuthon_school_progress college.group_student 1 1 0 0
17 access_yuthon_school_assignment_admin yuthon.school.assignment.admin model_yuthon_school_assignment base.group_system 1 1 1 1
18 access_yuthon_school_assignment_teacher yuthon.school.assignment.teacher model_yuthon_school_assignment college.group_teacher 1 1 1 1
19 access_yuthon_school_assignment_student yuthon.school.assignment.student model_yuthon_school_assignment college.group_student 1 0 0 0
20 access_yuthon_school_assignment_submission_admin yuthon.school.assignment.submission.admin model_yuthon_school_assignment_submission base.group_system 1 1 1 1
21 access_yuthon_school_assignment_submission_teacher yuthon.school.assignment.submission.teacher model_yuthon_school_assignment_submission college.group_teacher 1 1 1 1
22 access_yuthon_school_assignment_submission_student yuthon.school.assignment.submission.student model_yuthon_school_assignment_submission college.group_student 1 1 1 0
23 access_yuthon_school_assignment_submission_batch_wizard_admin yuthon.school.assignment.submission.batch.wizard.admin model_yuthon_school_assignment_submission_batch_wizard base.group_system 1 1 1 1
24 access_yuthon_school_assignment_submission_batch_wizard_teacher yuthon.school.assignment.submission.batch.wizard.teacher model_yuthon_school_assignment_submission_batch_wizard college.group_teacher 1 1 1 1
25 access_yuthon_school_import_wizard_admin yuthon.school.import.wizard.admin model_yuthon_school_import_wizard base.group_system 1 1 1 1
26 access_yuthon_school_import_wizard_teacher yuthon.school.import.wizard.teacher model_yuthon_school_import_wizard college.group_teacher 1 1 1 1
27 access_yuthon_school_course_chapter_admin yuthon.school.course.chapter.admin model_yuthon_school_course_chapter base.group_system 1 1 1 1
28 access_yuthon_school_course_chapter_teacher yuthon.school.course.chapter.teacher model_yuthon_school_course_chapter college.group_teacher 1 1 1 1
29 access_yuthon_school_course_chapter_student yuthon.school.course.chapter.student model_yuthon_school_course_chapter college.group_student 1 0 0 0
30 access_yuthon_school_course_chapter_attachment_admin yuthon.school.course.chapter.attachment.admin model_yuthon_school_course_chapter_attachment base.group_system 1 1 1 1
31 access_yuthon_school_course_chapter_attachment_teacher yuthon.school.course.chapter.attachment.teacher model_yuthon_school_course_chapter_attachment college.group_teacher 1 1 1 1
32 access_yuthon_school_course_chapter_attachment_student yuthon.school.course.chapter.attachment.student model_yuthon_school_course_chapter_attachment college.group_student 1 0 0 0
33 access_yuthon_school_course_chapter_exercise_admin yuthon.school.course.chapter.exercise.admin model_yuthon_school_course_chapter_exercise base.group_system 1 1 1 1
34 access_yuthon_school_course_chapter_exercise_teacher yuthon.school.course.chapter.exercise.teacher model_yuthon_school_course_chapter_exercise college.group_teacher 1 1 1 1
35 access_yuthon_school_course_chapter_exercise_student yuthon.school.course.chapter.exercise.student model_yuthon_school_course_chapter_exercise college.group_student 1 0 0 0
36 access_yuthon_school_course_import_wizard_admin yuthon.school.course.import.wizard.admin model_yuthon_school_course_import_wizard base.group_system 1 1 1 1
37 access_yuthon_school_course_import_wizard_teacher yuthon.school.course.import.wizard.teacher model_yuthon_school_course_import_wizard college.group_teacher 1 1 1 1
38 access_yuthon_school_course_chapter_exercise_answer_admin yuthon.school.course.chapter.exercise.answer.admin model_yuthon_school_course_chapter_exercise_answer base.group_system 1 1 1 1
39 access_yuthon_school_course_chapter_exercise_answer_teacher yuthon.school.course.chapter.exercise.answer.teacher model_yuthon_school_course_chapter_exercise_answer college.group_teacher 1 0 0 0
40 access_yuthon_school_course_chapter_exercise_answer_student yuthon.school.course.chapter.exercise.answer.student model_yuthon_school_course_chapter_exercise_answer college.group_student 1 1 1 0
41 access_yuthon_school_course_chapter_task_admin yuthon.school.course.chapter.task.admin model_yuthon_school_course_chapter_task base.group_system 1 1 1 1
42 access_yuthon_school_course_chapter_task_teacher yuthon.school.course.chapter.task.teacher model_yuthon_school_course_chapter_task college.group_teacher 1 1 1 1
43 access_yuthon_school_course_chapter_task_student yuthon.school.course.chapter.task.student model_yuthon_school_course_chapter_task college.group_student 1 0 0 0
44 access_yuthon_school_training_admin yuthon.school.training.admin model_yuthon_school_training base.group_system 1 1 1 1
45 access_yuthon_school_training_teacher yuthon.school.training.teacher model_yuthon_school_training college.group_teacher 1 1 1 1
46 access_yuthon_school_training_student yuthon.school.training.student model_yuthon_school_training college.group_student 1 1 1 1
47 access_yuthon_about_page_admin yuthon.about.page.admin model_yuthon_about_page base.group_system 1 1 1 1
48 access_yuthon_about_page_teacher yuthon.about.page.teacher model_yuthon_about_page college.group_teacher 1 1 1 1
49 access_yuthon_about_page_student yuthon.about.page.student model_yuthon_about_page college.group_student 1 0 0 0
50 access_ir_module_module_teacher ir.module.module.teacher base.model_ir_module_module college.group_teacher 1 1 1 1
51 access_ir_module_module_student ir.module.module.student.read base.model_ir_module_module college.group_student 1 0 0 0
52 access_yuthon_school_exam_question_admin yuthon.school.exam.question.admin model_yuthon_school_exam_question base.group_system 1 1 1 1
53 access_yuthon_school_exam_question_teacher yuthon.school.exam.question.teacher model_yuthon_school_exam_question college.group_teacher 1 1 1 1
54 access_yuthon_school_exam_question_student yuthon.school.exam.question.student model_yuthon_school_exam_question college.group_student 1 0 0 0
55 access_yuthon_school_exam_question_option_admin yuthon.school.exam.question.option.admin model_yuthon_school_exam_question_option base.group_system 1 1 1 1
56 access_yuthon_school_exam_question_option_teacher yuthon.school.exam.question.option.teacher model_yuthon_school_exam_question_option college.group_teacher 1 1 1 1
57 access_yuthon_school_exam_question_option_student yuthon.school.exam.question.option.student model_yuthon_school_exam_question_option college.group_student 1 0 0 0
58 access_yuthon_school_exam_admin yuthon.school.exam.admin model_yuthon_school_exam base.group_system 1 1 1 1
59 access_yuthon_school_exam_teacher yuthon.school.exam.teacher model_yuthon_school_exam college.group_teacher 1 1 1 1
60 access_yuthon_school_exam_student yuthon.school.exam.student model_yuthon_school_exam college.group_student 1 0 0 0
61 access_yuthon_school_exam_line_admin yuthon.school.exam.line.admin model_yuthon_school_exam_line base.group_system 1 1 1 1
62 access_yuthon_school_exam_line_teacher yuthon.school.exam.line.teacher model_yuthon_school_exam_line college.group_teacher 1 1 1 1
63 access_yuthon_school_exam_line_student yuthon.school.exam.line.student model_yuthon_school_exam_line college.group_student 1 0 0 0
64 access_yuthon_school_exam_paper_admin yuthon.school.exam.paper.admin model_yuthon_school_exam_paper base.group_system 1 1 1 1
65 access_yuthon_school_exam_paper_teacher yuthon.school.exam.paper.teacher model_yuthon_school_exam_paper college.group_teacher 1 1 1 1
66 access_yuthon_school_exam_paper_student yuthon.school.exam.paper.student model_yuthon_school_exam_paper college.group_student 1 1 1 0
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.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}
);
+292
View File
@@ -0,0 +1,292 @@
// ============================================================
// college 看板主题适配
// 使用 Odoo SCSS 变量,自动跟随 udoo_om_ux 等主题的浅色/深色色板。
// 需同时注册到 web.assets_backend(浅色)与 web.assets_web_dark
// / web.assets_backend_lazy_dark(深色)。
// ============================================================
.college-course-kanban {
.college-kanban-card {
background-color: $o-view-background-color;
color: $o-main-text-color;
border: 1px solid rgba($o-main-text-color, 0.08);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
border-radius: 8px;
overflow: hidden;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.college-kanban-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
color: $o-main-text-color;
}
.college-kanban-meta {
font-size: 13px;
color: $o-gray-600;
&.text-muted,
.text-muted {
color: $o-gray-600 !important;
}
.fa {
color: $o-gray-500;
}
}
.college-kanban-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
}
// ---------------- 老师端:蓝调专业风 ----------------
&.college-kanban-teacher .college-kanban-card {
.college-kanban-badge-hours {
background: rgba($o-info, 0.15);
color: $o-main-text-color;
}
.college-kanban-badge-students {
background: rgba($o-success, 0.15);
color: $o-main-text-color;
}
}
// ---------------- 学生-我的课程:书本青蓝风 ----------------
&.college-kanban-my .college-kanban-card {
.college-kanban-badge-type {
background: rgba($o-info, 0.85);
color: #ffffff;
}
.college-kanban-badge-public {
background: rgba($o-success, 0.85);
color: #ffffff;
}
}
// ---------------- 学生-课程学习:活力橙风 ----------------
&.college-kanban-study .college-kanban-card {
.college-kanban-badge-type {
background: rgba($o-view-background-color, 0.92);
color: $o-danger;
}
.college-kanban-badge-hours {
background: rgba($o-warning, 0.15);
color: $o-main-text-color;
}
.college-kanban-badge-chapters {
background: rgba($o-info, 0.15);
color: $o-main-text-color;
}
}
// ---------------- 学习进展:蓝青科技风 ----------------
&.college-kanban-progress .college-kanban-card {
> div:first-child {
background: linear-gradient(135deg, #0e7490, #22d3ee) !important;
}
.college-kanban-title {
color: #ffffff;
}
.college-kanban-badge-score {
background: rgba($o-view-background-color, 0.92);
color: $o-info;
}
.college-kanban-progress-rate {
color: $o-main-text-color;
font-weight: 600;
}
}
// ---------------- 我的实训:紫罗兰 ----------------
&.college-kanban-training .college-kanban-card {
.college-kanban-title {
color: #ffffff;
}
.college-kanban-badge-purple {
background: rgba($o-info, 0.15);
color: $o-main-text-color;
}
.college-kanban-badge-pink {
background: rgba($o-danger, 0.15);
color: $o-main-text-color;
}
}
// ---------------- 作业提交:珊瑚红 ----------------
&.college-kanban-homework .college-kanban-card {
.college-kanban-title {
color: #ffffff;
}
.college-kanban-badge-score {
background: rgba($o-view-background-color, 0.9);
color: $o-danger;
}
.college-kanban-badge-state {
background: rgba($o-danger, 0.15);
color: $o-main-text-color;
}
}
}
// ============================================================
// 课程学习:章节列表看板化(网格卡片墙)
// 把内嵌 kanban 渲染成自适应的卡片网格,去掉默认列/分组样式。
// ============================================================
.o_college_chapter_kanban {
background: transparent !important;
border: none !important;
// 去掉 kanban 默认分组列包裹,让卡片直接成为网格项
.o_kanban_renderer {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) !important;
gap: 16px !important;
align-items: stretch !important;
background: transparent !important;
padding: 0 !important;
}
// 单列分组包裹层不参与布局,使其子卡片成为 renderer 的 grid item
.o_kanban_group {
display: contents !important;
}
.o_kanban_record {
width: 100% !important;
margin: 0 !important;
border: none !important;
padding: 0 !important;
background: transparent !important;
}
.o_college_chapter_card {
height: 100%;
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
background-color: $o-view-background-color !important;
border: 1px solid rgba($o-main-text-color, 0.08) !important;
border-radius: 12px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06) !important;
padding: 16px 18px !important;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1) !important;
}
.o_college_chapter_head {
display: flex !important;
align-items: center !important;
gap: 8px !important;
.o_college_chapter_title {
font-size: 15px;
font-weight: 600;
color: $o-main-text-color !important;
}
}
.o_college_chapter_video {
font-size: 13px;
color: $o-gray-600 !important;
}
.o_college_chapter_foot {
margin-top: auto !important;
text-align: right !important;
}
}
}
// ============================================================
// 关于页(产品介绍等):只读 H5 撑满内容区,去掉四周大留白
// content 字段内容已为全内联 style 的自包含 H5,这里只做容器铺满。
// ============================================================
.o_about_full_form {
// 去掉 form 默认内边距,让内容贴边
padding: 0 !important;
.o_form_renderer,
.o_form_renderer > .o_form_field {
padding: 0 !important;
margin: 0 !important;
}
}
.o_about_full.o_field_html {
// 只读展示时铺满,无编辑器工具栏
display: block !important;
padding: 0 !important;
margin: 0 !important;
max-width: 100% !important;
background: transparent !important;
border: none !important;
.o_readonly {
padding: 0 !important;
}
}
// 系统说明 / 操作手册 仍为普通富文本(保留基础样式)
.o_about_page {
max-width: 900px;
margin: 0 auto;
font-size: 14px;
line-height: 1.8;
color: $o-main-text-color;
h2 { font-size: 22px; margin: 8px 0 16px; }
h3 { font-size: 17px; margin: 22px 0 10px; }
h4 { font-size: 15px; margin: 16px 0 8px; }
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,302 @@
// ============================================================
// college 看板「深色模式」专属样式
// 本文件只注册在 web.assets_web_dark / web.assets_backend_lazy_dark,
// 这两个 bundle 仅当用户开启深色主题时才会加载,因此下面的颜色
// 全部硬编码,不依赖任何主题 SCSS 变量(避免跨模块编译顺序导致
// 变量未切换成深色值的问题)。
// 深色页面底色约 #111827,卡片用略亮的 slate-800 (#1e293b) 形成层次。
// ============================================================
// 顶部同级菜单段(如「我的课程 / 全部课程」)夜间白字加粗
.nav-segment .nav-link {
color: #f1f5f9 !important;
font-weight: 600 !important;
}
.nav-segment .nav-link.active {
color: #ffffff !important;
font-weight: 700 !important;
}
.college-course-kanban {
.college-kanban-card {
background-color: #1e293b;
color: #f1f5f9;
border: 1px solid #334155;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
border-radius: 8px;
overflow: hidden;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
}
.college-kanban-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
color: #f8fafc !important;
}
.college-kanban-body {
background-color: #1e293b;
}
.college-kanban-meta {
font-size: 13px;
color: #94a3b8 !important;
&.text-muted,
.text-muted {
color: #94a3b8 !important;
}
.fa {
color: #64748b !important;
}
}
.college-kanban-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
}
// ---------------- 老师端:蓝调专业风 ----------------
&.college-kanban-teacher .college-kanban-card {
// 头部渐变稍压暗,避免与深色页面「糊」在一起,白字依旧清晰
> div:first-child {
background: linear-gradient(135deg, #2563eb, #4f46e5) !important;
}
.college-kanban-badge-hours {
background: rgba(96, 165, 250, 0.18);
color: #93c5fd !important;
}
.college-kanban-badge-students {
background: rgba(52, 211, 153, 0.18);
color: #6ee7b7 !important;
}
}
// ---------------- 学生-我的课程:书本青蓝风 ----------------
&.college-kanban-my .college-kanban-card {
> div:first-child {
background: linear-gradient(135deg, #0ea5e9, #06b6d4) !important;
}
.college-kanban-badge-type {
background: #38bdf8;
color: #0f172a !important;
}
.college-kanban-badge-public {
background: #34d399;
color: #0f172a !important;
}
}
// ---------------- 学生-课程学习:活力橙风 ----------------
&.college-kanban-study .college-kanban-card {
> div:first-child {
background: linear-gradient(120deg, #f97316, #ef4444) !important;
}
.college-kanban-badge-type {
background: rgba(251, 146, 60, 0.22);
color: #fdba74 !important;
}
.college-kanban-badge-hours {
background: rgba(251, 191, 36, 0.18);
color: #fcd34d !important;
}
.college-kanban-badge-chapters {
background: rgba(96, 165, 250, 0.18);
color: #93c5fd !important;
}
}
// ---------------- 学习进展:蓝青科技风 ----------------
&.college-kanban-progress .college-kanban-card {
> div:first-child {
background: linear-gradient(135deg, #0e7490, #22d3ee) !important;
}
.college-kanban-title {
color: #f8fafc !important;
}
.college-kanban-badge-score {
background: rgba(34, 211, 238, 0.2);
color: #67e8f9 !important;
}
.college-kanban-progress-rate {
color: #f1f5f9 !important;
font-weight: 600;
}
}
// ---------------- 我的实训:紫罗兰 ----------------
&.college-kanban-training .college-kanban-card {
> div:first-child {
background: linear-gradient(135deg, #7c3aed, #c026d3) !important;
}
.college-kanban-title {
color: #f8fafc !important;
}
.college-kanban-badge-purple {
background: rgba(167, 139, 250, 0.2);
color: #c4b5fd !important;
}
.college-kanban-badge-pink {
background: rgba(244, 114, 182, 0.2);
color: #f9a8d4 !important;
}
}
// ---------------- 作业提交:珊瑚红 ----------------
&.college-kanban-homework .college-kanban-card {
> div:first-child {
background: linear-gradient(135deg, #e11d48, #f97316) !important;
}
.college-kanban-title {
color: #f8fafc !important;
}
.college-kanban-badge-score {
background: rgba(248, 113, 113, 0.2);
color: #fca5a5 !important;
}
.college-kanban-badge-state {
background: rgba(248, 113, 113, 0.18);
color: #fca5a5 !important;
}
}
}
// ============================================================
// 课程学习:章节列表看板化(深色模式网格卡片墙)
// ============================================================
.o_college_chapter_kanban {
background: transparent !important;
border: none !important;
.o_kanban_renderer {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) !important;
gap: 16px !important;
align-items: stretch !important;
background: transparent !important;
padding: 0 !important;
}
.o_kanban_group {
display: contents !important;
}
.o_kanban_record {
width: 100% !important;
margin: 0 !important;
border: none !important;
padding: 0 !important;
background: transparent !important;
}
.o_college_chapter_card {
height: 100%;
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
background-color: #1e293b !important;
border: 1px solid #334155 !important;
border-radius: 12px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35) !important;
padding: 16px 18px !important;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45) !important;
}
.o_college_chapter_head {
display: flex !important;
align-items: center !important;
gap: 8px !important;
.o_college_chapter_title {
font-size: 15px;
font-weight: 600;
color: #f8fafc !important;
}
}
.o_college_chapter_video {
font-size: 13px;
color: #94a3b8 !important;
}
.o_college_chapter_foot {
margin-top: auto !important;
text-align: right !important;
}
}
}
// ============================================================
// 关于页(产品介绍等):只读 H5 撑满内容区,去掉四周大留白
// content 字段内容已为全内联 style 的自包含 H5,这里只做容器铺满。
// ============================================================
.o_about_full_form {
padding: 0 !important;
.o_form_renderer,
.o_form_renderer > .o_form_field {
padding: 0 !important;
margin: 0 !important;
}
}
.o_about_full.o_field_html {
display: block !important;
padding: 0 !important;
margin: 0 !important;
max-width: 100% !important;
background: transparent !important;
border: none !important;
.o_readonly {
padding: 0 !important;
}
}
// 系统说明 / 操作手册 仍为普通富文本(深色下基础样式)
.o_about_page {
max-width: 900px;
margin: 0 auto;
font-size: 14px;
line-height: 1.8;
color: #e2e8f0;
h2 { font-size: 22px; margin: 8px 0 16px; color: #f8fafc; }
h3 { font-size: 17px; margin: 22px 0 10px; color: #f8fafc; }
h4 { font-size: 15px; margin: 16px 0 8px; color: #f8fafc; }
p, li { color: #cbd5e1; }
}
@@ -0,0 +1,7 @@
// 关于页(yuthon.about.page)为只读展示页:
// form 视图已设 edit="false",但教师组有该模型写权限时控制面板仍会渲染「编辑」按钮,
// 这里用 js_class + CSS 精准隐藏该视图控制面板里的编辑按钮,不影响其他表单。
.o_view_controller.yuthon_about_form .o_control_panel .o_form_button_edit,
.o_view_controller.yuthon_about_form .o_control_panel 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>
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===================== 关于页:表单 ===================== -->
<!-- content 为只读 html 字段,里面放一个自包含(全内联 style)的 H5 网页,
表单去掉 sheet、撑满内容区,避免四周大留白。 -->
<record id="yuthon_about_page_form" model="ir.ui.view">
<field name="name">yuthon.about.page.form</field>
<field name="model">yuthon.about.page</field>
<field name="arch" type="xml">
<!-- create/edit 关掉:关于页内容由代码预置维护,页面右上角不再显示「创建/编辑」按钮 -->
<form class="o_about_full_form" create="false" edit="false" js_class="yuthon_about_form">
<field name="content" widget="html" readonly="1" class="o_about_full"/>
</form>
</field>
</record>
<!-- ===================== 关于页:列表(供管理员在设置中维护) ===================== -->
<record id="yuthon_about_page_list" model="ir.ui.view">
<field name="name">yuthon.about.page.list</field>
<field name="model">yuthon.about.page</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="category"/>
<field name="active"/>
</list>
</field>
</record>
</odoo>
@@ -0,0 +1,27 @@
<odoo>
<!-- 批量批改向导动作(由列表按钮通过对象方法打开,无需绑定到模型 Action 菜单) -->
<record id="action_yuthon_school_assignment_submission_batch_wizard" model="ir.actions.act_window">
<field name="name">批量批改</field>
<field name="res_model">yuthon.school.assignment.submission.batch.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- 批量批改向导表单 -->
<record id="yuthon_school_assignment_submission_batch_wizard_view_form" model="ir.ui.view">
<field name="name">yuthon.school.assignment.submission.batch.wizard.form</field>
<field name="model">yuthon.school.assignment.submission.batch.wizard</field>
<field name="arch" type="xml">
<form>
<group>
<field name="score"/>
<field name="feedback"/>
</group>
<footer>
<button name="action_apply" type="object" string="确认批改" class="btn-primary"/>
<button string="取消" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>
+303
View File
@@ -0,0 +1,303 @@
<odoo>
<!-- ===================== 章节:老师编辑表单(默认) ===================== -->
<record id="yuthon_school_course_chapter_form_view" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.form.view</field>
<field name="model">yuthon.school.course.chapter</field>
<field name="priority">30</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<form>
<group>
<group>
<field name="name"/>
<field name="course_id" readonly="1"/>
<field name="sequence" invisible="1"/>
</group>
<group>
<field name="class_hours" string="课时"/>
</group>
</group>
<group>
<field name="description" widget="html" string="章节说明"/>
<field name="notes" widget="html" string="随堂笔记"/>
</group>
<separator string="训练习题(均为简答题,可添加多道;学生可自由作答,不计分)"/>
<field name="exercise_ids" string="训练习题">
<list editable="bottom" decoration-muted="sequence &lt; 0">
<field name="sequence" widget="handle" string="排序"/>
<field name="question" string="题目"/>
<field name="analysis" string="参考解析" optional="show"/>
<field name="answer_count" string="作答人数"/>
<button name="action_view_answers" type="object"
string="查看作答" class="btn-link"
icon="fa-comments"
title="查看本题所有学员的回答"/>
</list>
</field>
<separator string="任务列表"/>
<field name="task_ids" string="任务列表">
<list editable="bottom" decoration-muted="is_done == True">
<field name="sequence" widget="handle" string="排序"/>
<field name="name" string="任务名称"/>
<field name="is_done" string="已完成" optional="show"/>
</list>
</field>
<group>
<field name="video_file" filename="video_filename"/>
<field name="video_filename" invisible="1"/>
<field name="video_filename_display" invisible="1"/>
</group>
<field name="attachment_ids" string="章节资料">
<list editable="bottom">
<field name="name" string="文件名"/>
<field name="file" filename="name"/>
</list>
</field>
</form>
</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>
<field name="priority">20</field>
<field name="groups_id" eval="[(4, ref('college.group_student')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<form>
<header>
<button name="action_toggle_complete" type="object"
string="标记已学完" class="btn-primary"
invisible="is_done_for_me"/>
<button name="action_toggle_complete" type="object"
string="取消完成" class="btn-secondary"
invisible="not is_done_for_me"/>
<field name="is_done_for_me" invisible="1"/>
</header>
<sheet>
<h2><field name="name" readonly="1"/></h2>
<div class="text-muted mb-3">
所属课程:<field name="course_id" readonly="1"/>
</div>
<group>
<field name="description" widget="html" readonly="1"/>
</group>
<!-- 有视频时直接播放 -->
<field name="video_embed_html" widget="html" readonly="1"
invisible="not video_file"/>
<!-- 没有视频但有附件时,只显示附件 -->
<separator string="章节资料" invisible="not attachment_ids"/>
<field name="attachment_ids" readonly="1"
invisible="not attachment_ids">
<list editable="bottom" create="0" delete="0">
<field name="name" string="文件名" readonly="1"/>
<field name="download_link_html" widget="html"
nolabel="1"/>
</list>
</field>
<!-- 训练习题:可自由作答,不计分 -->
<separator string="训练习题(可自由作答,不计分)" invisible="not exercise_ids"/>
<field name="exercise_ids" readonly="1"
invisible="not exercise_ids">
<list editable="bottom" create="0" delete="0">
<field name="sequence" string="序号"/>
<field name="question" string="题目" readonly="1"/>
<field name="my_answer_preview" string="我的回答"
placeholder="(未作答)" optional="show"/>
<field name="my_answer_content" string="我的回答(详细)"
optional="hide"/>
<field name="answer_count" string="已作答人数" optional="show"/>
<button name="action_answer" type="object"
string="作答 / 修改" class="btn-primary"
icon="fa-pencil"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<!-- ===================== 学员作答:弹窗表单 ===================== -->
<record id="yuthon_school_course_chapter_exercise_answer_form" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.exercise.answer.form</field>
<field name="model">yuthon.school.course.chapter.exercise.answer</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="exercise_id" invisible="1"/>
<field name="student_id" invisible="1"/>
<field name="user_id" invisible="1"/>
<field name="submit_time" invisible="1"/>
</group>
<div class="text-muted small mb-1">
所属课程:<field name="course_id" readonly="1" nolabel="1" class="oe_inline"/>
· 所属章节:<field name="chapter_id" readonly="1" nolabel="1" class="oe_inline"/>
</div>
<separator string="题目"/>
<field name="question_display" widget="html" readonly="1" nolabel="1"/>
<separator string="我的回答(自由作答,不计分,可随时修改)"/>
<field name="content" widget="html" nolabel="1"
placeholder="请输入你的回答..."/>
<div class="text-muted small mt-2" invisible="not submit_time">
上次作答时间:<field name="submit_time" readonly="1" nolabel="1" class="oe_inline"/>
</div>
</sheet>
</form>
</field>
</record>
<!-- ===================== 学员作答:老师只读列表(弹窗查看用) ===================== -->
<record id="yuthon_school_course_chapter_exercise_answer_view_tree_teacher" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.exercise.answer.tree.teacher</field>
<field name="model">yuthon.school.course.chapter.exercise.answer</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<list editable="bottom" create="0" delete="0">
<field name="student_id" string="学员" readonly="1"/>
<field name="content" string="回答内容" readonly="1"/>
<field name="submit_time" string="作答时间" readonly="1"/>
</list>
</field>
</record>
<!-- ===================== 课程学习:学生专用表单(仅学生可见,避免覆盖老师默认表单) ===================== -->
<record id="yuthon_school_course_study_form" model="ir.ui.view">
<field name="name">yuthon.school.course.study.form</field>
<field name="model">yuthon.school.course</field>
<field name="priority">10</field>
<field name="groups_id" eval="[(4, ref('college.group_student'))]"/>
<field name="arch" type="xml">
<form>
<header>
<field name="name" readonly="1"/>
<field name="chapter_count" readonly="1" string="章节数"/>
</header>
<sheet>
<div class="text-muted mb-3">
任课教师:<field name="teacher_id" readonly="1"/>
· 课时:<field name="hours" readonly="1"/>
</div>
<group>
<field name="introduction" widget="html" readonly="1"/>
</group>
<separator string="课程章节(点击「去学习」观看视频与资料)"/>
<field name="chapter_ids" readonly="1">
<kanban class="o_college_chapter_kanban">
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click o_college_chapter_card">
<div class="o_college_chapter_head">
<span class="o_college_chapter_title"><field name="name"/></span>
</div>
<div class="o_college_chapter_video" t-if="record.video_filename_display.raw_value">
<i class="fa fa-play-circle mr-1"/>
<field name="video_filename_display"/>
</div>
<div class="o_college_chapter_foot">
<button name="action_open_study" type="object"
class="btn btn-primary btn-sm" icon="fa-play">去学习</button>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</sheet>
</form>
</field>
</record>
<!-- ===================== 学生「课程学习」入口动作 ===================== -->
<record id="action_yuthon_school_course_study" model="ir.actions.act_window">
<field name="name">课程学习</field>
<field name="res_model">yuthon.school.course</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">[('student_ids.user_id', '=', uid), ('state', '=', 'published')]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_course_view_kanban_study')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_course_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_course_study_form')})]"/>
<field name="target">current</field>
</record>
<!-- ===================== 学生「我的课程」入口(用老师完整表单 + 只看自己的课程) ===================== -->
<record id="action_yuthon_school_my_course_student" model="ir.actions.act_window">
<field name="name">我的课程</field>
<field name="res_model">yuthon.school.course</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">[('student_ids.user_id', '=', uid), ('state', '=', 'published')]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_course_view_kanban_student')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_course_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_course_view_form_student')})]"/>
<field name="target">current</field>
</record>
<!-- ===================== 随堂笔记:列表(按课程分组) ===================== -->
<record id="yuthon_school_course_chapter_notes_list" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.notes.list</field>
<field name="model">yuthon.school.course.chapter</field>
<field name="priority">40</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('college.group_student')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<list>
<field name="course_id"/>
<field name="name"/>
<field name="sequence"/>
</list>
</field>
</record>
<!-- ===================== 随堂笔记:编辑表单 ===================== -->
<record id="yuthon_school_course_chapter_notes_form" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.notes.form</field>
<field name="model">yuthon.school.course.chapter</field>
<field name="priority">40</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('college.group_student')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<form>
<sheet>
<h2><field name="name" readonly="1"/></h2>
<div class="text-muted mb-3">
所属课程:<field name="course_id" readonly="1"/>
</div>
<group>
<field name="notes" widget="html" groups="college.group_teacher,base.group_system"/>
<field name="notes" widget="html" readonly="1" groups="college.group_student"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===================== 随堂笔记:搜索视图(按课程分组) ===================== -->
<record id="yuthon_school_course_chapter_notes_search" model="ir.ui.view">
<field name="name">yuthon.school.course.chapter.notes.search</field>
<field name="model">yuthon.school.course.chapter</field>
<field name="priority">40</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('college.group_student')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<search>
<field name="course_id"/>
<field name="name"/>
<filter name="group_by_course" string="按课程分组" context="{'group_by': 'course_id'}"/>
</search>
</field>
</record>
<!-- ===================== 随堂笔记:动作 ===================== -->
<record id="action_yuthon_school_class_notes" model="ir.actions.act_window">
<field name="name">随堂笔记</field>
<field name="res_model">yuthon.school.course.chapter</field>
<field name="view_mode">list,form</field>
<field name="domain">['|', ('course_id.teacher_id', '=', uid), ('course_id.student_ids.user_id', '=', uid)]</field>
<field name="context">{'search_default_group_by_course': 1}</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_course_chapter_notes_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_course_chapter_notes_form')})]"/>
<field name="search_view_id" ref="yuthon_school_course_chapter_notes_search"/>
<field name="target">current</field>
</record>
</odoo>
@@ -0,0 +1,25 @@
<odoo>
<!-- ===================== 课程批量导入向导表单 ===================== -->
<record id="yuthon_school_course_import_wizard_view_form" model="ir.ui.view">
<field name="name">yuthon.school.course.import.wizard.form</field>
<field name="model">yuthon.school.course.import.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<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>
+424
View File
@@ -0,0 +1,424 @@
<odoo>
<!-- ===================== 课程:列表 ===================== -->
<record id="yuthon_school_course_view_list" model="ir.ui.view">
<field name="name">yuthon.school.course.list</field>
<field name="model">yuthon.school.course</field>
<field name="arch" type="xml">
<list string="课程">
<field name="name"/>
<field name="code"/>
<field name="college"/>
<field name="semester"/>
<field name="course_type"/>
<field name="hours"/>
<field name="teacher_id" column_invisible="1"/>
<field name="student_count"/>
<field name="chapter_count"/>
<field name="state"/>
</list>
</field>
</record>
<!-- ===================== 课程:表单 ===================== -->
<record id="yuthon_school_course_view_form" model="ir.ui.view">
<field name="name">yuthon.school.course.form</field>
<field name="model">yuthon.school.course</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_publish" type="object" string="发布"
class="btn-primary"
invisible="[('state', '!=', 'draft')]"/>
<button name="action_archive" type="object" string="归档"
invisible="[('state', '!=', 'published')]"/>
<button name="action_to_draft" type="object" string="转为草稿"
invisible="[('state', '=', 'draft')]"/>
<!-- <field name="state" widget="statusbar"-->
<!-- statusbar_visible="draft,published,archived"/>-->
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="teacher_id" invisible="1"/>
<field name="college"/>
<field name="semester"/>
</group>
<group>
<field name="course_type"/>
<field name="hours"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="is_public"/>
</group>
</group>
<notebook>
<page string="课程内容">
<separator string="课程章节(每章含视频与资料)"/>
<field name="chapter_ids">
<list editable="top">
<field name="name"/>
<field name="class_hours" string="课时"/>
<field name="video_filename_display" string="视频"/>
<button name="action_open_edit" type="object"
string="编辑章节" icon="fa-pencil"
class="btn-link"
groups="college.group_teacher,base.group_system"/>
</list>
<form>
<group>
<field name="name"/>
<field name="sequence" invisible="1"/>
</group>
<group>
<field name="description" widget="html"
string="章节说明"/>
</group>
<group>
<field name="video_file" filename="video_filename"/>
<field name="video_filename" invisible="1"/>
</group>
<field name="attachment_ids" string="章节资料">
<list editable="bottom">
<field name="name" string="文件名"/>
<field name="file" filename="name"/>
</list>
</field>
</form>
</field>
</page>
<page string="选课学员" groups="college.group_teacher,base.group_system">
<field name="student_ids" widget="many2many"
context="{'default_name': name}"/>
</page>
<page string="课程介绍">
<field name="introduction"/>
</page>
<page string="课程目标">
<field name="objectives"/>
</page>
<page string="课程特色">
<field name="features"/>
</page>
<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/>
</form>
</field>
</record>
<!-- ===================== 课程:我的课程学生只读表单(含 Chatter) ===================== -->
<record id="yuthon_school_course_view_form_student" model="ir.ui.view">
<field name="name">yuthon.school.course.form.student</field>
<field name="model">yuthon.school.course</field>
<field name="priority">15</field>
<field name="groups_id" eval="[(4, ref('college.group_student'))]"/>
<field name="arch" type="xml">
<form edit="0" create="0" delete="0" duplicate="0">
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="code" readonly="1" string="课程编号"/>
<field name="college" readonly="1"/>
<field name="semester" readonly="1"/>
</group>
<group>
<field name="teacher_id" readonly="1"/>
<field name="course_type" readonly="1" string="课程模块"/>
<field name="hours" readonly="1"/>
<field name="start_date" readonly="1"/>
<field name="end_date" readonly="1"/>
<field name="is_public" readonly="1"/>
</group>
</group>
<notebook>
<page string="课程内容">
<separator string="课程章节(按顺序学习视频与资料)"/>
<field name="chapter_ids" readonly="1">
<list>
<field name="name"/>
<field name="class_hours" string="课时"/>
</list>
</field>
</page>
<page string="选课学员" groups="college.group_teacher,base.group_system">
<field name="student_ids" readonly="1"/>
</page>
<page string="课程介绍">
<field name="introduction" readonly="1"/>
</page>
<page string="课程目标">
<field name="objectives" readonly="1"/>
</page>
<page string="课程特色">
<field name="features" readonly="1"/>
</page>
<page string="业务情景">
<field name="business_scenario" readonly="1"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===================== 课程:搜索 ===================== -->
<record id="yuthon_school_course_view_search" model="ir.ui.view">
<field name="name">yuthon.school.course.search</field>
<field name="model">yuthon.school.course</field>
<field name="arch" type="xml">
<search>
<field name="name" string="课程名称"
filter_domain="[('name', 'ilike', self)]"/>
<field name="college"/>
<field name="semester"/>
<filter name="theory" string="理论"
domain="[('course_type', '=', 'theory')]"/>
<filter name="practice" string="实践"
domain="[('course_type', '=', 'practice')]"/>
<filter name="my_courses" string="我的课程"
domain="[('teacher_id', '=', uid)]"/>
<filter name="published" string="已发布"
domain="[('state', '=', 'published')]"/>
<group expand="0" string="分组">
<filter name="group_college" string="学院"
context="{'group_by': 'college'}"/>
<filter name="group_semester" string="学期"
context="{'group_by': 'semester'}"/>
<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>
<!-- ===================== 课程:看板 ===================== -->
<record id="yuthon_school_course_view_kanban" model="ir.ui.view">
<field name="name">yuthon.school.course.kanban</field>
<field name="model">yuthon.school.course</field>
<field name="arch" type="xml">
<kanban default_order="semester,college,name"
class="college-course-kanban college-kanban-teacher o_kanban_mobile">
<field name="name"/>
<field name="college"/>
<field name="semester"/>
<field name="course_type"/>
<field name="teacher_id"/>
<field name="student_count"/>
<field name="chapter_count"/>
<field name="state"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:8px; overflow:hidden;">
<!-- 顶帽:蓝调 + 课程编号 -->
<div style="background:linear-gradient(135deg,#3B82F6,#6366F1); padding:10px 14px; display:flex; align-items:center; justify-content:space-between;">
<div style="color:#fff; font-size:11px; opacity:.85; letter-spacing:.5px;">
<field name="code"/>
</div>
<span class="badge text-white"
style="font-size:10px; background:rgba(255,255,255,.25); padding:3px 8px; border-radius:6px;">
<field name="course_type"/>
</span>
</div>
<!-- 主体 -->
<div class="college-kanban-body" style="padding:10px 14px;">
<div class="college-kanban-title">
<field name="name"/>
</div>
<div class="college-kanban-meta text-muted small mb-1">
<i class="fa fa-building"/> <field name="college"/> · <field name="semester"/>
</div>
<div class="college-kanban-meta small mb-1">
<i class="fa fa-user text-muted"/> <field name="teacher_id"/>
</div>
<div style="display:flex; gap:8px; margin-top:8px;">
<span class="college-kanban-badge college-kanban-badge-hours">
<i class="fa fa-clock-o"/> <field name="hours"/> 课时
</span>
<span class="college-kanban-badge college-kanban-badge-students">
<i class="fa fa-users"/> <field name="student_count"/> 学员
</span>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================== 课程:我的课程学生专属看板(按学期分组,书本风格) ===================== -->
<record id="yuthon_school_course_view_kanban_student" model="ir.ui.view">
<field name="name">yuthon.school.course.kanban.student</field>
<field name="model">yuthon.school.course</field>
<field name="priority">20</field>
<field name="groups_id" eval="[(4, ref('college.group_student'))]"/>
<field name="arch" type="xml">
<kanban default_group_by="semester"
default_order="college,name"
class="college-course-kanban college-kanban-my o_kanban_mobile">
<field name="name"/>
<field name="code"/>
<field name="college"/>
<field name="semester"/>
<field name="course_type"/>
<field name="teacher_id"/>
<field name="hours"/>
<field name="chapter_count"/>
<field name="is_public"/>
<progressbar field="chapter_count" title="章节进度"
colors='{"done": "success", "warning": "warning"}'/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:8px; overflow:hidden;">
<!-- 顶帽:彩色条 + 课程编号 -->
<div style="background:linear-gradient(135deg,#5B86E5,#36D1DC); padding:10px 14px;">
<div style="color:#fff; font-size:11px; opacity:.85; letter-spacing:.5px;">
<field name="code"/>
</div>
<div style="color:#fff; font-size:15px; font-weight:600; margin-top:2px;">
<field name="name"/>
</div>
</div>
<!-- 主体 -->
<div class="college-kanban-body" style="padding:10px 14px;">
<div class="mb-2">
<span class="college-kanban-badge college-kanban-badge-type">
<field name="course_type"/>
</span>
<t t-if="record.is_public.raw_value">
<span class="college-kanban-badge college-kanban-badge-public ml-1">公开</span>
</t>
</div>
<div class="college-kanban-meta text-muted small mb-1">
<field name="college"/>
</div>
<div class="college-kanban-meta small mb-1">
<i class="fa fa-user-circle-o text-muted"/> <field name="teacher_id" widget="many2one_avatar_user"/>
</div>
<div class="college-kanban-meta small text-muted">
<i class="fa fa-clock-o"/> <field name="hours"/> 课时 · 共 <field name="chapter_count"/> 章节
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================== 课程:课程学习学生专属看板(按学院分组,活力橙风格) ===================== -->
<record id="yuthon_school_course_view_kanban_study" model="ir.ui.view">
<field name="name">yuthon.school.course.kanban.study</field>
<field name="model">yuthon.school.course</field>
<field name="priority">25</field>
<field name="groups_id" eval="[(4, ref('college.group_student'))]"/>
<field name="arch" type="xml">
<kanban default_group_by="college"
default_order="semester,name"
class="college-course-kanban college-kanban-study o_kanban_mobile">
<field name="name"/>
<field name="code"/>
<field name="college"/>
<field name="semester"/>
<field name="course_type"/>
<field name="teacher_id"/>
<field name="hours"/>
<field name="chapter_count"/>
<field name="is_public"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:10px; overflow:hidden;">
<!-- 顶帽:橙色渐变 + 课程类型徽章 -->
<div style="background:linear-gradient(120deg,#FF9966,#FF5E62); padding:8px 14px; display:flex; align-items:center; justify-content:space-between;">
<div style="color:#fff; font-size:11px; opacity:.92;">
<i class="fa fa-book"/> <field name="code"/>
</div>
<span class="college-kanban-badge college-kanban-badge-type">
<field name="course_type"/>
</span>
</div>
<!-- 主体 -->
<div class="college-kanban-body" style="padding:12px 14px;">
<div class="college-kanban-title" style="min-height:36px;">
<field name="name"/>
</div>
<div class="college-kanban-meta text-muted small mb-2">
<i class="fa fa-calendar"/> <field name="semester"/>
</div>
<div class="college-kanban-meta small mb-1">
<i class="fa fa-user text-muted"/> <field name="teacher_id" widget="many2one_avatar_user"/>
</div>
<div style="display:flex; gap:8px; margin-top:8px;">
<span class="college-kanban-badge college-kanban-badge-hours">
<i class="fa fa-clock-o"/> <field name="hours"/> 课时
</span>
<span class="college-kanban-badge college-kanban-badge-chapters">
<i class="fa fa-list"/> <field name="chapter_count"/> 章
</span>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================== 课程:动作(后置定义,显式绑定老师视图,避免学生视图靠优先级抢占) ===================== -->
<record id="action_yuthon_school_course_my" model="ir.actions.act_window">
<field name="name">我的课程</field>
<field name="res_model">yuthon.school.course</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">[('teacher_id', '=', uid)]</field>
<field name="context">{'search_default_my_courses': 1}</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_course_view_kanban')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_course_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_course_view_form')})]"/>
<field name="target">current</field>
</record>
<record id="action_yuthon_school_course_all" model="ir.actions.act_window">
<field name="name">全部课程</field>
<field name="res_model">yuthon.school.course</field>
<field name="view_mode">kanban,list,form</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_course_view_kanban')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_course_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_course_view_form')})]"/>
<field name="target">current</field>
</record>
</odoo>
@@ -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
+975
View File
@@ -0,0 +1,975 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ============================================================
学生答题卡式考试页面模板(自包含 HTML,不依赖 website/portal)
============================================================ -->
<template id="exam_take_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>
:root {
--exam-primary: #2563eb;
--exam-primary-dark: #1d4ed8;
--exam-success: #22c55e;
--exam-danger: #ef4444;
--exam-warn: #f59e0b;
--exam-bg: #f3f4f6;
--exam-card: #ffffff;
--exam-border: #e5e7eb;
--exam-text: #1f2937;
--exam-muted: #6b7280;
}
html, body { height: 100%; margin: 0; }
body {
background: var(--exam-bg);
color: var(--exam-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.exam-header {
background: var(--exam-card);
border-bottom: 1px solid var(--exam-border);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.exam-header-left h1 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
}
.exam-header-meta {
font-size: 13px;
color: var(--exam-muted);
}
.exam-header-meta span { margin-right: 16px; }
.exam-timer {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: var(--exam-text);
background: #eff6ff;
padding: 8px 14px;
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;
padding: 20px;
max-width: 1400px;
margin: 0 auto;
min-height: calc(100% - 80px);
box-sizing: border-box;
}
.exam-main {
flex: 1 1 0;
min-width: 0;
}
.exam-sidebar {
width: 320px;
flex-shrink: 0;
}
.exam-card {
background: var(--exam-card);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
padding: 24px;
margin-bottom: 16px;
}
.exam-question-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.exam-question-index {
font-size: 20px;
font-weight: 700;
}
.exam-question-type {
background: #e0e7ff;
color: #3730a3;
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 12px;
}
.exam-question-score {
margin-left: auto;
font-size: 14px;
color: var(--exam-warn);
font-weight: 600;
}
.exam-question-body {
font-size: 15px;
line-height: 1.7;
margin-bottom: 24px;
}
.exam-question-body img { max-width: 100%; height: auto; }
.exam-options { display: flex; flex-direction: column; gap: 12px; }
.exam-option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--exam-border);
border-radius: 10px;
cursor: pointer;
transition: all .15s ease;
}
.exam-option:hover { border-color: var(--exam-primary); background: #f8fafc; }
.exam-option.selected {
border-color: var(--exam-primary);
background: #eff6ff;
box-shadow: 0 0 0 1px var(--exam-primary);
}
.exam-option-marker {
width: 22px;
height: 22px;
border: 2px solid #d1d5db;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: #6b7280;
flex-shrink: 0;
margin-top: 1px;
}
.exam-option.selected .exam-option-marker {
border-color: var(--exam-primary);
background: var(--exam-primary);
color: #fff;
}
.exam-option-checkbox .exam-option-marker { border-radius: 5px; }
.exam-option-content { flex: 1; font-size: 14px; line-height: 1.6; }
.exam-option-content img { max-width: 100%; }
.exam-text-answer textarea {
width: 100%;
min-height: 160px;
padding: 12px;
border: 1px solid var(--exam-border);
border-radius: 10px;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
font-family: inherit;
}
.exam-text-answer textarea:focus {
outline: none;
border-color: var(--exam-primary);
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
}
.exam-actions {
display: flex;
justify-content: space-between;
margin-top: 24px;
}
.exam-btn {
border: none;
border-radius: 8px;
padding: 10px 22px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all .15s ease;
}
.exam-btn-primary { background: var(--exam-primary); color: #fff; }
.exam-btn-primary:hover { background: var(--exam-primary-dark); }
.exam-btn-default { background: #fff; color: var(--exam-text); border: 1px solid var(--exam-border); }
.exam-btn-default:hover { background: #f9fafb; }
.exam-btn-success { background: var(--exam-success); color: #fff; }
.exam-btn-success:hover { background: #16a34a; }
.exam-btn:disabled { opacity: .5; cursor: not-allowed; }
.exam-sidebar-title {
font-size: 16px;
font-weight: 700;
margin: 0 0 14px;
}
.exam-legend {
display: flex;
gap: 14px;
margin-bottom: 16px;
font-size: 12px;
color: var(--exam-muted);
}
.exam-legend-item { display: flex; align-items: center; gap: 6px; }
.exam-legend-dot {
width: 12px; height: 12px; border-radius: 3px;
border: 1px solid var(--exam-border);
}
.exam-legend-dot.current { background: var(--exam-primary); border-color: var(--exam-primary); }
.exam-legend-dot.answered { background: var(--exam-success); border-color: var(--exam-success); }
.exam-legend-dot.unanswered { background: #fff; }
.exam-card-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
}
.exam-card-btn {
height: 40px;
border: 1px solid var(--exam-border);
border-radius: 8px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all .15s ease;
}
.exam-card-btn:hover { border-color: var(--exam-primary); }
.exam-card-btn.current { background: var(--exam-primary); color: #fff; border-color: var(--exam-primary); }
.exam-card-btn.answered { background: var(--exam-success); color: #fff; border-color: var(--exam-success); }
.exam-card-btn.unanswered { color: var(--exam-muted); }
.exam-submit-area {
margin-top: 18px;
}
.exam-submit-area .exam-btn {
width: 100%;
padding: 14px;
font-size: 16px;
}
.exam-toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: #fff;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
z-index: 200;
opacity: 0;
pointer-events: none;
transition: opacity .2s ease;
}
.exam-toast.show { opacity: 1; }
.exam-progress {
font-size: 13px;
color: var(--exam-muted);
margin-top: 12px;
text-align: center;
}
@media (max-width: 900px) {
.exam-container { flex-direction: column; }
.exam-sidebar { width: 100%; }
.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>
<div class="exam-header-meta">
<span>课程:<t t-esc="course.name or '-'"/></span>
<span t-if="chapters">章节:<t t-esc="', '.join(chapters.mapped('name'))"/></span>
<span>学员:<t t-esc="student.name or '-'"/></span>
<span>满分:<t t-esc="paper.total_score"/> 分</span>
<span>及格:<t t-esc="paper.pass_score"/> 分</span>
</div>
</div>
<div class="exam-timer" id="exam-timer" t-att-data-seconds="remaining_seconds">
<span class="exam-timer-label">剩余时间</span>
<span id="timer-text">--:--:--</span>
</div>
</div>
<div class="exam-container">
<div class="exam-main">
<div class="exam-card" id="question-card">
<div class="exam-question-header">
<span class="exam-question-index" id="q-index">第 1 题</span>
<span class="exam-question-type" id="q-type">单选题</span>
<span class="exam-question-score" id="q-score">5 分</span>
</div>
<div class="exam-question-body" id="q-body">
<!-- 题干 -->
</div>
<div class="exam-options" id="q-options">
<!-- 选项 / 输入区 -->
</div>
<div class="exam-actions">
<button class="exam-btn exam-btn-default" id="btn-prev" type="button">上一题</button>
<button class="exam-btn exam-btn-primary" id="btn-next" type="button">下一题</button>
</div>
</div>
</div>
<div class="exam-sidebar">
<div class="exam-card">
<h3 class="exam-sidebar-title">答题卡</h3>
<div class="exam-legend">
<div class="exam-legend-item"><span class="exam-legend-dot current"/>当前</div>
<div class="exam-legend-item"><span class="exam-legend-dot answered"/>已做</div>
<div class="exam-legend-item"><span class="exam-legend-dot unanswered"/>未做</div>
</div>
<div class="exam-card-grid" id="answer-card-grid">
<!-- 题号按钮 -->
</div>
<div class="exam-progress" id="answer-progress">已完成 0 / 0 题</div>
<div class="exam-submit-area">
<button class="exam-btn exam-btn-success" id="btn-submit" type="button">提交答卷</button>
</div>
</div>
</div>
</div>
<div class="exam-toast" id="exam-toast"></div>
<!-- 将作答数据放入 data 属性:属性值中的实体会被正确解码,且可避免 HTML 题干里 </script> 造成脚本截断 -->
<div id="exam-meta"
t-att-data-answers="answer_json"
t-att-data-paper="paper.id"
style="display:none"></div>
<script type="text/javascript">
//<![CDATA[
(function() {
var metaEl = document.getElementById('exam-meta');
var answers = JSON.parse(metaEl.dataset.answers || '[]');
var paperId = parseInt(metaEl.dataset.paper, 10) || 0;
var currentIdx = 0;
var saveQueue = Promise.resolve();
function $(id) { return document.getElementById(id); }
function showToast(msg) {
var t = $('exam-toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(function() { t.classList.remove('show'); }, 1500);
}
function stripHtml(html) {
var tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
}
function isAnswered(ans) {
if (!ans) return false;
if (ans.question_type === 'single_choice') return !!ans.selected_option_id;
if (ans.question_type === 'multiple_choice') return (ans.selected_option_ids || []).length > 0;
if (ans.question_type === 'true_false') return !!ans.answer_choice;
return !!(ans.answer_text && stripHtml(ans.answer_text).trim());
}
function renderQuestion(idx) {
currentIdx = idx;
var ans = answers[idx];
$('q-index').textContent = '第 ' + (idx + 1) + ' 题';
$('q-type').textContent = ans.question_type_label || '';
$('q-score').textContent = (ans.max_score || 0) + ' 分';
$('q-body').innerHTML = ans.question_text || '';
var optsEl = $('q-options');
optsEl.innerHTML = '';
if (ans.question_type === 'single_choice') {
ans.options.forEach(function(opt) {
var div = document.createElement('div');
div.className = 'exam-option' + (ans.selected_option_id === opt.id ? ' selected' : '');
div.dataset.id = opt.id;
div.innerHTML = '<span class="exam-option-marker">' + opt.label + '</span>' +
'<span class="exam-option-content">' + (opt.content || '') + '</span>';
div.addEventListener('click', function() {
ans.selected_option_id = opt.id;
ans.selected_option_ids = [opt.id];
renderQuestion(idx);
saveAnswer(ans);
});
optsEl.appendChild(div);
});
} else if (ans.question_type === 'multiple_choice') {
ans.options.forEach(function(opt) {
var div = document.createElement('div');
var selected = (ans.selected_option_ids || []).indexOf(opt.id) >= 0;
div.className = 'exam-option exam-option-checkbox' + (selected ? ' selected' : '');
div.dataset.id = opt.id;
div.innerHTML = '<span class="exam-option-marker">' + (selected ? '✓' : '') + '</span>' +
'<span class="exam-option-content">' + (opt.content || '') + '</span>';
div.addEventListener('click', function() {
var ids = ans.selected_option_ids || [];
var pos = ids.indexOf(opt.id);
if (pos >= 0) ids.splice(pos, 1); else ids.push(opt.id);
ans.selected_option_ids = ids;
ans.selected_option_id = ids.length ? ids[0] : false;
renderQuestion(idx);
saveAnswer(ans);
});
optsEl.appendChild(div);
});
} else if (ans.question_type === 'true_false') {
[['True', '正确'], ['False', '错误']].forEach(function(pair) {
var val = pair[0], label = pair[1];
var div = document.createElement('div');
div.className = 'exam-option' + (ans.answer_choice === val ? ' selected' : '');
div.innerHTML = '<span class="exam-option-marker">' + (ans.answer_choice === val ? '✓' : '') + '</span>' +
'<span class="exam-option-content">' + label + '</span>';
div.addEventListener('click', function() {
ans.answer_choice = val;
renderQuestion(idx);
saveAnswer(ans);
});
optsEl.appendChild(div);
});
} else {
var wrap = document.createElement('div');
wrap.className = 'exam-text-answer';
var ta = document.createElement('textarea');
ta.placeholder = ans.question_type === 'essay' ? '请输入简答内容...' : '请输入答案...';
ta.value = ans.answer_text || '';
ta.addEventListener('input', function() {
ans.answer_text = ta.value;
});
ta.addEventListener('blur', function() {
saveAnswer(ans);
renderCard();
});
wrap.appendChild(ta);
optsEl.appendChild(wrap);
}
$('btn-prev').disabled = idx === 0;
$('btn-next').textContent = idx === answers.length - 1 ? '返回第一题' : '下一题';
renderCard();
}
function renderCard() {
var grid = $('answer-card-grid');
grid.innerHTML = '';
var answeredCount = 0;
answers.forEach(function(ans, idx) {
var answered = isAnswered(ans);
if (answered) answeredCount++;
var btn = document.createElement('div');
btn.className = 'exam-card-btn';
if (idx === currentIdx) btn.classList.add('current');
else if (answered) btn.classList.add('answered');
else btn.classList.add('unanswered');
btn.textContent = idx + 1;
btn.addEventListener('click', function() { renderQuestion(idx); });
grid.appendChild(btn);
});
$('answer-progress').textContent = '已完成 ' + answeredCount + ' / ' + answers.length + ' 题';
}
function saveAnswer(ans) {
var payload = {
paper_id: paperId,
answer_id: ans.id,
data: {}
};
if (ans.question_type === 'single_choice') payload.data.selected_option_id = ans.selected_option_id;
else if (ans.question_type === 'multiple_choice') payload.data.selected_option_ids = ans.selected_option_ids || [];
else if (ans.question_type === 'true_false') payload.data.answer_choice = ans.answer_choice;
else payload.data.answer_text = ans.answer_text || '';
saveQueue = saveQueue.then(function() {
return fetch('/exam/save', {
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) {
if (res.result && res.result.status === 'ok') {
// 静默保存,不打扰
}
}).catch(function() {
// 网络异常忽略,允许学生继续作答
});
});
}
$('btn-prev').addEventListener('click', function() {
if (currentIdx > 0) renderQuestion(currentIdx - 1);
});
$('btn-next').addEventListener('click', function() {
if (currentIdx < answers.length - 1) renderQuestion(currentIdx + 1);
else renderQuestion(0);
});
$('btn-submit').addEventListener('click', function() {
var unAnswered = answers.filter(function(a) { return !isAnswered(a); }).length;
var msg = unAnswered > 0
? '您还有 ' + unAnswered + ' 道题未作答,确定要提交吗?'
: '提交后不能再修改,是否继续?';
if (!confirm(msg)) return;
fetch('/exam/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: { paper_id: paperId }, id: Math.random() })
}).then(function(r) { return r.json(); }).then(function(res) {
if (res.result && res.result.status === 'ok') {
window.location.href = res.result.redirect;
} else {
var err = (res.result && res.result.message) || '提交失败';
alert(err);
}
}).catch(function() { alert('提交失败,请检查网络后重试。'); });
});
// 倒计时
var timerEl = $('exam-timer');
var seconds = parseInt(timerEl.dataset.seconds || '0', 10);
if (seconds > 0) {
function fmt(s) {
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
return [h, m, sec].map(function(n){ return n < 10 ? '0'+n : n; }).join(':');
}
$('timer-text').textContent = fmt(seconds);
var interval = setInterval(function() {
seconds--;
$('timer-text').textContent = fmt(seconds);
if (seconds < 300) timerEl.classList.add('warning');
if (seconds <= 0) {
clearInterval(interval);
alert('考试时间已结束,系统将自动提交。');
$('btn-submit').click();
}
}, 1000);
} else {
$('timer-text').textContent = '不限时';
}
// 题型标签翻译
var typeLabels = {
'single_choice': '单选题',
'multiple_choice': '多选题',
'true_false': '判断题',
'fill_blank': '填空题',
'essay': '简答题'
};
answers.forEach(function(a) { a.question_type_label = typeLabels[a.question_type] || a.question_type; });
if (answers.length) renderQuestion(0);
else {
$('q-body').innerHTML = '<p>该试卷暂无题目。</p>';
$('btn-submit').style.display = 'none';
}
})();
//]]>
</script>
</body>
</html>
</template>
<!-- 结果页面 -->
<template id="exam_result_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; }
.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; }
</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>
</div>
</div>
<div class="result-card">
<div class="result-answer-title">答题详情</div>
<t t-foreach="answers" t-as="ans">
<div class="result-question">
<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><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.explanation" style="margin-top:10px; padding:10px; background:#fff; border-radius:6px;">
<strong>答案说明:</strong>
<div t-raw="ans.explanation"/>
</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>
</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="考试错误提示">
<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); }
.err-icon { font-size:48px; color:#f59e0b; margin-bottom:16px; }
.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>
</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>
+577
View File
@@ -0,0 +1,577 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ============================================================
考试模块视图:题库 / 考试发布 / 答卷 / 答题记录
============================================================ -->
<!-- ===================== 题库:列表 ===================== -->
<record id="yuthon_school_exam_question_view_tree" model="ir.ui.view">
<field name="name">yuthon.school.exam.question.tree</field>
<field name="model">yuthon.school.exam.question</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="question_text_short" string="题干"/>
<field name="question_type"/>
<field name="difficulty"/>
<field name="course_id"/>
<field name="chapter_id" optional="show"/>
<field name="score"/>
<field name="teacher_id" optional="hide"/>
<field name="visibility" optional="show"/>
</list>
</field>
</record>
<!-- ===================== 题库:表单 ===================== -->
<record id="yuthon_school_exam_question_view_form" model="ir.ui.view">
<field name="name">yuthon.school.exam.question.form</field>
<field name="model">yuthon.school.exam.question</field>
<field name="arch" type="xml">
<form>
<sheet>
<field name="question_text" widget="html" string="题干" required="1"/>
<group>
<group>
<field name="question_type"/>
<field name="difficulty"/>
<field name="score"/>
<field name="visibility"/>
</group>
<group>
<field name="course_id"/>
<field name="chapter_id"/>
<field name="teacher_id" readonly="1"/>
</group>
</group>
<notebook>
<page string="选项" name="options"
invisible="question_type not in ('single_choice','multiple_choice')">
<field name="option_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="label"/>
<field name="content"/>
<field name="is_correct" string="正确答案"/>
</list>
</field>
<div class="text-muted mt-2">
单选题请只勾选一个正确答案;多选题可勾选多个正确答案。
</div>
</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 != 'fill_blank'"
required="question_type == 'fill_blank'"/>
<field name="reference_answer" widget="html"
invisible="question_type != 'essay'"/>
</group>
<field name="explanation" widget="html" string="答案说明 / 解析" nolabel="1"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ===================== 题库:搜索 ===================== -->
<record id="yuthon_school_exam_question_view_search" model="ir.ui.view">
<field name="name">yuthon.school.exam.question.search</field>
<field name="model">yuthon.school.exam.question</field>
<field name="arch" type="xml">
<search>
<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>
</search>
</field>
</record>
<!-- ===================== 考试发布:列表 ===================== -->
<record id="yuthon_school_exam_view_tree" model="ir.ui.view">
<field name="name">yuthon.school.exam.tree</field>
<field name="model">yuthon.school.exam</field>
<field name="arch" type="xml">
<list decoration-info="state == 'published'" decoration-muted="state == 'closed'">
<field name="name"/>
<field name="course_id"/>
<field name="start_time"/>
<field name="end_time"/>
<field name="duration_minutes"/>
<field name="total_score"/>
<field name="state" widget="badge"
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="已阅"/>
</list>
</field>
</record>
<!-- ===================== 考试发布:表单 ===================== -->
<record id="yuthon_school_exam_view_form" model="ir.ui.view">
<field name="name">yuthon.school.exam.form</field>
<field name="model">yuthon.school.exam</field>
<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="截止考试"
invisible="state != 'published'"/>
<button name="action_reopen" type="object" string="重新开放"
invisible="state != 'closed'"/>
<button name="action_draft" type="object" string="撤回草稿"
invisible="state != 'draft' and state != 'published'"
confirm="撤回草稿将删除已生成的待考答卷,是否继续?"/>
<field name="state" widget="statusbar" statusbar_visible="draft,published,closed"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="考试名称"/></h1>
</div>
<group>
<group>
<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"/>
<field name="end_time"/>
<field name="duration_minutes"/>
<field name="pass_score"/>
<field name="total_score" readonly="1"/>
</group>
</group>
<field name="description" widget="html" string="考试说明"/>
<notebook>
<page string="试卷题目" name="lines">
<field name="line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="question_id"/>
<field name="question_type" readonly="1"/>
<field name="score"/>
</list>
</field>
</page>
<page string="答卷" name="papers">
<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>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===================== 考试发布:搜索 ===================== -->
<record id="yuthon_school_exam_view_search" model="ir.ui.view">
<field name="name">yuthon.school.exam.search</field>
<field name="model">yuthon.school.exam</field>
<field name="arch" type="xml">
<search>
<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')]"/>
<group expand="0" string="分组">
<filter name="group_course" string="按课程" context="{'group_by':'course_id'}"/>
<filter name="group_state" string="按状态" context="{'group_by':'state'}"/>
</group>
</search>
</field>
</record>
<!-- ===================== 答卷:列表 ===================== -->
<record id="yuthon_school_exam_paper_view_tree" model="ir.ui.view">
<field name="name">yuthon.school.exam.paper.tree</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_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="state" widget="badge"/>
<field name="score"/>
<field name="total_score"/>
<field name="is_passed"/>
<field name="submit_time" optional="show"/>
<button name="action_open_take_page" type="object" string="开始考试" icon="fa-play"
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>
<!-- ===================== 答卷:老师评阅表单 ===================== -->
<record id="yuthon_school_exam_paper_form" model="ir.ui.view">
<field name="name">yuthon.school.exam.paper.form.teacher</field>
<field name="model">yuthon.school.exam.paper</field>
<field name="arch" type="xml">
<form>
<header>
<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>
<div class="oe_title">
<h1><field name="exam_id" string="考试名称" readonly="1"/></h1>
</div>
<group>
<group>
<field name="course_id" string="所属课程" readonly="1"/>
<field name="chapter_ids" widget="many2many_tags" string="关联章节" readonly="1"/>
<field name="student_id" readonly="1"/>
<field name="start_time" readonly="1"/>
<field name="submit_time" readonly="1"/>
</group>
<group>
<field name="score" readonly="1"/>
<field name="total_score" readonly="1"/>
<field name="pass_score" readonly="1"/>
<field name="is_passed" readonly="1"/>
</group>
</group>
<field name="answer_ids" readonly="0" mode="list">
<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>
</form>
</field>
</record>
<!-- ===================== 答卷:学生考试表单 ===================== -->
<record id="yuthon_school_exam_paper_form_student" model="ir.ui.view">
<field name="name">yuthon.school.exam.paper.form.student</field>
<field name="model">yuthon.school.exam.paper</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_open_take_page" type="object" string="开始考试"
class="oe_highlight" invisible="state != 'pending'"/>
<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>
<div class="oe_title">
<h1><field name="name" string="考试/学员" readonly="1"/></h1>
</div>
<group>
<group>
<field name="exam_id" string="考试名称" readonly="1"/>
<field name="course_id" string="所属课程" readonly="1"/>
<field name="chapter_ids" widget="many2many_tags" string="关联章节" readonly="1"/>
<field name="student_id" readonly="1"/>
</group>
<group>
<field name="total_score" readonly="1"/>
<field name="pass_score" readonly="1"/>
<field name="score" readonly="1" invisible="state == 'in_progress'"/>
<field name="is_passed" readonly="1" invisible="state == 'in_progress'"/>
</group>
</group>
<field name="description" widget="html" string="考试说明" readonly="1" nolabel="1"/>
<separator string="试卷题目" invisible="state == 'pending'"/>
<field name="answer_ids" readonly="0" mode="list"
invisible="state in ('pending',)">
<list>
<field name="sequence" invisible="1"/>
<field name="question_text_short" string="题干"/>
<field name="question_type"/>
<field name="answer_display" string="我的作答"/>
<field name="score" readonly="1"/>
<field name="max_score" readonly="1"/>
<button name="action_view_answer_form" type="object" string="作答" icon="fa-pencil"
invisible="parent.state not in ('in_progress',)"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<!-- ===================== 答卷:搜索 ===================== -->
<record id="yuthon_school_exam_paper_view_search" model="ir.ui.view">
<field name="name">yuthon.school.exam.paper.search</field>
<field name="model">yuthon.school.exam.paper</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="exam_id"/>
<field name="student_id"/>
<filter name="submitted" string="待评阅" domain="[('state','=','submitted')]"/>
<filter name="graded" string="已评阅" domain="[('state','=','graded')]"/>
<filter name="mine" string="我的答卷" domain="[('user_id','=',uid)]"/>
<group expand="0" string="分组">
<filter name="group_exam" string="按考试" context="{'group_by':'exam_id'}"/>
<filter name="group_state" string="按状态" context="{'group_by':'state'}"/>
</group>
</search>
</field>
</record>
<!-- ===================== 答题记录:表单(作答/评阅弹窗) ===================== -->
<record id="yuthon_school_exam_answer_view_form" model="ir.ui.view">
<field name="name">yuthon.school.exam.answer.form</field>
<field name="model">yuthon.school.exam.answer</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="question_type" readonly="1"/>
<field name="max_score" readonly="1"/>
<field name="is_correct" readonly="1"/>
</group>
<field name="question_text" widget="html" readonly="1" string="题干" nolabel="1"/>
<separator string="学生作答"/>
<group>
<field name="selected_option_id" string="选择答案"
invisible="question_type != 'single_choice'"
required="question_type == 'single_choice'"/>
<field name="selected_option_ids" widget="many2many_checkboxes" string="选择答案"
invisible="question_type != 'multiple_choice'"
required="question_type == 'multiple_choice'"/>
<field name="answer_choice" string="判断"
invisible="question_type != 'true_false'"
required="question_type == 'true_false'"/>
<field name="answer_text" string="作答"
invisible="question_type not in ('fill_blank','essay')"
required="question_type in ('fill_blank','essay')"/>
</group>
<separator string="评阅" groups="college.group_teacher,base.group_system"/>
<group groups="college.group_teacher,base.group_system">
<field name="score" string="得分"/>
<field name="teacher_comment" string="评语"/>
</group>
<separator string="答案说明"/>
<field name="explanation" widget="html" readonly="1" string="解析" nolabel="1"/>
<field name="reference_answer" widget="html" readonly="1" string="参考答案" nolabel="1"
invisible="question_type != 'essay'"/>
</sheet>
</form>
</field>
</record>
<!-- ===================== 答题记录:老师评阅列表 ===================== -->
<record id="yuthon_school_exam_answer_view_tree_grade" model="ir.ui.view">
<field name="name">yuthon.school.exam.answer.tree.grade</field>
<field name="model">yuthon.school.exam.answer</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="paper_id" readonly="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"/>
<field name="teacher_comment"/>
</list>
</field>
</record>
<!-- ===================== 答题记录:搜索 ===================== -->
<record id="yuthon_school_exam_answer_view_search" model="ir.ui.view">
<field name="name">yuthon.school.exam.answer.search</field>
<field name="model">yuthon.school.exam.answer</field>
<field name="arch" type="xml">
<search>
<field name="paper_id"/>
<field name="question_id"/>
<filter name="submitted" string="待评阅" domain="[('paper_id.state','=','submitted')]"/>
<filter name="essay" string="简答题" domain="[('question_type','=','essay')]"/>
<group expand="0" string="分组">
<filter name="group_paper" string="按答卷" context="{'group_by':'paper_id'}"/>
<filter name="group_type" string="按题型" context="{'group_by':'question_type'}"/>
</group>
</search>
</field>
</record>
<!-- ===================== 动作 ===================== -->
<record id="action_yuthon_school_exam_question" model="ir.actions.act_window">
<field name="name">题库</field>
<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_group_chapter': 1}</field>
</record>
<record id="action_yuthon_school_exam" model="ir.actions.act_window">
<field name="name">考试管理</field>
<field name="res_model">yuthon.school.exam</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="yuthon_school_exam_view_search"/>
<field name="context">{'search_default_mine': 1}</field>
</record>
<record id="action_yuthon_school_exam_paper_grade" 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_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','=','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_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>
</record>
<record id="action_yuthon_school_exam_result" 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_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>
<record id="action_yuthon_school_exam_answer_grade" model="ir.actions.act_window">
<field name="name">逐题评阅</field>
<field name="res_model">yuthon.school.exam.answer</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="yuthon_school_exam_answer_view_tree_grade"/>
<field name="search_view_id" ref="yuthon_school_exam_answer_view_search"/>
<field name="domain">[('paper_id.state','=','submitted')]</field>
</record>
</odoo>
+271
View File
@@ -0,0 +1,271 @@
<odoo>
<!-- ===================== 作业布置 ===================== -->
<record id="action_yuthon_school_assignment" model="ir.actions.act_window">
<field name="name">作业布置</field>
<field name="res_model">yuthon.school.assignment</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_my_assignments': 1}</field>
</record>
<record id="yuthon_school_assignment_view_list" model="ir.ui.view">
<field name="name">yuthon.school.assignment.list</field>
<field name="model">yuthon.school.assignment</field>
<field name="arch" type="xml">
<list string="作业">
<header>
<button name="action_publish_batch" type="object" string="批量发布"
class="btn-primary" groups="college.group_teacher"/>
</header>
<field name="name"/>
<field name="course_id"/>
<field name="teacher_id"/>
<field name="due_date"/>
<field name="submission_count"/>
<field name="state"/>
</list>
</field>
</record>
<record id="yuthon_school_assignment_view_form" model="ir.ui.view">
<field name="name">yuthon.school.assignment.form</field>
<field name="model">yuthon.school.assignment</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_publish" type="object" string="发布"
class="btn-primary"
invisible="state != 'draft'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,published,closed"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<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>
<group>
<field name="attachment" filename="attachment_filename"/>
<field name="attachment_filename" invisible="1"/>
</group>
</group>
<notebook>
<page string="作业说明">
<field name="description"/>
</page>
<page string="提交记录">
<field name="submission_ids" readonly="1">
<list>
<field name="student_id"/>
<field name="submit_time"/>
<field name="score"/>
<field name="state"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="yuthon_school_assignment_view_search" model="ir.ui.view">
<field name="name">yuthon.school.assignment.search</field>
<field name="model">yuthon.school.assignment</field>
<field name="arch" type="xml">
<search>
<field name="name" string="作业标题"
filter_domain="[('name', 'ilike', self)]"/>
<field name="course_id" string="课程"/>
<field name="teacher_id" string="布置教师"/>
<filter name="my_assignments" string="我布置的"
domain="[('teacher_id', '=', uid)]"/>
<filter name="published" string="已发布"
domain="[('state', '=', 'published')]"/>
<group expand="0" string="分组">
<filter name="group_course" string="课程"
context="{'group_by': 'course_id'}"/>
<filter name="group_state" string="状态"
context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<!-- ===================== 提交与批改(老师端) ===================== -->
<record id="action_yuthon_school_assignment_submission" model="ir.actions.act_window">
<field name="name">提交与批改</field>
<field name="res_model">yuthon.school.assignment.submission</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_group_state': 1}</field>
</record>
<!-- ===================== 作业提交(学生端) ===================== -->
<record id="action_yuthon_school_my_submission" model="ir.actions.act_window">
<field name="name">作业提交</field>
<field name="res_model">yuthon.school.assignment.submission</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">['&amp;', ('student_id.user_id', '=', uid), ('assignment_id.course_id.state', '=', 'published')]</field>
<field name="context">{'search_default_my_submissions': 1}</field>
</record>
<record id="yuthon_school_assignment_submission_view_list" model="ir.ui.view">
<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" create="0">
<header>
<button name="action_open_batch_wizard"
type="object" string="批量批改"
class="btn-primary"
groups="college.group_teacher"/>
<button name="action_download_attachments"
type="object" string="批量下载附件"
class="btn-secondary"
groups="college.group_teacher"/>
</header>
<field name="assignment_id"/>
<field name="student_id" string="姓名" groups="college.group_teacher"/>
<field name="score"/>
<field name="state"/>
<field name="submit_time"/>
<field name="due_date"/>
</list>
</field>
</record>
<record id="yuthon_school_assignment_submission_view_kanban" model="ir.ui.view">
<field name="name">yuthon.school.assignment.submission.kanban</field>
<field name="model">yuthon.school.assignment.submission</field>
<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"
create="0">
<field name="assignment_id"/>
<field name="student_id"/>
<field name="score"/>
<field name="state"/>
<field name="due_date"/>
<field name="submit_time"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:8px; overflow:hidden;">
<!-- 顶帽:珊瑚渐变 + 得分 -->
<div style="background:linear-gradient(135deg,#F56565,#F6AD55); padding:10px 14px; display:flex; align-items:center; justify-content:space-between;">
<div class="college-kanban-title" style="color:#fff; font-size:13px; max-width:70%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
<field name="assignment_id"/>
</div>
<span class="college-kanban-badge college-kanban-badge-score">
<field name="score"/> 分
</span>
</div>
<!-- 主体 -->
<div class="college-kanban-body" style="padding:10px 14px;">
<div class="college-kanban-meta small mb-2" groups="college.group_teacher">
<i class="fa fa-user text-muted"/> <field name="student_id"/>
</div>
<div class="mb-2" style="display:flex; gap:6px; flex-wrap:wrap;">
<span class="college-kanban-badge college-kanban-badge-state">
<field name="state"/>
</span>
</div>
<div class="college-kanban-meta small text-muted" style="line-height:1.5;">
<div><i class="fa fa-calendar-check-o"/> 截止:<field name="due_date"/></div>
<t t-if="record.submit_time.raw_value">
<div><i class="fa fa-clock-o"/> 提交:<field name="submit_time"/></div>
</t>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="yuthon_school_assignment_submission_view_form" model="ir.ui.view">
<field name="name">yuthon.school.assignment.submission.form</field>
<field name="model">yuthon.school.assignment.submission</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_submit" type="object" string="提交"
class="btn-primary"
invisible="state != 'pending'"/>
<field name="state" widget="statusbar"
statusbar_visible="pending,submitted,graded"/>
</header>
<sheet>
<field name="name" invisible="1"/>
<group>
<field name="assignment_id" readonly="state in ('submitted', 'graded')"/>
<field name="student_id" invisible="1"/>
<field name="teacher_id" invisible="1"/>
</group>
<group string="老师布置的附件">
<field name="assignment_attachment"
filename="assignment_attachment_filename"
readonly="1"
string="附件"/>
<field name="assignment_attachment_filename" invisible="1"/>
</group>
<group>
<field name="due_date" readonly="1"/>
<field name="submit_time" readonly="1"/>
</group>
<group groups="college.group_teacher">
<field name="score"/>
</group>
<group>
<field name="content" readonly="state in ('submitted', 'graded')"/>
</group>
<group>
<field name="attachment" filename="attachment_filename" readonly="state in ('submitted', 'graded')"/>
<field name="attachment_filename" invisible="1"/>
</group>
<group groups="college.group_teacher">
<field name="feedback"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="yuthon_school_assignment_submission_view_search" model="ir.ui.view">
<field name="name">yuthon.school.assignment.submission.search</field>
<field name="model">yuthon.school.assignment.submission</field>
<field name="arch" type="xml">
<search>
<field name="assignment_id" string="作业"/>
<field name="student_id" string="学员"/>
<filter name="my_submissions" string="我的提交"
domain="[('student_id.user_id', '=', uid)]"/>
<filter name="pending" string="待提交"
domain="[('state', '=', 'pending')]"/>
<filter name="submitted" string="待批改"
domain="[('state', '=', 'submitted')]"/>
<filter name="graded" string="已批改"
domain="[('state', '=', 'graded')]"/>
<group expand="0" string="分组">
<filter name="group_assignment" string="作业"
context="{'group_by': 'assignment_id'}"/>
<filter name="group_state" string="状态"
context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
</odoo>
+44
View File
@@ -0,0 +1,44 @@
<odoo>
<!-- ===================== 师生批量导入向导 ===================== -->
<record id="yuthon_school_import_wizard_view_form" model="ir.ui.view">
<field name="name">yuthon.school.import.wizard.form</field>
<field name="model">yuthon.school.import.wizard</field>
<field name="arch" type="xml">
<form string="导入师生">
<sheet>
<field name="note" widget="html" nolabel="1"/>
<group string="下载模板">
<div class="d-flex align-items-center"
style="padding: 12px 0;">
<button name="action_download_template"
type="object" string="📥 下载模板"
class="btn-primary"/>
<span class="text-muted ms-2 small">
模板为 CSV 格式,可用 Excel / WPS 打开编辑
</span>
</div>
</group>
<group string="上传文件">
<field name="data_file" filename="file_name"/>
<field name="file_name" invisible="1"/>
</group>
</sheet>
<footer>
<button name="action_import" type="object"
string="开始导入" class="btn-primary"
invisible="not data_file"/>
<button string="取消" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- 导入动作 -->
<record id="action_yuthon_school_import_wizard" model="ir.actions.act_window">
<field name="name">导入师生</field>
<field name="res_model">yuthon.school.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>
+436
View File
@@ -0,0 +1,436 @@
<odoo>
<!-- ===================== 产品介绍(顶级菜单,置顶,位于「我的实训」上方) ===================== -->
<!-- 在当前页面的内容区以表单形式打开预置记录 about_page_product,
其 content 富文本字段即为 H5 页面内容(介绍本教育产品本身)。 -->
<record id="action_yuthon_product_intro" 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_product"/>
<field name="target">current</field>
</record>
<menuitem id="menu_yuthon_product_intro"
name="产品介绍"
web_icon="college,static/src/img/product_intro.png"
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"
groups="college.group_teacher,college.group_student,base.group_system"/>
<!-- ===================== 我的课程(统一入口,按用户角色路由) ===================== -->
<!-- 老师 / 管理员 → action_yuthon_school_course_my(老师端我的课程) -->
<!-- 学生 → action_yuthon_school_my_course_student(学生端我的课程) -->
<!-- 这样管理员只看到一个「我的课程」菜单,避免与学生端重复 -->
<record id="action_yuthon_school_course_router" model="ir.actions.server">
<field name="name">我的课程(按角色路由)</field>
<field name="model_id" search="[('model', '=', 'yuthon.school.progress')]"/>
<field name="state">code</field>
<field name="code"><![CDATA[
if env.user.has_group('college.group_teacher') or env.user.has_group('base.group_system'):
action = env.ref('college.action_yuthon_school_course_my').sudo().read()[0]
else:
action = env.ref('college.action_yuthon_school_my_course_student').sudo().read()[0]
]]></field>
</record>
<!-- ===================== 我的课程(学生端父菜单;老师/管理员入口见下方 menu_yuthon_course_my_teacher) ===================== -->
<menuitem id="menu_yuthon_course_my"
name="我的课程"
web_icon="college,static/src/img/my_course.png"
sequence="3"
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">
<field name="active" eval="False"/>
</record>
<!-- ===================== 「设置」菜单:仅超级管理员可见 ===================== -->
<record id="base.menu_management" model="ir.ui.menu">
<field name="active" eval="True"/>
<field name="groups_id" eval="[(6, 0, [ref('base.group_system')])]"/>
</record>
<!-- ===================== 原生「应用」菜单:师生可见 =====================
让老师、学生都能在左侧看到 Odoo 默认的「应用」菜单(模块列表)。
由于「应用」挂在「设置」下,这里单独给 base.menu_apps 追加
teacher/student 组,菜单会脱离隐藏的父菜单提升到顶层。
权限区分:
- 老师:group_teacher 隐含 base.group_erp_manager,可安装/升级/卸载模块
(模块按钮已放开到 group_erp_manager),但看不到「设置」菜单;
- 学生:仅 group_user,只读浏览,无法安装/升级/卸载。 -->
<record id="base.menu_apps" model="ir.ui.menu">
<field name="groups_id" eval="[
(6, 0, [
ref('base.group_system'),
ref('college.group_teacher'),
ref('college.group_student'),
])
]"/>
</record>
<!-- ===================== 隐藏学生的「请求访问」按钮 =====================
base_install_request 给所有非系统管理员都加了「Request Access」按钮,
学生点了会发起安装申请。这里把该按钮限定为仅管理员可见,让学生彻底只读。 -->
<record id="view_module_kanban_hide_request_access" model="ir.ui.view">
<field name="name">ir.module.module.kanban.hide.request.access</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base_install_request.ir_module_module_view_kanban"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_open_install_request']" position="attributes">
<attribute name="groups">base.group_system</attribute>
</xpath>
</field>
</record>
<!-- ===================== 模块按钮放开给「访问权限」组 =====================
原生模块安装/升级/卸载按钮 groups="base.group_system",导致只有设置组
(管理员)可见。为了让老师(group_erp_manager)能安装模块、又不用进
「设置」,把这些按钮的 groups 从 group_system 改为 group_erp_manager。
老师属于 group_erp_manager,可见可点;学生只属于 group_user,仍不可见。 -->
<!-- 看板视图(应用列表卡片) -->
<record id="view_module_kanban_teacher_install" model="ir.ui.view">
<field name="name">ir.module.module.kanban.teacher.install</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base.module_view_kanban"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='button_immediate_install']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//a[@name='button_immediate_upgrade']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//a[@name='button_uninstall_wizard']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_uninstall_cancel']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_install_cancel']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
</field>
</record>
<!-- 表单视图(模块详情页) -->
<record id="view_module_form_teacher_install" model="ir.ui.view">
<field name="name">ir.module.module.form.teacher.install</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base.module_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='button_immediate_install']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_immediate_upgrade']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_uninstall_wizard']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_uninstall_cancel']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_upgrade_cancel']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
<xpath expr="//button[@name='button_install_cancel']" position="attributes">
<attribute name="groups">base.group_erp_manager</attribute>
</xpath>
</field>
</record>
<!-- ===================== 课程管理(直接指向全部课程的 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"/>
<!-- ===================== 考试(与课程管理同级,顶级菜单,无 parent 才能带 web_icon) ===================== -->
<menuitem id="menu_yuthon_exam_root"
name="考试"
web_icon="college,static/src/img/exam.png"
sequence="15"/>
<menuitem id="menu_yuthon_exam_question"
name="题库"
parent="menu_yuthon_exam_root"
action="action_yuthon_school_exam_question"
sequence="10"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_exam_manage"
name="考试管理"
parent="menu_yuthon_exam_root"
action="action_yuthon_school_exam"
sequence="20"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_exam_grade"
name="评阅试卷"
parent="menu_yuthon_exam_root"
action="action_yuthon_school_exam_paper_grade"
sequence="30"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_exam_result"
name="考试结果"
parent="menu_yuthon_exam_root"
action="action_yuthon_school_exam_result"
sequence="40"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_exam_my"
name="我的考试"
parent="menu_yuthon_exam_root"
action="action_yuthon_school_exam_paper_student"
sequence="50"
groups="college.group_student"/>
<!-- 课程学习:原「我的学习」迁移为学生端「我的课程」的子菜单 -->
<menuitem id="menu_yuthon_course_study"
name="课程学习"
parent="menu_yuthon_course_my"
action="action_yuthon_school_course_study"
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="班级管理"
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_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_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_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,base.group_system"/>
<!-- 作业习题 -->
<menuitem id="menu_yuthon_homework"
name="作业习题"
parent="menu_yuthon_education_root"
sequence="30"/>
<!-- 作业布置:仅老师 -->
<menuitem id="menu_yuthon_homework_assignment"
name="作业布置"
parent="menu_yuthon_homework"
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_teacher,base.group_system"/>
<!-- 作业批改:仅老师(无提交入口) -->
<menuitem id="menu_yuthon_homework_grade"
name="作业批改"
parent="menu_yuthon_homework"
action="action_yuthon_school_assignment_submission"
sequence="30"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_class_notes"
name="随堂笔记"
parent="menu_yuthon_homework"
action="action_yuthon_school_class_notes"
sequence="40"
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 -->
<record id="action_yuthon_about_system" 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_system"/>
<field name="target">current</field>
</record>
<!-- 操作手册:打开预置记录 about_page_operation -->
<record id="action_yuthon_about_operation" 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_operation"/>
<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>
<field name="res_model">yuthon.school.course.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- ===================== 关于(顶级菜单,排在最后) ===================== -->
<menuitem id="menu_yuthon_about"
name="关于"
web_icon="college,static/src/img/about.png"
sequence="99"
groups="college.group_teacher,college.group_student,base.group_system"/>
<menuitem id="menu_yuthon_about_system"
name="系统说明"
parent="menu_yuthon_about"
action="action_yuthon_about_system"
sequence="10"/>
<menuitem id="menu_yuthon_about_operation"
name="操作手册"
parent="menu_yuthon_about"
action="action_yuthon_about_operation"
sequence="20"/>
<menuitem id="menu_yuthon_import_wizard"
name="导入师生"
parent="menu_yuthon_org_staff"
action="action_yuthon_school_import_wizard"
sequence="40"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_course_import_wizard"
name="课程导入"
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>
+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===================== 模块详情表单:增强「模块信息」 ===================== -->
<record id="ir_module_module_form_info" model="ir.ui.view">
<field name="name">ir.module.module.form.module.info</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base.module_form"/>
<field name="arch" type="xml">
<!-- 隐藏模块作者的 website 外链 -->
<field name="website" position="attributes">
<attribute name="invisible">1</attribute>
</field>
<!-- 去掉表单头部的 odoo.com 定价外链 -->
<xpath expr="//a[contains(@href,'odoo.com/pricing')]" position="replace"/>
<!-- 在头部之后插入「介绍 / 功能 / 知识点」三个页签 -->
<xpath expr="//div[hasclass('clearfix')]" position="after">
<notebook>
<page string="介绍" name="introduction">
<group string="模块介绍" colspan="2">
<field name="introduction" readonly="1" nolabel="1" class="oe_styling_v8"/>
</group>
</page>
<page string="功能" name="features">
<group string="功能亮点" colspan="2">
<field name="features" readonly="1" nolabel="1" class="oe_styling_v8"/>
</group>
</page>
<page string="知识点" name="knowledge_points">
<group string="核心知识点" colspan="2">
<field name="knowledge_points" readonly="1" nolabel="1" class="oe_styling_v8"/>
</group>
</page>
</notebook>
</xpath>
</field>
</record>
<!-- ===================== 应用列表:去掉 website 列 ===================== -->
<record id="ir_module_module_list_info" model="ir.ui.view">
<field name="name">ir.module.module.list.module.info</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base.module_tree"/>
<field name="arch" type="xml">
<field name="website" position="attributes">
<attribute name="column_invisible">1</attribute>
</field>
</field>
</record>
<!-- ===================== 隐藏「应用」菜单下的外部商店入口 ===================== -->
<record id="base.menu_third_party" model="ir.ui.menu">
<field name="active" eval="False"/>
</record>
<record id="base.menu_theme_store" model="ir.ui.menu">
<field name="active" eval="False"/>
</record>
<record id="base.theme_store" model="ir.ui.menu">
<field name="active" eval="False"/>
</record>
</odoo>
+99
View File
@@ -0,0 +1,99 @@
<odoo>
<!-- ===================== 老师 ===================== -->
<record id="action_yuthon_school_teacher" model="ir.actions.act_window">
<field name="name">老师</field>
<field name="res_model">yuthon.school.teacher</field>
<field name="view_mode">list,form</field>
</record>
<record id="yuthon_school_teacher_view_list" model="ir.ui.view">
<field name="name">yuthon.school.teacher.list</field>
<field name="model">yuthon.school.teacher</field>
<field name="arch" type="xml">
<list string="老师">
<field name="name"/>
<field name="teacher_no"/>
<field name="title"/>
<field name="user_id"/>
<field name="phone"/>
</list>
</field>
</record>
<record id="yuthon_school_teacher_view_form" model="ir.ui.view">
<field name="name">yuthon.school.teacher.form</field>
<field name="model">yuthon.school.teacher</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="user_id"/>
<field name="teacher_no"/>
<field name="title"/>
</group>
<group>
<field name="phone"/>
<field name="email"/>
</group>
<notebook>
<page string="担任班主任的班级">
<field name="class_ids">
<list>
<field name="name"/>
<field name="student_count"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ===================== 班级 ===================== -->
<record id="action_yuthon_school_class" model="ir.actions.act_window">
<field name="name">班级</field>
<field name="res_model">yuthon.school.class</field>
<field name="view_mode">list,form</field>
</record>
<record id="yuthon_school_class_view_list" model="ir.ui.view">
<field name="name">yuthon.school.class.list</field>
<field name="model">yuthon.school.class</field>
<field name="arch" type="xml">
<list string="班级">
<field name="name"/>
<field name="head_teacher_id"/>
<field name="student_count"/>
</list>
</field>
</record>
<record id="yuthon_school_class_view_form" model="ir.ui.view">
<field name="name">yuthon.school.class.form</field>
<field name="model">yuthon.school.class</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="head_teacher_id"/>
</group>
<notebook>
<page string="学生名单">
<field name="student_ids">
<list>
<field name="name"/>
<field name="student_number"/>
<field name="gender"/>
<field name="user_id"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
</odoo>
+197
View File
@@ -0,0 +1,197 @@
<odoo>
<!-- ===================== 学习进展:动作(老师用,无限制) ===================== -->
<record id="action_yuthon_school_progress" model="ir.actions.act_window">
<field name="name">学习进展</field>
<field name="res_model">yuthon.school.progress</field>
<field name="view_mode">kanban,list,form</field>
</record>
<!-- ===================== 学习进展:统一入口(按角色动态路由) ===================== -->
<record id="action_yuthon_school_progress_router" model="ir.actions.server">
<field name="name">学习进展</field>
<field name="model_id" ref="model_yuthon_school_progress"/>
<field name="state">code</field>
<field name="code"><![CDATA[
# 老师 / 管理员 -> 查看全部学习进展
# 学生 -> 仅查看自己已发布课程的学习进展
if env.user.has_group('college.group_teacher') or env.user.has_group('base.group_system'):
action = env.ref('college.action_yuthon_school_progress').sudo().read()[0]
else:
action = env.ref('college.action_yuthon_school_progress_student').sudo().read()[0]
]]></field>
</record>
<!-- ===================== 学习进展:列表 ===================== -->
<record id="yuthon_school_progress_view_list" model="ir.ui.view">
<field name="name">yuthon.school.progress.list</field>
<field name="model">yuthon.school.progress</field>
<field name="arch" type="xml">
<list string="学习进展">
<field name="course_id"/>
<field name="student_id" groups="college.group_teacher"/>
<field name="teacher_id"/>
<field name="chapter_done"/>
<field name="chapter_total"/>
<field name="progress_rate"/>
<field name="status"/>
<field name="last_study_date"/>
<field name="score"/>
</list>
</field>
</record>
<!-- ===================== 学习进展:表单(老师/管理员可编辑) ===================== -->
<record id="yuthon_school_progress_view_form" model="ir.ui.view">
<field name="name">yuthon.school.progress.form</field>
<field name="model">yuthon.school.progress</field>
<field name="groups_id" eval="[(4, ref('college.group_teacher')), (4, ref('base.group_system'))]"/>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="course_id"/>
<field name="student_id"/>
<field name="teacher_id"/>
</group>
<group>
<field name="chapter_done"/>
<field name="chapter_total"/>
<field name="progress_rate"/>
<field name="status"/>
<field name="last_study_date"/>
<field name="score"/>
</group>
<group>
<field name="note"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===================== 学习进展:表单(学生只读) ===================== -->
<record id="yuthon_school_progress_view_form_student" model="ir.ui.view">
<field name="name">yuthon.school.progress.form.student</field>
<field name="model">yuthon.school.progress</field>
<field name="priority">5</field>
<field name="groups_id" eval="[(4, ref('college.group_student'))]"/>
<field name="arch" type="xml">
<form edit="0" create="0" delete="0" duplicate="0">
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group string="课程信息">
<field name="course_id" readonly="1"/>
<field name="teacher_id" readonly="1"/>
</group>
<group string="学习进度">
<field name="chapter_done" readonly="1"/>
<field name="chapter_total" readonly="1"/>
<field name="progress_rate" readonly="1"/>
<field name="status" readonly="1"/>
</group>
</group>
<group>
<group>
<field name="last_study_date" readonly="1"/>
<field name="score" readonly="1"/>
</group>
</group>
<group string="备注">
<field name="note" readonly="1" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===================== 学习进展:搜索 ===================== -->
<record id="yuthon_school_progress_view_search" model="ir.ui.view">
<field name="name">yuthon.school.progress.search</field>
<field name="model">yuthon.school.progress</field>
<field name="arch" type="xml">
<search>
<field name="course_id" string="课程"/>
<field name="student_id" string="学员"/>
<field name="teacher_id" string="任课教师"/>
<filter name="not_started" string="未开始"
domain="[('status', '=', 'not_started')]"/>
<filter name="in_progress" string="进行中"
domain="[('status', '=', 'in_progress')]"/>
<filter name="completed" string="已完成"
domain="[('status', '=', 'completed')]"/>
<group expand="0" string="分组">
<filter name="group_course" string="课程"
context="{'group_by': 'course_id'}"/>
<filter name="group_status" string="状态"
context="{'group_by': 'status'}"/>
</group>
</search>
</field>
</record>
<!-- ===================== 学习进展:看板 ===================== -->
<record id="yuthon_school_progress_view_kanban" model="ir.ui.view">
<field name="name">yuthon.school.progress.kanban</field>
<field name="model">yuthon.school.progress</field>
<field name="arch" type="xml">
<kanban default_group_by="status" class="college-course-kanban college-kanban-progress o_kanban_mobile">
<field name="course_id"/>
<field name="student_id"/>
<field name="teacher_id"/>
<field name="chapter_done"/>
<field name="chapter_total"/>
<field name="progress_rate"/>
<field name="score"/>
<field name="status"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:8px; overflow:hidden;">
<div style="background:linear-gradient(135deg,#0e7490,#22d3ee); padding:10px 14px; display:flex; align-items:center; justify-content:space-between;">
<div class="college-kanban-title" style="color:#fff; font-size:14px;">
<field name="course_id"/>
</div>
<span class="college-kanban-badge college-kanban-badge-score">
<field name="score"/> 分
</span>
</div>
<div class="college-kanban-body" style="padding:10px 14px;">
<div class="college-kanban-meta small mb-2" groups="college.group_teacher">
<i class="fa fa-user text-muted"/> <field name="student_id"/>
</div>
<div class="mb-2">
<div style="display:flex; align-items:center; gap:8px;">
<span class="college-kanban-meta small text-muted">章节 <field name="chapter_done"/>/<field name="chapter_total"/></span>
<span style="flex:1;"/>
<span class="college-kanban-progress-rate small"><field name="progress_rate"/>%</span>
</div>
</div>
<div class="college-kanban-meta small text-muted">
<i class="fa fa-user-circle"/> <field name="teacher_id"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================== 学习进展:学生专用(仅看自己选课且已发布课程) ===================== -->
<!-- 注意:必须放在上方所有视图定义之后,否则加载时 ref() 找不到尚未注册的视图 xml_id -->
<record id="action_yuthon_school_progress_student" model="ir.actions.act_window">
<field name="name">学习进展</field>
<field name="res_model">yuthon.school.progress</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">['&amp;', ('student_id.user_id', '=', uid), ('course_id.state', '=', 'published')]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_progress_view_kanban')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_progress_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_progress_view_form_student')})]"/>
</record>
</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>
+69
View File
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===================== 实训模块专用看板(独立视图,不继承原生 Apps 看板) =====================
仅「实训模块」动作使用;原生 Apps 看板保持原样。
卡片只保留「模块信息」入口,去掉 Learn More 与 odoo.com 定价外链;
安装/升级/卸载按钮权限直接放开给老师组(base.group_erp_manager),
不依赖 menus.xml 对原生 base.module_view_kanban 的继承。 -->
<record id="shixun_module_kanban_view" model="ir.ui.view">
<field name="name">实训模块应用看板</field>
<field name="model">ir.module.module</field>
<field name="mode">primary</field>
<field name="arch" type="xml">
<kanban create="false" can_open="0" class="o_modules_kanban">
<field name="to_buy"/>
<field name="name"/>
<field name="state"/>
<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'"/>
<a type="open" class="dropdown-item">模块信息</a>
<a t-if="installed" name="button_immediate_upgrade" type="object" role="menuitem" class="dropdown-item" groups="base.group_erp_manager">升级</a>
<a t-if="installed" name="button_uninstall_wizard" type="object" role="menuitem" class="dropdown-item" groups="base.group_erp_manager">卸载</a>
</t>
<t t-name="card" class="flex-row align-items-center">
<aside>
<field name="icon" widget="image_url" options="{'size': [50, 50]}" alt="Icon"/>
<field t-if="record.icon_flag" name="icon_flag" class="oe_module_flag"/>
</aside>
<main class="me-4" t-att-title="record.shortdesc.value">
<field class="fw-bold fs-5" name="shortdesc"/>
<p class="text-muted small my-0 lh-sm">
<field groups="!base.group_no_one" name="summary"/>
<code groups="base.group_no_one"><field name="name"/></code>
</p>
<footer class="w-100 justify-content-between">
<button type="object" class="btn btn-primary btn-sm" name="button_immediate_install" invisible="state != 'uninstalled'" t-if="! record.to_buy.raw_value" groups="base.group_erp_manager">安装</button>
<div t-if="installed" class="d-flex align-items-center text-muted float-start">已安装</div>
<a type="edit" class="btn btn-secondary btn-sm float-end" role="button">模块信息</a>
<button invisible="state != 'to remove'" type="object" class="btn btn-sm btn-primary" name="button_uninstall_cancel" groups="base.group_erp_manager">取消卸载</button>
<button invisible="state != 'to install'" type="object" class="btn btn-sm btn-primary" name="button_install_cancel" groups="base.group_erp_manager">取消安装</button>
</footer>
</main>
</t>
</templates>
</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>
+49
View File
@@ -0,0 +1,49 @@
<odoo>
<record id="action_yuthon_school_student" model="ir.actions.act_window">
<field name="name">学员库</field>
<field name="res_model">yuthon.school.student</field>
<field name="view_mode">list,form</field>
<field name="target">current</field>
</record>
<record id="yuthon_school_student_view_list" model="ir.ui.view">
<field name="name">yuthon.school.student.list</field>
<field name="model">yuthon.school.student</field>
<field name="arch" type="xml">
<list string="学生">
<field name="name"/>
<field name="student_number"/>
<field name="gender"/>
<field name="class_id"/>
<field name="college"/>
<field name="user_id"/>
<field name="phone"/>
<field name="email"/>
</list>
</field>
</record>
<record id="yuthon_school_student_view_form" model="ir.ui.view">
<field name="name">yuthon.school.student.form</field>
<field name="model">yuthon.school.student</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="student_number"/>
<field name="gender"/>
<field name="user_id"/>
<field name="class_id"/>
<field name="college"/>
</group>
<group>
<field name="phone"/>
<field name="email"/>
<field name="note"/>
</group>
</sheet>
</form>
</field>
</record>
</odoo>
+80
View File
@@ -0,0 +1,80 @@
<odoo>
<!-- ===================== 我的实训:看板(占位) ===================== -->
<record id="yuthon_school_training_view_kanban" model="ir.ui.view">
<field name="name">yuthon.school.training.kanban</field>
<field name="model">yuthon.school.training</field>
<field name="arch" type="xml">
<kanban default_group_by="name" class="college-course-kanban college-kanban-training o_kanban_mobile">
<field name="name"/>
<field name="note"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click college-kanban-card"
style="padding:0; border-radius:10px; overflow:hidden;">
<!-- 顶帽:紫色渐变 -->
<div style="background:linear-gradient(135deg,#8B5CF6,#D946EF); padding:14px; text-align:center;">
<div style="font-size:28px; opacity:.25; color:#fff; margin-bottom:4px;">🏋️</div>
<div class="college-kanban-title" style="color:#fff; font-size:16px;">
<field name="name"/>
</div>
</div>
<!-- 主体 -->
<div class="college-kanban-body" style="padding:12px 14px;">
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<span class="college-kanban-badge college-kanban-badge-purple">
<i class="fa fa-graduation-cap"/> 实训中
</span>
<span class="college-kanban-badge college-kanban-badge-pink">
<i class="fa fa-clock-o"/> 进行中
</span>
</div>
<div class="college-kanban-meta text-muted small mt-2" style="line-height:1.4; max-height:40px; overflow:hidden;">
<field name="note"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================== 我的实训:列表 ===================== -->
<record id="yuthon_school_training_view_list" model="ir.ui.view">
<field name="name">yuthon.school.training.list</field>
<field name="model">yuthon.school.training</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="note"/>
</list>
</field>
</record>
<!-- ===================== 我的实训:表单 ===================== -->
<record id="yuthon_school_training_view_form" model="ir.ui.view">
<field name="name">yuthon.school.training.form</field>
<field name="model">yuthon.school.training</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="note"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===================== 我的实训:动作 ===================== -->
<record id="action_yuthon_school_training" model="ir.actions.act_window">
<field name="name">我的实训</field>
<field name="res_model">yuthon.school.training</field>
<field name="view_mode">kanban,list,form</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('yuthon_school_training_view_kanban')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('yuthon_school_training_view_list')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('yuthon_school_training_view_form')})]"/>
</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

Some files were not shown because too many files have changed in this diff Show More