Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
802b8efa39 | ||
|
|
87e8f0c636 | ||
|
|
8ea8b089af | ||
|
|
dfa665bbde | ||
|
|
8939e61094 | ||
|
|
0a96d0e868 | ||
|
|
ab62d0e79b | ||
|
|
d84c482c84 | ||
|
|
7b393414b3 | ||
|
|
fa5b2c649e | ||
|
|
27b17aeaa6 | ||
|
|
246326ed9c | ||
|
|
c84da2ed10 | ||
|
|
9d5a60af09 | ||
|
|
d73bd82769 | ||
|
|
d1a217f8cf | ||
|
|
0d6d3e0bb9 | ||
|
|
4cdbaf9799 | ||
|
|
84df154ce2 | ||
|
|
63c87c7e56 | ||
|
|
ba32af9e04 | ||
|
|
07c29e451d | ||
|
|
194594ffa5 | ||
|
|
2b89ca6cb5 | ||
|
|
013aa37ebc | ||
|
|
9f909e4833 | ||
|
|
544de24893 | ||
|
|
1b6ea810b2 | ||
|
|
f75aca9fd5 | ||
|
|
aa07c87bbb | ||
|
|
0bcbba4383 | ||
|
|
ad7bc5877d | ||
|
|
aa150974b5 | ||
|
|
2b186521ff | ||
|
|
c02a4c0f51 | ||
|
|
47dc1c428f | ||
|
|
5c8f8fa895 | ||
|
|
f5967ae13d | ||
|
|
285aeed475 | ||
|
|
40a9ed9bd1 | ||
|
|
c471fe95bc | ||
|
|
2627d3e8de | ||
|
|
5602975376 | ||
|
|
5c95a5852f | ||
|
|
89b67f0e7f | ||
|
|
fa061bf754 | ||
|
|
7601338f99 | ||
|
|
9d91137396 | ||
|
|
ed36f20beb | ||
|
|
70dece41ed | ||
|
|
102a8ca4e1 | ||
|
|
c3b994ca86 | ||
|
|
b044ba3367 | ||
|
|
148ba8b6c0 | ||
|
|
cd68aa8c72 | ||
|
|
c60acb1984 | ||
|
|
6bb6e7fc6d | ||
|
|
e5c26c260d | ||
|
|
52f3a92f6b | ||
|
|
4c03ef6a90 | ||
|
|
67aabfc2c1 | ||
|
|
b072b7f973 | ||
|
|
dc36d6f3c8 | ||
|
|
87f2003caa | ||
|
|
8a653407f2 | ||
|
|
d08668ca8a | ||
|
|
522562eaa0 | ||
|
|
ba80a569e9 | ||
|
|
6452d6b8fc | ||
|
|
9d3f94c966 | ||
|
|
7cdb9b6651 | ||
|
|
651a7d94ba | ||
|
|
10bc7f990e | ||
|
|
df06c81be0 | ||
|
|
6b166d1f34 | ||
|
|
2ba9f5297a | ||
|
|
923a0d7e03 | ||
|
|
64b0e18a74 | ||
|
|
e98be48f2c | ||
|
|
49d4fb1c5c | ||
|
|
0d2c9e890a | ||
|
|
809459e2eb | ||
|
|
ca105fc4ea | ||
|
|
19a294bd2d | ||
|
|
f42c3d13ee | ||
|
|
ca60dff018 | ||
|
|
5ff714f1d6 | ||
|
|
eb7c87fad1 | ||
|
|
9efb4bf916 | ||
|
|
6fcad70326 | ||
|
|
1dd79a0411 | ||
|
|
ccdb5f7111 | ||
|
|
068ff4c571 | ||
|
|
7adc6fc3c0 | ||
|
|
54eb9c6ac9 | ||
|
|
ceb085c717 | ||
|
|
8707f6d14d | ||
|
|
2733d863be | ||
|
|
a2bd6a5c78 | ||
|
|
37d8d3facc | ||
|
|
dc8cea7659 | ||
|
|
dc250f2ae2 | ||
|
|
6b0e8fe493 | ||
|
|
75c22679aa | ||
|
|
5c9cb7f8ff | ||
|
|
861144f615 | ||
|
|
a7165e0ce6 | ||
|
|
46c6e5af14 | ||
|
|
3a26822e71 | ||
|
|
eded38735a | ||
|
|
f453820775 | ||
|
|
6efe2130b8 | ||
|
|
72e9b84d56 | ||
|
|
1a0d1c57e1 | ||
|
|
67c61b7cd1 | ||
|
|
8f6a9b3381 | ||
|
|
59645cbed0 | ||
|
|
84c8505cfb | ||
|
|
160c3dcb3a | ||
|
|
fe3585d589 | ||
|
|
176da7546b | ||
|
|
cfe31fc044 | ||
|
|
0d14744c15 | ||
|
|
524aacbacb | ||
|
|
26c311d913 | ||
|
|
36b6cb1e0e |
@@ -0,0 +1,3 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import patch_wkhtmltopdf # noqa: F401 应用 wkhtmltopdf 命令行猴子补丁
|
||||
@@ -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',
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import exam_controller
|
||||
from . import paper_pdf_reportlab
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1,395 @@
|
||||
"""使用 reportlab 直接生成学生答卷 PDF,绕过 wkhtmltopdf 中文乱码问题。
|
||||
|
||||
Windows 环境的 wkhtmltopdf 0.12.6 (with patched qt) 无法正确加载 @font-face 或系统字体,
|
||||
导致 PDF 中文渲染为乱码/方框。本模块使用 reportlab 在 Python 端直接绘制 PDF,
|
||||
并注册 Windows 自带中文字体(SimHei / Microsoft YaHei),从根本上保证中文正常显示。
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import (
|
||||
Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, KeepTogether,
|
||||
PageBreak,
|
||||
)
|
||||
|
||||
|
||||
# 尝试注册系统中文字体,按优先级尝试多个路径
|
||||
_FONT_PATHS = [
|
||||
('SimHei', r'C:\Windows\Fonts\simhei.ttf'),
|
||||
('MicrosoftYaHei', r'C:\Windows\Fonts\msyh.ttc'),
|
||||
('SimSun', r'C:\Windows\Fonts\simsun.ttc'),
|
||||
]
|
||||
|
||||
_REGISTERED_FONTS = {}
|
||||
|
||||
|
||||
def _register_chinese_fonts():
|
||||
"""注册系统中文字体;返回成功注册的字体名列表。"""
|
||||
if _REGISTERED_FONTS:
|
||||
return list(_REGISTERED_FONTS.keys())
|
||||
for name, path in _FONT_PATHS:
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont(name, path))
|
||||
_REGISTERED_FONTS[name] = path
|
||||
except Exception:
|
||||
pass
|
||||
return list(_REGISTERED_FONTS.keys())
|
||||
|
||||
|
||||
def _get_font_name(preferred='SimHei'):
|
||||
"""返回可用的中文字体名,优先使用指定字体。"""
|
||||
fonts = _register_chinese_fonts()
|
||||
if preferred in fonts:
|
||||
return preferred
|
||||
return fonts[0] if fonts else 'Helvetica'
|
||||
|
||||
|
||||
def _strip_html(html):
|
||||
"""将 HTML 片段转为纯文本(保留换行/空格的可读性)。"""
|
||||
if not html:
|
||||
return ''
|
||||
text = str(html)
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
text = re.sub(r'</(p|div|br|li|h[1-6]|tr)>', '\n', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'<li>', '• ', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'<[^>]+>', ' ', text)
|
||||
lines = [re.sub(r'\s+', ' ', line).strip() for line in text.split('\n')]
|
||||
return '\n'.join(line for line in lines if line)
|
||||
|
||||
|
||||
def _escape_xml(text):
|
||||
"""为 reportlab Paragraph 转义 XML 特殊字符。"""
|
||||
if not text:
|
||||
return ''
|
||||
return text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
def _p(text, style):
|
||||
"""生成 Paragraph,并转义文本中的 XML 字符。"""
|
||||
return Paragraph(_escape_xml(text), style)
|
||||
|
||||
|
||||
def _build_styles():
|
||||
"""构建 PDF 中使用的段落样式。"""
|
||||
font_name = _get_font_name()
|
||||
base = getSampleStyleSheet()
|
||||
styles = {}
|
||||
styles['title'] = ParagraphStyle(
|
||||
'title', parent=base['Title'],
|
||||
fontName=font_name, fontSize=18, leading=26,
|
||||
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
|
||||
spaceAfter=6,
|
||||
)
|
||||
styles['subtitle'] = ParagraphStyle(
|
||||
'subtitle', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
|
||||
spaceAfter=12,
|
||||
)
|
||||
styles['score_big'] = ParagraphStyle(
|
||||
'score_big', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=26, leading=32,
|
||||
textColor=colors.HexColor('#2563eb'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['score_label'] = ParagraphStyle(
|
||||
'score_label', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=14,
|
||||
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['score_pass'] = ParagraphStyle(
|
||||
'score_pass', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=14,
|
||||
textColor=colors.HexColor('#15803d'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['score_fail'] = ParagraphStyle(
|
||||
'score_fail', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=14,
|
||||
textColor=colors.HexColor('#b91c1c'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['section_title'] = ParagraphStyle(
|
||||
'section_title', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=12, leading=18,
|
||||
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
|
||||
spaceBefore=14, spaceAfter=8,
|
||||
)
|
||||
styles['question_title'] = ParagraphStyle(
|
||||
'question_title', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
|
||||
spaceAfter=4,
|
||||
)
|
||||
styles['body'] = ParagraphStyle(
|
||||
'body', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#374151'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['body_green'] = ParagraphStyle(
|
||||
'body_green', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#15803d'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['body_red'] = ParagraphStyle(
|
||||
'body_red', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#b91c1c'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['body_gray'] = ParagraphStyle(
|
||||
'body_gray', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['body_orange'] = ParagraphStyle(
|
||||
'body_orange', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=10, leading=16,
|
||||
textColor=colors.HexColor('#b45309'), alignment=TA_LEFT,
|
||||
)
|
||||
styles['tag'] = ParagraphStyle(
|
||||
'tag', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=9, leading=12,
|
||||
textColor=colors.HexColor('#3730a3'), alignment=TA_CENTER,
|
||||
)
|
||||
styles['tag_green'] = ParagraphStyle(
|
||||
'tag_green', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=9, leading=12,
|
||||
textColor=colors.HexColor('#15803d'), alignment=TA_CENTER,
|
||||
)
|
||||
styles['tag_red'] = ParagraphStyle(
|
||||
'tag_red', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=9, leading=12,
|
||||
textColor=colors.HexColor('#b91c1c'), alignment=TA_CENTER,
|
||||
)
|
||||
styles['footer'] = ParagraphStyle(
|
||||
'footer', parent=base['Normal'],
|
||||
fontName=font_name, fontSize=9, leading=12,
|
||||
textColor=colors.HexColor('#9ca3af'), alignment=TA_RIGHT,
|
||||
)
|
||||
return styles
|
||||
|
||||
|
||||
def _question_type_label(qtype):
|
||||
return {
|
||||
'single_choice': '单选题',
|
||||
'multiple_choice': '多选题',
|
||||
'true_false': '判断题',
|
||||
'fill_blank': '填空题',
|
||||
'essay': '简答题',
|
||||
}.get(qtype, qtype)
|
||||
|
||||
|
||||
def _build_answer_block(ans, styles, question_number=None):
|
||||
"""为一道题生成 reportlab 元素列表。
|
||||
|
||||
:param question_number: 在试卷中显示的题号;默认使用题目本身的 sequence。
|
||||
"""
|
||||
story = []
|
||||
q = ans.question_id
|
||||
|
||||
# 题头:题号 + 题型 + 分数 + 对错
|
||||
score_text = f'{ans.score or 0} / {ans.max_score or 0} 分'
|
||||
status_style = styles['tag_green'] if ans.is_correct else styles['tag_red']
|
||||
status_text = ''
|
||||
if ans.question_type != 'essay':
|
||||
status_text = '正确' if ans.is_correct else '错误'
|
||||
|
||||
display_number = question_number if question_number is not None else (ans.sequence or 0)
|
||||
header_data = [
|
||||
[
|
||||
_p(f'第 {display_number} 题', styles['question_title']),
|
||||
_p(_question_type_label(ans.question_type), styles['tag']),
|
||||
_p(score_text, styles['body']),
|
||||
],
|
||||
]
|
||||
if status_text:
|
||||
header_data[0].append(_p(status_text, status_style))
|
||||
|
||||
col_count = len(header_data[0])
|
||||
if col_count == 3:
|
||||
widths = [None, 35 * mm, 35 * mm]
|
||||
else:
|
||||
widths = [None, 35 * mm, 35 * mm, 25 * mm]
|
||||
|
||||
header_table = Table(header_data, colWidths=widths)
|
||||
header_table.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 0),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
||||
]))
|
||||
story.append(header_table)
|
||||
|
||||
# 题干
|
||||
story.append(_p(_strip_html(ans.question_text), styles['body']))
|
||||
|
||||
# 选择题选项
|
||||
if ans.question_type in ('single_choice', 'multiple_choice') and q.option_ids:
|
||||
for opt in q.option_ids.sorted(lambda o: (o.sequence, o.id)):
|
||||
badges = []
|
||||
if opt.is_correct:
|
||||
badges.append('正确')
|
||||
if opt in ans.selected_option_ids:
|
||||
badges.append('你的选择')
|
||||
badge_text = f' [{" · ".join(badges)}]' if badges else ''
|
||||
option_text = f'{opt.label}. {_strip_html(opt.content)}{badge_text}'
|
||||
story.append(_p(option_text, styles['body']))
|
||||
|
||||
# 学生作答
|
||||
if ans.question_type == 'essay':
|
||||
answer_text = _strip_html(ans.answer_text) or '未作答'
|
||||
else:
|
||||
answer_text = ans.answer_display or '未作答'
|
||||
story.append(_p(f'学生作答:{answer_text}', styles['body']))
|
||||
|
||||
# 正确答案 / 参考答案
|
||||
if ans.question_type == 'single_choice':
|
||||
correct_labels = ', '.join(opt.label for opt in q._get_correct_options())
|
||||
story.append(_p(f'正确答案:{correct_labels}', styles['body_green']))
|
||||
elif ans.question_type == 'multiple_choice':
|
||||
correct_labels = ', '.join(opt.label for opt in q._get_correct_options())
|
||||
story.append(_p(f'正确答案:{correct_labels}', styles['body_green']))
|
||||
elif ans.question_type == 'true_false':
|
||||
text = '正确' if q.correct_answer == 'True' else '错误'
|
||||
story.append(_p(f'正确答案:{text}', styles['body_green']))
|
||||
elif ans.question_type == 'fill_blank':
|
||||
story.append(_p(f'正确答案:{q.correct_answer or "-"}', styles['body_green']))
|
||||
elif ans.question_type == 'essay':
|
||||
ref = _strip_html(q.reference_answer) or '-'
|
||||
story.append(_p(f'参考答案:{ref}', styles['body_gray']))
|
||||
|
||||
# 本题评语
|
||||
if ans.teacher_comment:
|
||||
story.append(_p(f'评语:{ans.teacher_comment}', styles['body_orange']))
|
||||
|
||||
return story
|
||||
|
||||
|
||||
def _build_paper_story(paper, styles):
|
||||
"""构建单份答卷的 PDF 流元素列表(不含 doc.build)。"""
|
||||
story = []
|
||||
|
||||
# 表头:考试名 + 学员信息
|
||||
story.append(_p(paper.exam_id.name or '考试', styles['title']))
|
||||
|
||||
info_parts = [f"学员:{paper.student_id.name or '-'}"]
|
||||
if paper.course_id:
|
||||
info_parts.append(f"课程:{paper.course_id.name}")
|
||||
if paper.chapter_ids:
|
||||
info_parts.append(f"章节:{', '.join(paper.chapter_ids.mapped('name'))}")
|
||||
state_map = dict(paper._fields['state'].selection)
|
||||
info_parts.append(f"状态:{state_map.get(paper.state, paper.state)}")
|
||||
if paper.submit_time:
|
||||
info_parts.append(f"交卷:{paper.submit_time.strftime('%Y-%m-%d %H:%M')}")
|
||||
story.append(_p(' | '.join(info_parts), styles['subtitle']))
|
||||
|
||||
# 得分概览(仅显示分数数字 + 及格状态 + 及格线)
|
||||
pass_style = styles['score_pass'] if paper.is_passed else styles['score_fail']
|
||||
pass_text = '及格' if paper.is_passed else '不及格'
|
||||
score_row = [
|
||||
_p(f"{paper.score or 0} 分", styles['score_big']),
|
||||
_p(pass_text, pass_style),
|
||||
_p(f"及格分:{paper.pass_score or 0} 分", styles['score_label']),
|
||||
]
|
||||
score_table = Table([score_row], colWidths=[70 * mm, 35 * mm, 70 * mm])
|
||||
score_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f8fafc')),
|
||||
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e5e7eb')),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 12),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 12),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 10),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 10),
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
]))
|
||||
story.append(score_table)
|
||||
story.append(Spacer(1, 8 * mm))
|
||||
|
||||
# 答题详情
|
||||
story.append(_p('答题详情', styles['section_title']))
|
||||
answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id))
|
||||
for idx, ans in enumerate(answers):
|
||||
block = _build_answer_block(ans, styles, question_number=idx + 1)
|
||||
block_table = Table([[element] for element in block], colWidths=[170 * mm])
|
||||
block_table.setStyle(TableStyle([
|
||||
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e5e7eb')),
|
||||
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#ffffff')),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 10),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 10),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 8),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||||
]))
|
||||
story.append(KeepTogether(block_table))
|
||||
story.append(Spacer(1, 4 * mm))
|
||||
|
||||
# 整卷评语
|
||||
if paper.teacher_comment:
|
||||
story.append(Spacer(1, 6 * mm))
|
||||
comment_table = Table(
|
||||
[[_p(f"整卷评语:{paper.teacher_comment}", styles['body_orange'])]],
|
||||
colWidths=[170 * mm]
|
||||
)
|
||||
comment_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#fffbeb')),
|
||||
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#fde68a')),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 10),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 10),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 8),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||||
]))
|
||||
story.append(comment_table)
|
||||
|
||||
# 页脚生成时间
|
||||
story.append(Spacer(1, 10 * mm))
|
||||
story.append(_p(f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}", styles['footer']))
|
||||
|
||||
return story
|
||||
|
||||
|
||||
def render_paper_pdf(paper):
|
||||
"""渲染指定答卷的 PDF 字节流。"""
|
||||
_register_chinese_fonts()
|
||||
styles = _build_styles()
|
||||
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer, pagesize=A4,
|
||||
rightMargin=20 * mm, leftMargin=20 * mm,
|
||||
topMargin=20 * mm, bottomMargin=18 * mm,
|
||||
)
|
||||
|
||||
story = _build_paper_story(paper, styles)
|
||||
doc.build(story)
|
||||
pdf_bytes = buffer.getvalue()
|
||||
buffer.close()
|
||||
return pdf_bytes
|
||||
|
||||
|
||||
def render_papers_pdf(papers):
|
||||
"""将多份答卷合并渲染为一个 PDF 字节流(每份之间自动分页)。"""
|
||||
_register_chinese_fonts()
|
||||
styles = _build_styles()
|
||||
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer, pagesize=A4,
|
||||
rightMargin=20 * mm, leftMargin=20 * mm,
|
||||
topMargin=20 * mm, bottomMargin=18 * mm,
|
||||
)
|
||||
|
||||
story = []
|
||||
papers_list = list(papers)
|
||||
for idx, paper in enumerate(papers_list):
|
||||
story.extend(_build_paper_story(paper, styles))
|
||||
if idx < len(papers_list) - 1:
|
||||
story.append(PageBreak())
|
||||
|
||||
doc.build(story)
|
||||
pdf_bytes = buffer.getvalue()
|
||||
buffer.close()
|
||||
return pdf_bytes
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- 为 school 自建模块填充「功能亮点 / 模块介绍」文案(幂等,每次升级执行) -->
|
||||
<function model="ir.module.module" name="populate_module_info"/>
|
||||
</odoo>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 "维修"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
|
||||
@@ -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'}
|
||||
@@ -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()
|
||||
@@ -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'})
|
||||
@@ -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
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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 '')
|
||||
@@ -0,0 +1,374 @@
|
||||
"""题库批量导入向导 —— 按题型下载 CSV 模板,上传后批量创建题库题目。
|
||||
|
||||
每种题型对应一份专属模板:
|
||||
- 单选 / 多选:题干 + 选项 A~E + 正确答案(单选填 A;多选填 A,B)
|
||||
- 判断:题干 + 正确答案(正确 / 错误)
|
||||
- 填空:题干 + 正确答案(标准答案,多空用 | 分隔)
|
||||
- 简答:题干 + 参考答案
|
||||
|
||||
上传后按「所属课程 / 所属章节」定位,自动创建 yuthon.school.exam.question 与对应选项,
|
||||
出题人默认为当前用户,可见范围默认私有(可在模板中覆盖)。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# 题型 → 模板列与示例行
|
||||
_TEMPLATE_SPECS = {
|
||||
'single_choice': {
|
||||
'label': '单选题',
|
||||
'cols': [
|
||||
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
|
||||
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
|
||||
'答案说明(可选)',
|
||||
'选项A', '选项B', '选项C', '选项D', '选项E(可选)',
|
||||
'正确答案(填A/B/C/D/E)',
|
||||
],
|
||||
'sample': [
|
||||
'第一章练习-单选', '下列选项中正确的是?', 'Odoo 18 框架入门', '第1章 基础',
|
||||
'中等', '2', '私有', '',
|
||||
'选项一', '选项二', '选项三', '选项四', '',
|
||||
'A',
|
||||
],
|
||||
},
|
||||
'multiple_choice': {
|
||||
'label': '多选题',
|
||||
'cols': [
|
||||
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
|
||||
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
|
||||
'答案说明(可选)',
|
||||
'选项A', '选项B', '选项C', '选项D', '选项E(可选)',
|
||||
'正确答案(填 A,B 逗号分隔)',
|
||||
],
|
||||
'sample': [
|
||||
'第一章练习-多选', '以下属于可迭代对象的是?', 'Odoo 18 框架入门', '第1章 基础',
|
||||
'中等', '3', '私有', '',
|
||||
'列表', '字典', '字符串', '整数', '',
|
||||
'A,B,C',
|
||||
],
|
||||
},
|
||||
'true_false': {
|
||||
'label': '判断题',
|
||||
'cols': [
|
||||
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
|
||||
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
|
||||
'答案说明(可选)',
|
||||
'正确答案(正确/错误)',
|
||||
],
|
||||
'sample': [
|
||||
'第一章练习-判断', 'Odoo 是一个开源 ERP 框架。', 'Odoo 18 框架入门', '第1章 基础',
|
||||
'简单', '1', '私有', '',
|
||||
'正确',
|
||||
],
|
||||
},
|
||||
'fill_blank': {
|
||||
'label': '填空题',
|
||||
'cols': [
|
||||
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
|
||||
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
|
||||
'答案说明(可选)',
|
||||
'正确答案(标准答案,多个空用 | 分隔)',
|
||||
],
|
||||
'sample': [
|
||||
'第一章练习-填空', 'Odoo 的 ORM 全称是 ____。', 'Odoo 18 框架入门', '第1章 基础',
|
||||
'简单', '1', '私有', '',
|
||||
'Object Relational Mapping',
|
||||
],
|
||||
},
|
||||
'essay': {
|
||||
'label': '简答题',
|
||||
'cols': [
|
||||
'题目标题(可选)', '题干', '所属课程', '所属章节(可选)',
|
||||
'难度(简单/中等/困难,可选)', '分值', '可见范围(公开/私有,可选,默认私有)',
|
||||
'答案说明(可选)',
|
||||
'参考答案(可选)',
|
||||
],
|
||||
'sample': [
|
||||
'第一章练习-简答', '简述 Odoo 中 Model 与 Recordset 的关系。', 'Odoo 18 框架入门', '第1章 基础',
|
||||
'困难', '10', '私有', '',
|
||||
'Model 定义数据结构与行为;Recordset 是某模型的记录集合,支持批量操作。',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
_DIFFICULTY_MAP = {
|
||||
'简单': 'easy', '中等': 'medium', '困难': 'hard',
|
||||
'easy': 'easy', 'medium': 'medium', 'hard': 'hard',
|
||||
}
|
||||
|
||||
_VISIBILITY_MAP = {
|
||||
'公开': 'public', '私有': 'private',
|
||||
'public': 'public', 'private': 'private',
|
||||
}
|
||||
|
||||
|
||||
class QuestionImportWizard(models.TransientModel):
|
||||
"""题库批量导入向导。"""
|
||||
|
||||
_name = 'yuthon.school.question.import.wizard'
|
||||
_description = '题库批量导入'
|
||||
|
||||
question_type = fields.Selection([
|
||||
('single_choice', '单选题'),
|
||||
('multiple_choice', '多选题'),
|
||||
('true_false', '判断题'),
|
||||
('fill_blank', '填空题'),
|
||||
('essay', '简答题'),
|
||||
], string='导入题型', required=True,
|
||||
help='选择要导入的题目类型,系统会给出对应的模板。')
|
||||
data_file = fields.Binary(string='上传文件')
|
||||
file_name = fields.Char(string='文件名')
|
||||
note = fields.Html(
|
||||
string='操作说明', readonly=True,
|
||||
default=lambda self: _(
|
||||
'<p><b>使用步骤:</b></p>'
|
||||
'<ol>'
|
||||
'<li>先在上方选择「导入题型」;</li>'
|
||||
'<li>点击「下载模板」获取该题型专属的 CSV 模板(含一列示例数据);</li>'
|
||||
'<li>用 Excel / WPS 打开,按列填写:<b>题干</b>、<b>所属课程</b> 为必填;'
|
||||
'选择题填选项 A~E 并在「正确答案」标出正确项(多选用逗号分隔);'
|
||||
'判断填 正确/错误;填空填标准答案;简答填参考答案;</li>'
|
||||
'<li>保存为 CSV(建议 UTF-8)后上传,点击「开始导入」;</li>'
|
||||
'<li>题目会直接写入题库,出题人默认为当前登录老师,可见范围默认私有。</li>'
|
||||
'</ol>'
|
||||
),
|
||||
)
|
||||
|
||||
# ─────────────────── 下载模板 ───────────────────
|
||||
|
||||
def action_download_template(self):
|
||||
self.ensure_one()
|
||||
if not self.question_type:
|
||||
raise UserError(_('请先选择要导入的题型。'))
|
||||
spec = _TEMPLATE_SPECS[self.question_type]
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(spec['cols'])
|
||||
writer.writerow(spec['sample'])
|
||||
|
||||
attachment = self.env['ir.attachment'].sudo().create({
|
||||
'name': '题库导入模板_%s.csv' % spec['label'],
|
||||
'datas': base64.b64encode(buf.getvalue().encode('utf-8-sig')),
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'type': 'binary',
|
||||
'mimetype': 'text/csv',
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=true' % attachment.id,
|
||||
'target': 'self',
|
||||
}
|
||||
|
||||
# ─────────────────── 导入 ───────────────────
|
||||
|
||||
def action_import(self):
|
||||
self.ensure_one()
|
||||
if not self.question_type:
|
||||
raise UserError(_('请先选择要导入的题型。'))
|
||||
if not self.data_file:
|
||||
raise UserError(_('请先上传填写好的文件。'))
|
||||
|
||||
content = base64.b64decode(self.data_file)
|
||||
for encoding in ('utf-8-sig', 'utf-8', 'gbk', 'gb2312'):
|
||||
try:
|
||||
text = content.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
else:
|
||||
raise UserError(_('无法识别文件编码,请使用 UTF-8 或 GBK 编码的 CSV。'))
|
||||
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
if not reader.fieldnames:
|
||||
raise UserError(_('文件为空或格式不正确。'))
|
||||
|
||||
col_map = self._normalize_columns(reader.fieldnames)
|
||||
qtype = self.question_type
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
Question = self.env['yuthon.school.exam.question']
|
||||
|
||||
for row_idx, raw in enumerate(reader, start=2):
|
||||
row = {col_map.get(k, k): (v.strip() if v else '') for k, v in raw.items()}
|
||||
qtext = row.get('question_text', '').strip()
|
||||
if not qtext:
|
||||
skipped += 1
|
||||
errors.append(_('第 %d 行:题干为必填,跳过。') % row_idx)
|
||||
continue
|
||||
|
||||
course_name = row.get('course', '').strip()
|
||||
if not course_name:
|
||||
skipped += 1
|
||||
errors.append(_('第 %d 行:所属课程为必填,跳过。') % row_idx)
|
||||
continue
|
||||
course = self.env['yuthon.school.course'].sudo().search(
|
||||
[('name', '=', course_name)], limit=1)
|
||||
if not course:
|
||||
skipped += 1
|
||||
errors.append(_('第 %d 行:找不到课程「%s」,跳过。') % (row_idx, course_name))
|
||||
continue
|
||||
|
||||
vals = {
|
||||
'question_text': qtext,
|
||||
'question_type': qtype,
|
||||
'course_id': course.id,
|
||||
'teacher_id': self.env.user.id,
|
||||
'score': self._parse_float(row.get('score', ''), 1.0),
|
||||
'difficulty': _DIFFICULTY_MAP.get(row.get('difficulty', '').strip(), 'medium'),
|
||||
'visibility': _VISIBILITY_MAP.get(row.get('visibility', '').strip(), 'private'),
|
||||
}
|
||||
title = row.get('title', '').strip()
|
||||
if title:
|
||||
vals['name'] = title
|
||||
chapter_name = row.get('chapter', '').strip()
|
||||
if chapter_name:
|
||||
chapter = self.env['yuthon.school.course.chapter'].sudo().search([
|
||||
('course_id', '=', course.id),
|
||||
('name', '=', chapter_name),
|
||||
], limit=1)
|
||||
if chapter:
|
||||
vals['chapter_id'] = chapter.id
|
||||
explanation = row.get('explanation', '').strip()
|
||||
if explanation:
|
||||
vals['explanation'] = explanation
|
||||
|
||||
try:
|
||||
if qtype in ('single_choice', 'multiple_choice'):
|
||||
option_cmds, correct_set = self._build_options(row, qtype)
|
||||
if not option_cmds:
|
||||
skipped += 1
|
||||
errors.append(_('第 %d 行:未填写任何选项,跳过。') % row_idx)
|
||||
continue
|
||||
vals['option_ids'] = option_cmds
|
||||
elif qtype == 'true_false':
|
||||
ans = row.get('correct', '').strip()
|
||||
mapped = self._map_bool(ans)
|
||||
if mapped is None:
|
||||
skipped += 1
|
||||
errors.append(_('第 %d 行:正确答案请填 正确/错误,跳过。') % row_idx)
|
||||
continue
|
||||
vals['correct_answer'] = mapped
|
||||
elif qtype == 'fill_blank':
|
||||
ans = row.get('correct', '').strip()
|
||||
if not ans:
|
||||
skipped += 1
|
||||
errors.append(_('第 %d 行:正确答案(标准答案)为必填,跳过。') % row_idx)
|
||||
continue
|
||||
vals['correct_answer'] = ans
|
||||
elif qtype == 'essay':
|
||||
ref = row.get('reference', '').strip()
|
||||
if ref:
|
||||
vals['reference_answer'] = ref
|
||||
|
||||
Question.create(vals)
|
||||
created += 1
|
||||
except Exception as exc:
|
||||
skipped += 1
|
||||
errors.append(_('第 %d 行导入失败:%s') % (row_idx, str(exc)))
|
||||
|
||||
summary = _('导入完成!新建题目:%d,跳过:%d') % (created, skipped)
|
||||
if errors:
|
||||
summary += '\n' + _('注意事项:\n') + '\n'.join(errors[:10])
|
||||
if len(errors) > 10:
|
||||
summary += _('\n... 还有 %d 条,详见日志。') % (len(errors) - 10)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('导入结果'),
|
||||
'message': summary,
|
||||
'type': 'success' if not errors else 'warning',
|
||||
'sticky': True,
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
|
||||
# ─────────────────── 辅助 ───────────────────
|
||||
|
||||
def _build_options(self, row, qtype):
|
||||
"""根据选项列构造 option_ids 命令,并返回正确选项标签集合。"""
|
||||
correct_raw = row.get('correct', '').strip().upper()
|
||||
if qtype == 'single_choice':
|
||||
# 单选只取第一个正确项
|
||||
first = next((c for c in correct_raw.replace(',', ',').split(',') if c.strip()), None)
|
||||
correct_set = {first} if first else set()
|
||||
else:
|
||||
correct_set = {c.strip() for c in correct_raw.replace(',', ',').split(',') if c.strip()}
|
||||
|
||||
cmds = []
|
||||
for idx in range(5):
|
||||
content = row.get('option_%s' % chr(97 + idx), '').strip()
|
||||
if not content:
|
||||
continue
|
||||
label = chr(65 + idx) # A,B,C,D,E
|
||||
cmds.append((0, 0, {
|
||||
'label': label,
|
||||
'content': content,
|
||||
'is_correct': label in correct_set,
|
||||
}))
|
||||
return cmds, correct_set
|
||||
|
||||
def _parse_float(self, value, default):
|
||||
try:
|
||||
return float(str(value).strip() or default)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def _map_bool(self, value):
|
||||
v = value.strip().lower()
|
||||
if v in ('正确', 'true', 't', '1', '是', '对'):
|
||||
return 'True'
|
||||
if v in ('错误', 'false', 'f', '0', '否', '错'):
|
||||
return 'False'
|
||||
return None
|
||||
|
||||
def _normalize_columns(self, fieldnames):
|
||||
"""列名 → 内部 key。模板表头常带括号说明(如「正确答案(填A/B/C/D/E)」),
|
||||
故用「包含」匹配而非全等,兼容中英文与 Excel 自定义列名。"""
|
||||
mapping = {}
|
||||
for f in fieldnames:
|
||||
fl = f.strip().lower()
|
||||
if '题目标题' in fl or fl in ('title', 'name', '标题'):
|
||||
mapping[f] = 'title'
|
||||
elif '题干' in fl or fl in ('question', 'text', '题目'):
|
||||
mapping[f] = 'question_text'
|
||||
elif '所属课程' in fl or fl in ('course', '课程'):
|
||||
mapping[f] = 'course'
|
||||
elif '所属章节' in fl or fl in ('chapter', '章节'):
|
||||
mapping[f] = 'chapter'
|
||||
elif '难度' in fl or fl == 'difficulty':
|
||||
mapping[f] = 'difficulty'
|
||||
elif '分值' in fl or '分数' in fl or fl == 'score':
|
||||
mapping[f] = 'score'
|
||||
elif '可见范围' in fl or fl == 'visibility':
|
||||
mapping[f] = 'visibility'
|
||||
elif '参考答案' in fl or fl == 'reference':
|
||||
mapping[f] = 'reference'
|
||||
elif '正确答案' in fl or fl in ('correct', 'correct_answer', '答案'):
|
||||
mapping[f] = 'correct'
|
||||
elif '答案说明' in fl or '解析' in fl or fl == 'explanation':
|
||||
mapping[f] = 'explanation'
|
||||
elif '选项a' in fl or fl in ('a', 'option_a'):
|
||||
mapping[f] = 'option_a'
|
||||
elif '选项b' in fl or fl in ('b', 'option_b'):
|
||||
mapping[f] = 'option_b'
|
||||
elif '选项c' in fl or fl in ('c', 'option_c'):
|
||||
mapping[f] = 'option_c'
|
||||
elif '选项d' in fl or fl in ('d', 'option_d'):
|
||||
mapping[f] = 'option_d'
|
||||
elif '选项e' in fl or fl in ('e', 'option_e'):
|
||||
mapping[f] = 'option_e'
|
||||
else:
|
||||
mapping[f] = f
|
||||
return mapping
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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='备注')
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
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);
|
||||
}
|
||||
});
|
||||
@@ -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}
|
||||
);
|
||||
@@ -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 < 142 ? (app.label or app.name) : false</attribute>
|
||||
<attribute name="data-tooltip-position">right</attribute>
|
||||
</xpath>
|
||||
<!-- 收藏夹里「当前应用不在收藏中」时额外显示的那一项 -->
|
||||
<xpath expr="//div[hasclass('nav-item')][@t-if][not(@t-foreach)]" position="attributes">
|
||||
<attribute name="t-att-data-tooltip">ue.sidenavWidth < 142 ? (currentApp.label or currentApp.name) : false</attribute>
|
||||
<attribute name="data-tooltip-position">right</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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>
|
||||
@@ -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 < 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">['&', ('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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">['&', ('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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -35,15 +35,7 @@ class CourseHomework(models.Model):
|
||||
teaching_class_id = fields.Many2one('course.teaching_class', string='教学班',
|
||||
ondelete='cascade', domain="[('course_id', '=', course_id)]")
|
||||
submit_ids = fields.One2many('course.homework.submit', 'homework_id', string='提交记录')
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
score_item_ids = fields.One2many('course.score.item', 'homework_id', string='成绩项目')
|
||||
=======
|
||||
score_item_ids = fields.One2many('course.score.item', 'homework_id', string='成绩项')
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
score_item_ids = fields.One2many('course.score.item', 'homework_id', string='成绩项')
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
|
||||
# ====================== 新增:自动计算提交通道是否开启 ======================
|
||||
@api.depends('state', 'deadline')
|
||||
@@ -90,8 +82,6 @@ class CourseHomework(models.Model):
|
||||
self.publish_time = fields.Datetime.now()
|
||||
# 1. 批量为本班学生生成空白未提交记录
|
||||
self._auto_create_empty_submit()
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
# 新增:自动生成对应作业的学生成绩明细
|
||||
self._auto_create_homework_score_detail()
|
||||
# 2. 推送作业通知给学生
|
||||
@@ -147,15 +137,7 @@ class CourseHomework(models.Model):
|
||||
|
||||
if detail_vals_list:
|
||||
self.env['course.student.score.detail'].create(detail_vals_list)
|
||||
=======
|
||||
# 2. 推送作业通知给学生
|
||||
self._send_homework_notice_to_students()
|
||||
|
||||
=======
|
||||
# 2. 推送作业通知给学生
|
||||
self._send_homework_notice_to_students()
|
||||
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
def _send_homework_notice_to_students(self):
|
||||
"""发布作业后推送消息给教学班学生"""
|
||||
self.ensure_one()
|
||||
@@ -184,10 +166,6 @@ class CourseHomework(models.Model):
|
||||
partner_ids=student_users.mapped('partner_id').ids,
|
||||
subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
<<<<<<< HEAD
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
|
||||
def _auto_create_empty_submit(self):
|
||||
"""仅发布时执行:自动给教学班所有已选课学生生成空白未提交记录"""
|
||||
@@ -215,53 +193,12 @@ class CourseHomework(models.Model):
|
||||
batch_vals.append({
|
||||
'homework_id': self.id,
|
||||
'student_id': stu_id,
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
'course_score_item_id':self.course_score_item_id.id,
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
'state': 'unsubmit' # 默认未提交
|
||||
})
|
||||
# 批量创建空白提交记录
|
||||
submit_model.create(batch_vals)
|
||||
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
def _send_homework_notice_to_students(self):
|
||||
"""发布作业后推送消息给教学班学生"""
|
||||
self.ensure_one()
|
||||
if not self.teaching_class_id:
|
||||
return
|
||||
# 获取当前教学班所有已选课学生
|
||||
enrolls = self.env['course.teaching_class.enrollment'].search([
|
||||
('teaching_class_id', '=', self.teaching_class_id.id),
|
||||
('state', '=', 'enrolled')
|
||||
])
|
||||
student_users = enrolls.mapped('student_id.user_id').filtered(lambda u: u)
|
||||
if not student_users:
|
||||
return
|
||||
# 消息内容
|
||||
subject = f"新作业发布:{self.name}"
|
||||
body = f"""
|
||||
<p>课程:{self.course_id.name}</p>
|
||||
<p>作业名称:{self.name}</p>
|
||||
<p>截止时间:{self.deadline or '无'}</p>
|
||||
<p>作业要求:{self.requirement or '无'}</p>
|
||||
"""
|
||||
# 发送消息(关联当前作业记录,学生在消息中心可直接跳转)
|
||||
self.message_post(
|
||||
subject=subject,
|
||||
body=body,
|
||||
partner_ids=student_users.mapped('partner_id').ids,
|
||||
subtype_xmlid='mail.mt_note'
|
||||
)
|
||||
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
# ====================== 草稿/关闭作业 ======================
|
||||
def action_draft(self):
|
||||
self.state = 'draft'
|
||||
@@ -388,21 +325,14 @@ class CourseHomework(models.Model):
|
||||
def create(self, vals):
|
||||
homework_record = super().create(vals)
|
||||
# 草稿状态不执行自动生成提交记录,移到action_publish
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
|
||||
target_a = self.env["course.score.item"].search([
|
||||
("name", "=", "作业"),
|
||||
("create_uid", "=", self.env.uid)
|
||||
], limit=1)
|
||||
|
||||
if target_a:
|
||||
# 将刚新建的B追加到A的多对多字段
|
||||
# 将新建的作业追加到"作业"成绩项目的多对多字段
|
||||
target_a.write({
|
||||
"homework_ids": [(4, homework_record.id)]
|
||||
})
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
return homework_record
|
||||
@@ -93,13 +93,6 @@ class CourseHomeworkSubmit(models.Model):
|
||||
def create(self, vals):
|
||||
submit_rec = super().create(vals)
|
||||
self._check_submit_change_state(submit_rec)
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
=======
|
||||
>>>>>>> 0b2a587b69524dafde97f76bad27721afebdcddb
|
||||
return submit_rec
|
||||
|
||||
def write(self, vals):
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# OdooshCN Lab Environments
|
||||
|
||||
Syncs the users of this Odoo instance to an OdooshCN platform and gives every user a
|
||||
one-click entry to their own lab environment.
|
||||
|
||||
## Install
|
||||
|
||||
1. Add the parent folder to `addons_path`:
|
||||
|
||||
```
|
||||
addons_path = ...,D:\odoo_project\odoo18-2\odoo\custom_project\school-python
|
||||
```
|
||||
|
||||
2. Restart Odoo, update the app list and install **OdooshCN Lab Environments**.
|
||||
|
||||
## Configure
|
||||
|
||||
### 1. Create an access token on OdooshCN
|
||||
|
||||
Sign in as a super administrator, open the user menu in the top right corner and choose
|
||||
**Access tokens**, then create one:
|
||||
|
||||
| Field | Suggested value |
|
||||
|---|---|
|
||||
| Name | Odoo course system |
|
||||
| Source | `school` (synced users are recorded under this source) |
|
||||
| Organisation | where the users should land |
|
||||
| Scopes | tick all four |
|
||||
| Maximum grantable role | Organisation manager, if any user needs that role |
|
||||
| Instances per user | 1 |
|
||||
| Allow deleting users | on, if deleting a user here should delete the platform account |
|
||||
| Allow other organisations / platform administrator | off |
|
||||
|
||||
The token is shown once, so copy it immediately. Scopes can be edited later without
|
||||
changing the token value.
|
||||
|
||||
### 2. Fill it in here
|
||||
|
||||
Settings > General Settings > OdooshCN Lab Environments:
|
||||
|
||||
1. Turn on **Enable sync**.
|
||||
2. Enter the platform URL and the token, then press **Test connection**. It validates the
|
||||
URL and token only, reports the organisation, the scopes and the limits, and warns when
|
||||
a scope is missing.
|
||||
For local testing use `http://127.0.0.1:port` rather than `localhost`: on Windows
|
||||
`localhost` resolves to IPv6 first while WSL2 and Docker only forward IPv4, and every
|
||||
request would wait out a 20 second timeout. The module rewrites localhost anyway, but
|
||||
an explicit address is safer.
|
||||
3. Adjust the environment defaults if needed and save.
|
||||
|
||||
That is all. There is nothing to configure per user.
|
||||
|
||||
## Daily use
|
||||
|
||||
New users are synced automatically. Everything the platform knows about a user sits on the
|
||||
**Lab Environment** tab of the user form:
|
||||
|
||||
| Field | Meaning | Default |
|
||||
|---|---|---|
|
||||
| Sync to lab platform | Master switch for this user | On |
|
||||
| Platform username override | Empty derives it from the login | Empty |
|
||||
| Organisation | Platform organisation slug | Empty (default) |
|
||||
| Platform role | Regular user or platform administrator | Regular user |
|
||||
| Role in organisation | Tester / Developer / Organisation manager | Developer |
|
||||
| Web Shell, Enterprise edition, Auto-provision | Switches | On / Off / On |
|
||||
| Instance limit, Extra database limit | 0 means unlimited | 1 / 2 |
|
||||
| Platform menus | Overview / Git / AI / Backups | Off / Off / On / Off |
|
||||
|
||||
Select several users in the list and use the **Actions** menu to sync, enable or disable
|
||||
them in bulk.
|
||||
|
||||
Users open their environment from the **Lab Environment** menu. The platform console - the
|
||||
instance list and the instance detail page - is embedded in a frame on that same page, so
|
||||
nobody leaves the course system, with an *Open in a new tab* button for a full window. The
|
||||
instance's own Odoo always opens in a new tab: nesting a whole Odoo inside another one
|
||||
stacks three navigation bars and two sidebars.
|
||||
|
||||
For the frame to work the platform has to allow being embedded by this Odoo. Its console
|
||||
answers `X-Frame-Options: SAMEORIGIN` by default and the frame stays blank; the platform
|
||||
administrator drops a file `console-frame-odoo.inc` into `<data dir>/nginx/` naming this
|
||||
server, then reloads the platform's web container:
|
||||
|
||||
```
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' http://school.example.com:8069" always;
|
||||
```
|
||||
|
||||
The value has to match what users actually have in the address bar, scheme and port
|
||||
included: `http://localhost:8069` does not cover `http://the-machine-name:8069`.
|
||||
|
||||
## What happens when
|
||||
|
||||
| In Odoo | On the platform |
|
||||
|---|---|
|
||||
| Create a user | Account created, sync switch turned on, environment provisioned |
|
||||
| Update a user | Pushed when the switch is on, ignored when it is off |
|
||||
| Archive a user | Account deactivated, instances stopped, nothing deleted |
|
||||
| Restore an archived user | Account activated again |
|
||||
| Delete a user | Account deleted; the instances move to the platform recycle bin, where the data stays recoverable for the retention period |
|
||||
|
||||
Deleting requires the access token to allow it. Without that permission the platform
|
||||
refuses and the queue row shows `delete_not_allowed`; the account is then only deactivated.
|
||||
|
||||
Portal users and built-in Odoo accounts (OdooBot, the public user, the portal template and
|
||||
the new-user default template) are never synced.
|
||||
|
||||
## How syncing works
|
||||
|
||||
The hooks only write a row into `odoosh.sync.log`; **no HTTP happens inside a user
|
||||
request**. A dedicated thread pushes right after the transaction commits, using its own
|
||||
database connection, so a failure there can never affect what was already saved.
|
||||
|
||||
Three paths back each other up:
|
||||
|
||||
| Path | Trigger | Purpose |
|
||||
|---|---|---|
|
||||
| Background thread | Right after commit | The user barely notices a delay |
|
||||
| Queue cron | Every minute | Takes over when the thread was killed with the worker, and drives the retry back-off |
|
||||
| User scan | Inside that same cron, every 30 minutes | Walks users changed since the last scan and compares fingerprints |
|
||||
|
||||
The scan has no cron of its own: it is cheap, but Odoo's cron pool is shared by the whole
|
||||
database (`max_cron_threads`, 2 by default), so a slot costs more than the scan does. Set the
|
||||
system parameter `odoosh.scan_interval_minutes` to change the interval, or to `0` to turn the
|
||||
scan off and rely on the hooks alone.
|
||||
|
||||
Cost control: only `login`, `name`, `active`, the sync switch and the platform permission
|
||||
fields trigger a push, so changing an avatar or a preference does not; the scan reads at
|
||||
most 200 users per round through an index; identical content is never pushed twice.
|
||||
|
||||
A daily reconciliation sends the full list of users so the platform can deactivate accounts
|
||||
that disappeared. An empty local list is skipped, and the platform refuses a list that
|
||||
would deactivate an implausible share of the accounts.
|
||||
|
||||
## Security
|
||||
|
||||
* The token is stored in a system parameter, readable by system administrators only, and
|
||||
the settings page shows only a mask plus the last four characters.
|
||||
* The identity sent to the platform always comes from the current session, never from a
|
||||
request parameter. Otherwise a user could edit a URL and land in somebody else's
|
||||
environment.
|
||||
* Entry URLs are short lived and single use: they are redirected to immediately, never
|
||||
stored and never logged.
|
||||
|
||||
## Adjusting the code
|
||||
|
||||
Everything a user gets is a field, so day-to-day changes need no code. If the mapping
|
||||
itself has to change, it lives in `models/res_users.py`:
|
||||
|
||||
* `_odoosh_payload()` builds the user payload from the fields.
|
||||
* `_odoosh_env_spec()` builds the environment specification.
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
{
|
||||
'name': 'OdooshCN Lab Environments',
|
||||
'category': 'Productivity',
|
||||
'version': '18.0.2.3.0',
|
||||
'sequence': 20,
|
||||
'summary': "Sync users to the OdooshCN platform and let them open their own lab environment in one click",
|
||||
'description': """
|
||||
OdooshCN Lab Environments
|
||||
=========================
|
||||
|
||||
Syncs the users of this system to an OdooshCN platform and gives every user a
|
||||
one-click entry to their own lab environment, without a second set of credentials.
|
||||
|
||||
Features
|
||||
--------
|
||||
* **User sync** - creating, updating, archiving or deleting a user is pushed to
|
||||
OdooshCN asynchronously. The hooks only write to a local queue, so the platform
|
||||
being slow or down never blocks user management here.
|
||||
* **Per-user permissions** - the platform role, organisation, quotas, Web Shell,
|
||||
enterprise edition and menu visibility are plain fields on the user form.
|
||||
Change a field, it is synced.
|
||||
* **One-click entry** - a menu entry opens the user's own lab environment with no
|
||||
extra login.
|
||||
* **Environment board** - each user sees the name, status and version of their
|
||||
own environments, read live from the platform.
|
||||
* **Sync queue** - administrators can inspect every push, its payload, its result
|
||||
and retry the failed ones.
|
||||
|
||||
Rules
|
||||
-----
|
||||
1. Once the platform URL and access token are configured, a new user is synced
|
||||
immediately and their sync switch is turned on automatically.
|
||||
2. Updating a user syncs when the switch is on, and does nothing when it is off.
|
||||
3. Archiving deactivates the platform account; deleting deletes it; restoring an
|
||||
archived user activates it again.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
Settings > General Settings > OdooshCN Lab Environments: fill in the platform URL
|
||||
and the access token, then press *Test connection*.
|
||||
""",
|
||||
'author': 'Charles',
|
||||
'website': '',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['base', 'base_setup', 'web', 'college'],
|
||||
'data': [
|
||||
'security/odoosh_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_cron_data.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/res_users_views.xml',
|
||||
'views/odoosh_sync_log_views.xml',
|
||||
'views/odoosh_environment_views.xml',
|
||||
'views/odoosh_menus.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'odoosh_connector/static/src/js/odoosh_embed.js',
|
||||
'odoosh_connector/static/src/xml/odoosh_embed.xml',
|
||||
'odoosh_connector/static/src/scss/odoosh.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import main
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""User-facing entry points.
|
||||
|
||||
Hard rule: the identity sent to OdooshCN always comes from request.env.user, never from a
|
||||
request parameter. Otherwise a user could edit the URL and land in somebody else's
|
||||
environment. No line in this file reads an identity from kw.
|
||||
|
||||
The access token is read on the server only (inside odoosh.client) and never reaches the
|
||||
browser.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.http import request
|
||||
|
||||
from ..models.odoosh_client import OdooshError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OdooshPortal(http.Controller):
|
||||
|
||||
def _guard(self):
|
||||
"""Whether the current user may use a lab environment."""
|
||||
user = request.env.user
|
||||
if not user or user._is_public():
|
||||
return _("Please sign in first.")
|
||||
if not user.sudo()._odoosh_in_scope():
|
||||
return _("Your account is not set up for lab environments. Ask your administrator.")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ redirect entry
|
||||
@http.route('/odoosh/go', type='http', auth='user', website=False)
|
||||
def go(self, target='console', env=None, **kw):
|
||||
"""Exchange for a passwordless URL and redirect. target=console opens the console,
|
||||
target=odoo goes straight into the instance.
|
||||
|
||||
`env` is only an environment name, not an identity; the platform still checks that
|
||||
the environment belongs to the current user."""
|
||||
error = self._guard()
|
||||
if error:
|
||||
return request.render('odoosh_connector.entry_error', {'message': error})
|
||||
uid = str(request.env.user.id) # identity comes from the session, only ever from there
|
||||
try:
|
||||
result = request.env['odoosh.client'].sudo().handoff(
|
||||
uid, target='odoo' if target == 'odoo' else 'console', env=env)
|
||||
except OdooshError as err:
|
||||
_logger.warning("OdooshCN handoff failed for user#%s: %s", uid, err)
|
||||
return request.render('odoosh_connector.entry_error', {
|
||||
'message': _("Cannot open the lab environment: %s", err.message),
|
||||
'code': err.code,
|
||||
})
|
||||
return request.redirect(result['url'], local=False)
|
||||
|
||||
# ------------------------------------------------------------------ JSON for the client action
|
||||
@http.route('/odoosh/my/envs', type='json', auth='user')
|
||||
def my_envs(self, **kw):
|
||||
"""The current user's environments, for the board in the menu."""
|
||||
error = self._guard()
|
||||
if error:
|
||||
return {'error': error, 'envs': []}
|
||||
try:
|
||||
data = request.env['odoosh.client'].sudo().list_envs(str(request.env.user.id))
|
||||
except OdooshError as err:
|
||||
return {'error': err.message, 'code': err.code, 'envs': []}
|
||||
return {'envs': data.get('envs', []), 'username': data.get('username')}
|
||||
|
||||
@http.route('/odoosh/my/url', type='json', auth='user')
|
||||
def my_url(self, target='console', env=None, **kw):
|
||||
"""Exchange for a passwordless URL for the browser to open right away."""
|
||||
error = self._guard()
|
||||
if error:
|
||||
return {'error': error}
|
||||
try:
|
||||
result = request.env['odoosh.client'].sudo().handoff(
|
||||
str(request.env.user.id), target='odoo' if target == 'odoo' else 'console', env=env)
|
||||
except OdooshError as err:
|
||||
return {'error': err.message, 'code': err.code}
|
||||
return {'url': result['url'], 'expires_in': result.get('expires_in')}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Upgrading from 18.0.2.0.0: the per-minute queue cron and the 5-minute scan cron are
|
||||
merged into cron_odoosh_tick below. Matched by code rather than by XML id, so a fresh
|
||||
install stays quiet (a missing XML id logs a warning and a traceback), and restricted
|
||||
to this model so no other module's cron can be caught by the generic code strings. -->
|
||||
<delete model="ir.cron"
|
||||
search="[('model_id.model', '=', 'odoosh.sync.log'),
|
||||
('code', 'in', ['model.cron_process()', 'model.cron_scan()']),
|
||||
('active', 'in', [True, False])]"/>
|
||||
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- The only frequent cron. Every minute it drains the queue: one indexed query on a
|
||||
table that is normally empty, so an idle tick costs nothing worth measuring.
|
||||
Every `odoosh.scan_interval_minutes` minutes (30 by default, 0 disables it) the
|
||||
same tick walks the users first.
|
||||
|
||||
Why one cron instead of two: the work is tiny either way, but Odoo's cron pool is
|
||||
shared by the whole database (max_cron_threads, 2 by default), and it is the slot
|
||||
held every minute - not the CPU - that competes with other modules. -->
|
||||
<record id="cron_odoosh_tick" model="ir.cron">
|
||||
<field name="name">OdooshCN: sync queue and user scan</field>
|
||||
<field name="model_id" ref="model_odoosh_sync_log"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_tick()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Daily: send the full list of users so the platform can deactivate the ones that
|
||||
disappeared. An empty local list is skipped; the platform guards the ratio too. -->
|
||||
<record id="cron_odoosh_reconcile" model="ir.cron">
|
||||
<field name="name">OdooshCN: daily reconciliation</field>
|
||||
<field name="model_id" ref="model_odoosh_sync_log"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_reconcile()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import odoosh_client
|
||||
from . import odoosh_sync_log
|
||||
from . import res_users
|
||||
from . import res_config_settings
|
||||
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The single place that talks to the OdooshCN platform.
|
||||
|
||||
Keeping every outbound request here means retry classification, timeouts and error
|
||||
decoding have one implementation, and the access token is read in this file only.
|
||||
|
||||
Errors come in two kinds, which decide whether the queue retries or gives up:
|
||||
* OdooshRetryable - network trouble, rate limiting, platform 5xx. Worth retrying.
|
||||
* OdooshPermanent - bad parameters, name already taken, beyond the token limits.
|
||||
Retrying cannot help; a human has to look at it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from odoo import _, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT = 20 # seconds; syncing runs in a background queue, never in a user request
|
||||
PARAM_URL = 'odoosh.base_url'
|
||||
PARAM_TOKEN = 'odoosh.token'
|
||||
|
||||
|
||||
class OdooshError(Exception):
|
||||
"""A call to OdooshCN failed. `code` is the platform's stable error code."""
|
||||
|
||||
def __init__(self, message, code='unknown', status=None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
def __str__(self):
|
||||
return '[%s] %s' % (self.code, self.message)
|
||||
|
||||
|
||||
class OdooshRetryable(OdooshError):
|
||||
"""Temporary failure, worth retrying."""
|
||||
|
||||
|
||||
class OdooshPermanent(OdooshError):
|
||||
"""The caller's own problem; retrying will not help."""
|
||||
|
||||
|
||||
class OdooshClient(models.AbstractModel):
|
||||
_name = 'odoosh.client'
|
||||
_description = 'OdooshCN API Client'
|
||||
|
||||
# ------------------------------------------------------------------ configuration
|
||||
def _config(self):
|
||||
"""Read the platform URL and the token. Read here only, and always as sudo:
|
||||
regular users have no access to system parameters."""
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
base = (icp.get_param(PARAM_URL) or '').strip().rstrip('/')
|
||||
token = (icp.get_param(PARAM_TOKEN) or '').strip()
|
||||
if not base or not token:
|
||||
raise OdooshPermanent(
|
||||
_("The OdooshCN platform URL or access token is not configured yet. "
|
||||
"Go to Settings > General Settings > OdooshCN Lab Environments."),
|
||||
code='not_configured')
|
||||
return self._prefer_ipv4(base), token
|
||||
|
||||
@staticmethod
|
||||
def _prefer_ipv4(base):
|
||||
"""Local development trap on Windows: `localhost` resolves to the IPv6 address
|
||||
::1 first, while WSL2 and Docker only forward IPv4. urllib has no happy-eyeballs
|
||||
fallback, so it waits out the full timeout (20s) on every single request.
|
||||
Swapping localhost for 127.0.0.1 avoids it. Real deployments use a domain name."""
|
||||
parts = urllib.parse.urlsplit(base)
|
||||
if parts.hostname and parts.hostname.lower() == 'localhost':
|
||||
netloc = '127.0.0.1' + (':%s' % parts.port if parts.port else '')
|
||||
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
||||
return base
|
||||
|
||||
def _enabled(self):
|
||||
return self.env['ir.config_parameter'].sudo().get_param('odoosh.enabled') in ('True', 'true', '1', True)
|
||||
|
||||
# ------------------------------------------------------------------ request
|
||||
def _request(self, method, path, payload=None):
|
||||
"""Send one request and return the decoded body.
|
||||
Raises OdooshRetryable / OdooshPermanent on failure."""
|
||||
base, token = self._config()
|
||||
url = '%s%s' % (base, path)
|
||||
data = None
|
||||
headers = {'Authorization': 'Bearer %s' % token, 'Accept': 'application/json'}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode('utf-8') or '{}')
|
||||
except urllib.error.HTTPError as err:
|
||||
raise self._http_error(err, method, path)
|
||||
except urllib.error.URLError as err:
|
||||
# DNS failure, refused connection, timeout: the platform may just be restarting
|
||||
raise OdooshRetryable(_("Cannot reach OdooshCN (%(url)s): %(reason)s",
|
||||
url=base, reason=err.reason), code='unreachable')
|
||||
except json.JSONDecodeError:
|
||||
raise OdooshRetryable(_("OdooshCN returned a body that is not valid JSON"), code='bad_response')
|
||||
|
||||
def _http_error(self, err, method, path):
|
||||
"""Turn an HTTP error into a coded exception and decide whether it is worth retrying."""
|
||||
raw = ''
|
||||
try:
|
||||
raw = err.read().decode('utf-8')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
code, detail = 'http_%s' % err.code, raw or err.reason
|
||||
try:
|
||||
parsed = json.loads(raw or '{}')
|
||||
code = parsed.get('code') or code
|
||||
d = parsed.get('detail')
|
||||
if isinstance(d, list): # 422 validation errors come as a list
|
||||
d = '; '.join(str(x.get('msg') or x) for x in d)
|
||||
detail = d or detail
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
_logger.warning("OdooshCN %s %s -> %s %s", method, path, err.code, detail)
|
||||
exc = OdooshRetryable if err.code in (408, 429) or err.code >= 500 else OdooshPermanent
|
||||
return exc(detail, code=code, status=err.code)
|
||||
|
||||
# ------------------------------------------------------------------ endpoints
|
||||
def ping(self):
|
||||
"""Connectivity probe: validates URL and token only, no side effect.
|
||||
Returns the token's source, organisation, scopes and limits."""
|
||||
return self._request('GET', '/api/ext/ping')
|
||||
|
||||
def upsert_user(self, payload):
|
||||
"""Create or update a user, keyed by external_id and idempotent.
|
||||
`payload['provision']` additionally asks for a lab environment."""
|
||||
return self._request('POST', '/api/ext/users', payload)
|
||||
|
||||
def deactivate_user(self, external_id, stop_envs=True):
|
||||
"""Deactivate the platform account. Nothing is deleted."""
|
||||
return self._request('POST', '/api/ext/users/deactivate',
|
||||
{'external_id': str(external_id), 'stop_envs': stop_envs})
|
||||
|
||||
def delete_user(self, external_id, purge_envs=False):
|
||||
"""Delete the platform account for good. Requires the token to allow deletion.
|
||||
The user's environments go to the platform recycle bin unless purge_envs is set."""
|
||||
return self._request('POST', '/api/ext/users/delete',
|
||||
{'external_id': str(external_id), 'purge_envs': purge_envs})
|
||||
|
||||
def reconcile(self, external_ids, stop_envs=True, confirm=False):
|
||||
"""Full reconciliation: accounts of this source that are not in the list get
|
||||
deactivated. The platform refuses an obviously incomplete list unless confirmed."""
|
||||
return self._request('POST', '/api/ext/users/reconcile',
|
||||
{'external_ids': [str(x) for x in external_ids],
|
||||
'stop_envs': stop_envs, 'confirm': confirm})
|
||||
|
||||
def list_envs(self, uid):
|
||||
"""List the lab environments of one user."""
|
||||
return self._request('GET', '/api/ext/envs?uid=%s' % urllib.parse.quote(str(uid)))
|
||||
|
||||
def create_env(self, uid, spec):
|
||||
"""Provision one lab environment for a user."""
|
||||
body = dict(spec or {})
|
||||
body['uid'] = str(uid)
|
||||
return self._request('POST', '/api/ext/envs', body)
|
||||
|
||||
def handoff(self, uid, target='console', env=None):
|
||||
"""Exchange for a passwordless entry URL: {'url': ..., 'expires_in': ...}.
|
||||
|
||||
The URL is short lived and single use: redirect to it straight away, never
|
||||
store it and never log it."""
|
||||
body = {'uid': str(uid), 'target': target}
|
||||
if env:
|
||||
body['env'] = env
|
||||
return self._request('POST', '/api/ext/handoff', body)
|
||||
@@ -0,0 +1,458 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Sync queue: user changes land in this table and are pushed to OdooshCN asynchronously.
|
||||
|
||||
Why the hooks do not call the API directly: that would make user management here depend
|
||||
on the platform being up. Writing a queue row is a local insert and never fails.
|
||||
|
||||
Three paths, each a fallback for the previous one:
|
||||
* kick_async - a dedicated thread right after the transaction commits, so the user
|
||||
barely notices any delay.
|
||||
* cron_tick - every minute, in case the thread was killed with the worker. It is also
|
||||
the only thing that drives the retry back-off: nothing else comes back
|
||||
for a row whose next_retry_at is four hours away.
|
||||
* cron_scan - folded into that same tick, at most every SCAN_INTERVAL_DEFAULT minutes.
|
||||
Walks users by write_date and compares a payload fingerprint, catching
|
||||
anything the hooks missed (changes made outside the ORM, or made while
|
||||
the module was disabled).
|
||||
|
||||
The scan shares the queue cron's slot instead of owning one. Both jobs are cheap in
|
||||
themselves, but Odoo's cron pool is shared by the whole database (max_cron_threads, 2 by
|
||||
default), so a slot taken every few minutes costs more than the work does.
|
||||
|
||||
Retry policy: temporary failures back off 1, 5, 15, 60 and 240 minutes, then the row is
|
||||
marked failed and waits for a human. Permanent failures are not retried at all.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import SUPERUSER_ID, _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.modules.registry import Registry
|
||||
|
||||
from .odoosh_client import OdooshPermanent, OdooshRetryable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Minutes to wait after the n-th failure; once exhausted the row is marked failed
|
||||
BACKOFF_MINUTES = [1, 5, 15, 60, 240]
|
||||
BATCH_SIZE = 50 # rows processed per queue round
|
||||
SCAN_BATCH = 200 # users inspected per scan round
|
||||
SCAN_INTERVAL_DEFAULT = 30 # minutes between two scans; the parameter set to 0 disables it
|
||||
PARAM_LAST_SCAN = 'odoosh.last_scan_at' # cursor: user write_date reached by the scan
|
||||
PARAM_SCAN_RUN = 'odoosh.last_scan_run' # when the scan last ran, to space the runs out
|
||||
PARAM_SCAN_INTERVAL = 'odoosh.scan_interval_minutes'
|
||||
|
||||
# At most one push thread per database: a second request finding the lock taken just
|
||||
# returns, because the running thread re-queries the queue until it is empty.
|
||||
_workers = {}
|
||||
_workers_guard = threading.Lock()
|
||||
|
||||
|
||||
class OdooshSyncLog(models.Model):
|
||||
_name = 'odoosh.sync.log'
|
||||
_description = 'OdooshCN Sync Queue'
|
||||
_order = 'id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
# ondelete must be `set null`: when the Odoo user is deleted this row still has to
|
||||
# travel to the platform to delete the account there.
|
||||
user_id = fields.Many2one('res.users', string='User', ondelete='set null', index=True)
|
||||
login = fields.Char(string='Login', help="Kept so the row still identifies someone after the user is deleted")
|
||||
external_id = fields.Char(string='External ID', required=True, index=True,
|
||||
help="The stable key sent to OdooshCN, i.e. the user ID in this system")
|
||||
operation = fields.Selection([
|
||||
('upsert', 'Create / Update'),
|
||||
('deactivate', 'Deactivate'),
|
||||
('delete', 'Delete'),
|
||||
], string='Operation', required=True, default='upsert')
|
||||
state = fields.Selection([
|
||||
('pending', 'Pending'),
|
||||
('done', 'Done'),
|
||||
('skipped', 'Skipped (no change)'),
|
||||
('failed', 'Failed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], string='Status', default='pending', required=True, index=True)
|
||||
force = fields.Boolean(string='Force push',
|
||||
help="Ignore the payload fingerprint and push even when nothing changed")
|
||||
attempts = fields.Integer(string='Attempts', default=0)
|
||||
next_retry_at = fields.Datetime(string='Next retry', index=True)
|
||||
last_error = fields.Text(string='Last error')
|
||||
error_code = fields.Char(string='Error code',
|
||||
help="The platform's stable error code, used to decide retry versus manual handling")
|
||||
payload_preview = fields.Text(string='Payload', help="What was sent last time; contains no secrets")
|
||||
result = fields.Text(string='Response')
|
||||
synced_at = fields.Datetime(string='Completed on')
|
||||
display_name = fields.Char(compute='_compute_display_name')
|
||||
|
||||
@api.depends('login', 'operation', 'state')
|
||||
def _compute_display_name(self):
|
||||
ops = dict(self._fields['operation'].selection)
|
||||
for rec in self:
|
||||
rec.display_name = '%s - %s' % (rec.login or _('Unknown user'), ops.get(rec.operation, rec.operation))
|
||||
|
||||
# ------------------------------------------------------------------ enqueue
|
||||
@api.model
|
||||
def enqueue(self, users, operation='upsert', force=False):
|
||||
"""Queue a batch of users. An existing pending row for the same user and operation
|
||||
is reused (back-off reset, latest data resent) so renaming twice does not pile up
|
||||
rows. One query for the whole batch, never one per user."""
|
||||
users = users.sudo()
|
||||
if not users:
|
||||
return self.browse()
|
||||
pending = self.sudo().search([
|
||||
('user_id', 'in', users.ids),
|
||||
('operation', '=', operation),
|
||||
('state', '=', 'pending'),
|
||||
])
|
||||
by_user = {p.user_id.id: p for p in pending}
|
||||
if pending:
|
||||
vals = {'next_retry_at': False, 'attempts': 0}
|
||||
if force:
|
||||
vals['force'] = True
|
||||
pending.write(vals)
|
||||
new_vals = [{
|
||||
'user_id': u.id,
|
||||
'login': u.login,
|
||||
'external_id': str(u.id),
|
||||
'operation': operation,
|
||||
'state': 'pending',
|
||||
'force': force,
|
||||
} for u in users if u.id not in by_user]
|
||||
created = self.sudo().create(new_vals) if new_vals else self.browse()
|
||||
return pending | created
|
||||
|
||||
@api.model
|
||||
def enqueue_delete(self, users):
|
||||
"""Deleting an Odoo user: record the identity now, because after `unlink` the
|
||||
user record is gone and the row has to survive on its own."""
|
||||
users = users.sudo()
|
||||
if not users:
|
||||
return self.browse()
|
||||
return self.sudo().create([{
|
||||
'user_id': u.id,
|
||||
'login': u.login,
|
||||
'external_id': str(u.id),
|
||||
'operation': 'delete',
|
||||
'state': 'pending',
|
||||
} for u in users])
|
||||
|
||||
# ------------------------------------------------------------------ background thread
|
||||
@api.model
|
||||
def kick_async(self, dbname=None, scan_first=False):
|
||||
"""Called after commit: drain the queue in a dedicated thread. One thread per
|
||||
database. `scan_first` runs a user scan first, used after the settings change.
|
||||
|
||||
The thread owns its cursor and environment, fully isolated from the request that
|
||||
started it; anything it raises is logged and cannot affect committed data."""
|
||||
dbname = dbname or self.env.cr.dbname
|
||||
with _workers_guard:
|
||||
lock = _workers.setdefault(dbname, threading.Lock())
|
||||
if not lock.acquire(blocking=False):
|
||||
return False # a thread is already running and will pick up the new rows
|
||||
|
||||
def run():
|
||||
threading.current_thread().dbname = dbname # makes the log lines carry the db name
|
||||
try:
|
||||
registry = Registry(dbname)
|
||||
if scan_first:
|
||||
with registry.cursor() as cr:
|
||||
api.Environment(cr, SUPERUSER_ID, {})['odoosh.sync.log'].cron_scan(kick=False)
|
||||
rounds = 0
|
||||
while rounds < 20: # guard: hand back to the cron if rows keep pouring in
|
||||
rounds += 1
|
||||
with registry.cursor() as cr:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
n = env['odoosh.sync.log'].cron_process()
|
||||
if n == 0:
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("OdooshCN push thread died (the cron will take over)")
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
threading.Thread(target=run, name='odoosh-sync-%s' % dbname, daemon=True).start()
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ the cron
|
||||
@api.model
|
||||
def cron_tick(self):
|
||||
"""The module's only frequent cron: drain the queue, and fold the user scan into the
|
||||
same run every `odoosh.scan_interval_minutes` minutes (0 turns the scan off).
|
||||
|
||||
Scanning first and pushing afterwards means whatever the scan finds leaves in this
|
||||
tick instead of waiting for the next one."""
|
||||
if not self.env['odoosh.client']._enabled():
|
||||
return 0
|
||||
if self._scan_due():
|
||||
self.cron_scan(kick=False) # kick_async would only duplicate the push below
|
||||
return self.cron_process()
|
||||
|
||||
@api.model
|
||||
def _scan_due(self):
|
||||
"""Whether this tick also scans. The timestamp is written before the scan runs, so a
|
||||
scan that crashes waits for the next interval instead of retrying every minute."""
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
try:
|
||||
minutes = int(icp.get_param(PARAM_SCAN_INTERVAL) or SCAN_INTERVAL_DEFAULT)
|
||||
except (TypeError, ValueError):
|
||||
minutes = SCAN_INTERVAL_DEFAULT
|
||||
if minutes <= 0:
|
||||
return False
|
||||
now = fields.Datetime.now()
|
||||
last = icp.get_param(PARAM_SCAN_RUN)
|
||||
if last:
|
||||
try:
|
||||
if now - fields.Datetime.to_datetime(last) < timedelta(minutes=minutes):
|
||||
return False
|
||||
except ValueError:
|
||||
pass # unreadable value: scan now and write a good one back
|
||||
icp.set_param(PARAM_SCAN_RUN, fields.Datetime.to_string(now))
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ queue processing
|
||||
@api.model
|
||||
def cron_process(self, limit=BATCH_SIZE):
|
||||
"""Process the rows that are due; returns how many were handled, failures included."""
|
||||
client = self.env['odoosh.client']
|
||||
if not client._enabled():
|
||||
return 0
|
||||
now = fields.Datetime.now()
|
||||
records = self.sudo().search([
|
||||
('state', '=', 'pending'),
|
||||
'|', ('next_retry_at', '=', False), ('next_retry_at', '<=', now),
|
||||
], order='id asc', limit=limit)
|
||||
if not records:
|
||||
return 0
|
||||
ctx = self.env['res.users']._odoosh_scope_ctx() # shared by the whole batch
|
||||
for rec in records:
|
||||
rec._process_one(ctx)
|
||||
return len(records)
|
||||
|
||||
def _process_one(self, ctx=None):
|
||||
"""Process one row; returns whether it succeeded (skipped counts as success).
|
||||
Each row commits on its own so one failure never rolls back the others."""
|
||||
self.ensure_one()
|
||||
client = self.env['odoosh.client']
|
||||
user = self.user_id.sudo() if self.user_id else self.env['res.users']
|
||||
try:
|
||||
if self.operation == 'delete':
|
||||
payload = {'external_id': self.external_id}
|
||||
result = client.delete_user(self.external_id)
|
||||
self._finish('done', payload, result)
|
||||
if user.exists():
|
||||
user._odoosh_mark('none')
|
||||
return True
|
||||
|
||||
if self.operation == 'deactivate':
|
||||
payload = {'external_id': self.external_id, 'stop_envs': True}
|
||||
result = client.deactivate_user(self.external_id, stop_envs=True)
|
||||
self._finish('done', payload, result)
|
||||
if user.exists():
|
||||
user._odoosh_mark('none')
|
||||
return True
|
||||
|
||||
if not user.exists():
|
||||
self._mark_failed(_("The user was deleted; there is nothing left to sync"), 'user_gone')
|
||||
return False
|
||||
payload = user._odoosh_payload(ctx)
|
||||
digest = user._odoosh_payload_hash(payload)
|
||||
if not self.force and user.odoosh_sync_state == 'synced' and user.odoosh_payload_hash == digest:
|
||||
self._finish('skipped', payload, {'note': 'unchanged'}) # nothing changed, leave the platform alone
|
||||
return True
|
||||
try:
|
||||
result = client.upsert_user(payload)
|
||||
except OdooshPermanent as err:
|
||||
# Platform username already taken (typically Odoo's `admin` colliding with the
|
||||
# platform's own admin): retry once with a "-<odoo user id>" suffix and remember it.
|
||||
if err.code != 'username_taken' or (user.odoosh_username or '').strip():
|
||||
raise
|
||||
alt = ('%s-%s' % (payload['username'], user.id))[:64]
|
||||
payload['username'] = alt
|
||||
if payload.get('provision'):
|
||||
payload['provision']['name'] = alt
|
||||
result = client.upsert_user(payload)
|
||||
user.with_context(odoosh_no_sync=True).write({'odoosh_username': alt})
|
||||
digest = user._odoosh_payload_hash(payload)
|
||||
self._finish('done', payload, result)
|
||||
user._odoosh_mark('synced', payload_hash=digest, result=result)
|
||||
return True
|
||||
except OdooshPermanent as err:
|
||||
self._mark_failed(err.message, err.code)
|
||||
if user.exists():
|
||||
user._odoosh_mark('failed', error='[%s] %s' % (err.code, err.message))
|
||||
return False
|
||||
except OdooshRetryable as err:
|
||||
self._mark_retry(err.message, err.code)
|
||||
if user.exists():
|
||||
user._odoosh_mark('pending', error='[%s] %s' % (err.code, err.message))
|
||||
return False
|
||||
except Exception as err: # noqa: BLE001 - unexpected errors are retried too, never fatal
|
||||
_logger.exception("OdooshCN sync crashed on log#%s", self.id)
|
||||
self._mark_retry(str(err), 'unexpected')
|
||||
return False
|
||||
finally:
|
||||
# Commit per row: progress survives the thread or worker being killed mid-way
|
||||
self.env.cr.commit()
|
||||
|
||||
def _finish(self, state, payload, result):
|
||||
self.write({
|
||||
'state': state,
|
||||
'attempts': self.attempts + 1,
|
||||
'synced_at': fields.Datetime.now(),
|
||||
'payload_preview': self._pretty(payload),
|
||||
'result': self._pretty(result),
|
||||
'last_error': False,
|
||||
'error_code': False,
|
||||
'next_retry_at': False,
|
||||
'force': False,
|
||||
})
|
||||
|
||||
def _mark_retry(self, message, code):
|
||||
attempts = self.attempts + 1
|
||||
if attempts > len(BACKOFF_MINUTES):
|
||||
return self._mark_failed(
|
||||
_("Gave up after %(n)s attempts. Last error: %(err)s", n=attempts - 1, err=message), code)
|
||||
delay = BACKOFF_MINUTES[attempts - 1]
|
||||
self.write({
|
||||
'attempts': attempts,
|
||||
'last_error': message,
|
||||
'error_code': code,
|
||||
'next_retry_at': fields.Datetime.now() + timedelta(minutes=delay),
|
||||
})
|
||||
_logger.warning("OdooshCN sync postponed log#%s (%s), retry in %s min: %s", self.id, code, delay, message)
|
||||
|
||||
def _mark_failed(self, message, code):
|
||||
self.write({
|
||||
'state': 'failed',
|
||||
'attempts': self.attempts + 1,
|
||||
'last_error': message,
|
||||
'error_code': code,
|
||||
'next_retry_at': False,
|
||||
})
|
||||
_logger.error("OdooshCN sync failed log#%s (%s): %s", self.id, code, message)
|
||||
|
||||
@staticmethod
|
||||
def _pretty(data):
|
||||
try:
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
except (TypeError, ValueError):
|
||||
return str(data)
|
||||
|
||||
# ------------------------------------------------------------------ periodic scan
|
||||
@api.model
|
||||
def cron_scan(self, limit=SCAN_BATCH, kick=True):
|
||||
"""Walk users by write_date to catch whatever the hooks missed. Only rows changed
|
||||
since the last scan are read, never the whole table.
|
||||
|
||||
Cost control: one indexed query, one in-memory fingerprint comparison per user,
|
||||
at most SCAN_BATCH users per round; the cursor only advances to the last user
|
||||
actually inspected, so nobody is skipped."""
|
||||
if not self.env['odoosh.client']._enabled():
|
||||
return 0
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
last = icp.get_param(PARAM_LAST_SCAN)
|
||||
Users = self.env['res.users'].sudo()
|
||||
ctx = Users._odoosh_scope_ctx()
|
||||
|
||||
domain = [('odoosh_sync_enabled', '=', True), ('share', '=', False)]
|
||||
if last:
|
||||
domain.append(('write_date', '>', last))
|
||||
# Archived users matter too, so active_test is off; deactivation is only sent for
|
||||
# accounts the platform already knows.
|
||||
users = Users.with_context(active_test=False).search(domain, order='write_date asc, id asc', limit=limit)
|
||||
if not users:
|
||||
icp.set_param(PARAM_LAST_SCAN, fields.Datetime.to_string(fields.Datetime.now()))
|
||||
return 0
|
||||
|
||||
to_upsert = Users.browse()
|
||||
to_deactivate = Users.browse()
|
||||
in_scope = users._odoosh_filter_in_scope(ctx)
|
||||
for u in users:
|
||||
if u not in in_scope:
|
||||
continue # switch off, portal or built-in account
|
||||
if not u.active:
|
||||
if u.odoosh_sync_state in ('synced', 'pending', 'failed'):
|
||||
to_deactivate |= u
|
||||
continue
|
||||
if u.odoosh_sync_state != 'synced' or u.odoosh_payload_hash != u._odoosh_payload_hash(ctx=ctx):
|
||||
to_upsert |= u
|
||||
|
||||
if to_upsert:
|
||||
self.enqueue(to_upsert, 'upsert')
|
||||
to_upsert._odoosh_mark('pending')
|
||||
if to_deactivate:
|
||||
self.enqueue(to_deactivate, 'deactivate')
|
||||
|
||||
# Cursor: when the batch was full, only advance to its last write_date
|
||||
cursor = users[-1].write_date if len(users) >= limit else fields.Datetime.now()
|
||||
icp.set_param(PARAM_LAST_SCAN, fields.Datetime.to_string(cursor))
|
||||
n = len(to_upsert) + len(to_deactivate)
|
||||
if n:
|
||||
_logger.info("OdooshCN scan: inspected %s users, queued %s changes", len(users), n)
|
||||
self.env.cr.commit()
|
||||
if kick:
|
||||
self.kick_async()
|
||||
return n
|
||||
|
||||
# ------------------------------------------------------------------ manual actions
|
||||
def action_retry(self):
|
||||
"""Put failed or cancelled rows back in the queue and process them at once."""
|
||||
self.write({'state': 'pending', 'attempts': 0, 'next_retry_at': False, 'force': True})
|
||||
ctx = self.env['res.users']._odoosh_scope_ctx()
|
||||
for rec in self:
|
||||
rec._process_one(ctx)
|
||||
return True
|
||||
|
||||
def action_cancel(self):
|
||||
self.filtered(lambda r: r.state in ('pending', 'failed')).write({'state': 'cancelled', 'next_retry_at': False})
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ reconciliation
|
||||
@api.model
|
||||
def cron_reconcile(self):
|
||||
"""Daily reconciliation: send the full list of users who should have an account,
|
||||
so the platform can deactivate the ones that are gone.
|
||||
|
||||
An incomplete list would deactivate everyone, so an empty list is skipped here and
|
||||
the platform applies a ratio guard of its own."""
|
||||
client = self.env['odoosh.client']
|
||||
if not client._enabled():
|
||||
return False
|
||||
users = self.env['res.users'].sudo().search([('active', '=', True)])._odoosh_filter_in_scope()
|
||||
if not users:
|
||||
_logger.warning("OdooshCN reconciliation skipped: the local list is empty")
|
||||
return False
|
||||
try:
|
||||
result = client.reconcile([u.id for u in users], stop_envs=True, confirm=False)
|
||||
_logger.info("OdooshCN reconciliation done: %s users sent, %s deactivated on the platform",
|
||||
len(users), result.get('deactivated'))
|
||||
except OdooshPermanent as err:
|
||||
# The ratio guard lands here: never auto-confirm, let an administrator decide
|
||||
_logger.warning("OdooshCN reconciliation refused (%s): %s", err.code, err.message)
|
||||
except OdooshRetryable as err:
|
||||
_logger.warning("OdooshCN reconciliation postponed (%s): %s", err.code, err.message)
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def action_sync_all(self):
|
||||
"""Re-queue every user in scope, ignoring fingerprints."""
|
||||
users = self.env['res.users'].sudo().search([])._odoosh_filter_in_scope()
|
||||
if not users:
|
||||
raise UserError(_("No user is in sync scope. Check the settings and the per-user switch."))
|
||||
self.enqueue(users, 'upsert', force=True)
|
||||
users._odoosh_mark('pending')
|
||||
self.env.cr.commit()
|
||||
self.kick_async()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _("Queued"),
|
||||
'message': _("%s users queued; the background worker has started.", len(users)),
|
||||
'type': 'success',
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Settings: platform connection and the defaults used for auto-provisioned environments.
|
||||
|
||||
The access token lives in ir.config_parameter, readable only by system administrators.
|
||||
Nothing sent to the browser ever contains it in clear text.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from .odoosh_client import OdooshError
|
||||
|
||||
MASK = '********'
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
odoosh_enabled = fields.Boolean(
|
||||
string='Enable OdooshCN sync',
|
||||
config_parameter='odoosh.enabled',
|
||||
help="Master switch. When off nothing is pushed and queued rows are left alone.")
|
||||
odoosh_base_url = fields.Char(
|
||||
string='Platform URL',
|
||||
config_parameter='odoosh.base_url',
|
||||
help="Address of OdooshCN, for example https://lab.school.edu. No trailing slash.")
|
||||
odoosh_token = fields.Char(
|
||||
string='Access token',
|
||||
help="Created by an OdooshCN super administrator under Access tokens. Stored on the server only.")
|
||||
odoosh_token_set = fields.Boolean(string='Token configured', compute='_compute_token_set')
|
||||
|
||||
odoosh_tenant = fields.Char(
|
||||
string='Default organisation',
|
||||
config_parameter='odoosh.tenant',
|
||||
help="Slug of the platform organisation users land in. Empty means the organisation the token "
|
||||
"is bound to. Another organisation requires the token to allow it.")
|
||||
odoosh_default_version = fields.Char(
|
||||
string='Default Odoo version', config_parameter='odoosh.default_version', default='18',
|
||||
help="Odoo version used for new lab environments.")
|
||||
odoosh_default_lang = fields.Char(
|
||||
string='Default language', config_parameter='odoosh.default_lang', default='en_US',
|
||||
help="Initial language of new lab environments, for example en_US or zh_CN.")
|
||||
odoosh_default_mem_mb = fields.Integer(
|
||||
string='Memory limit per environment (MB)', config_parameter='odoosh.default_mem_mb',
|
||||
help="Empty uses the platform default. This value times the number of concurrent users "
|
||||
"should stay below the server memory.")
|
||||
odoosh_default_sleep_hours = fields.Float(
|
||||
string='Idle sleep (hours)', config_parameter='odoosh.default_sleep_hours',
|
||||
help="How long an environment may stay idle before it is put to sleep to free memory. "
|
||||
"It wakes up on the next visit. 0 disables sleeping.")
|
||||
|
||||
# ------------------------------------------------------------------ token
|
||||
@api.depends('odoosh_base_url')
|
||||
def _compute_token_set(self):
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
has = bool((icp.get_param('odoosh.token') or '').strip())
|
||||
for rec in self:
|
||||
rec.odoosh_token_set = has
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
values = super().get_values()
|
||||
token = (self.env['ir.config_parameter'].sudo().get_param('odoosh.token') or '').strip()
|
||||
# Only a mask plus the last 4 characters: enough to confirm it is set, never the secret
|
||||
values['odoosh_token'] = (MASK + token[-4:]) if token else ''
|
||||
return values
|
||||
|
||||
# Changing any of these changes the sync scope or target, so existing users are rescanned
|
||||
SCOPE_PARAMS = ('odoosh.enabled', 'odoosh.tenant', 'odoosh.base_url', 'odoosh.token')
|
||||
|
||||
def set_values(self):
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
before = {k: icp.get_param(k) for k in self.SCOPE_PARAMS}
|
||||
super().set_values()
|
||||
value = (self.odoosh_token or '').strip()
|
||||
if not value:
|
||||
icp.set_param('odoosh.token', '')
|
||||
elif not value.startswith(MASK): # the mask sent back untouched means "unchanged"
|
||||
icp.set_param('odoosh.token', value)
|
||||
after = {k: icp.get_param(k) for k in self.SCOPE_PARAMS}
|
||||
if after != before and after.get('odoosh.enabled') in ('True', 'true', '1', True):
|
||||
# Reset the scan cursor and rescan every user in the background after commit
|
||||
icp.set_param('odoosh.last_scan_at', '')
|
||||
dbname = self.env.cr.dbname
|
||||
self.env.cr.postcommit.add(
|
||||
lambda: self.env['odoosh.sync.log'].kick_async(dbname, scan_first=True))
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
def action_odoosh_test(self):
|
||||
"""Connectivity check: probes the platform, does not depend on any synced user, and
|
||||
reports whether the token carries the scopes this module needs."""
|
||||
self.ensure_one()
|
||||
client = self.env['odoosh.client']
|
||||
try:
|
||||
info = client.ping()
|
||||
except OdooshError as err:
|
||||
hint = ''
|
||||
if err.code == 'http_404':
|
||||
hint = _(" The platform answered 404, which usually means it still runs an older "
|
||||
"version without the integration API. Rebuild and restart it first.")
|
||||
elif err.code == 'unreachable':
|
||||
hint = _(" Check the URL and whether this server can reach it.")
|
||||
raise UserError(_("Connection failed (%(code)s): %(msg)s%(hint)s",
|
||||
code=err.code, msg=err.message, hint=hint))
|
||||
if not info.get('ok'):
|
||||
raise UserError(_("The platform returned something unexpected. Check its version."))
|
||||
|
||||
need = {'handoff': _("passwordless entry"), 'users:write': _("user sync"),
|
||||
'envs:read': _("read instances"), 'envs:write': _("create instances")}
|
||||
have = set(info.get('scopes') or [])
|
||||
missing = [label for scope, label in need.items() if scope not in have]
|
||||
limits = info.get('limits') or {}
|
||||
lines = [
|
||||
_("Organisation: %(name)s (%(slug)s)", name=info.get('tenant_name'), slug=info.get('tenant_slug')),
|
||||
_("Source: %s", info.get('source')),
|
||||
_("Scopes: %s", ', '.join(need.get(s, s) for s in sorted(have)) or _("none")),
|
||||
_("Limits: up to %(role)s, %(n)s instance(s) per user",
|
||||
role=limits.get('max_grant_role'), n=limits.get('max_envs_per_user')),
|
||||
]
|
||||
if not limits.get('allow_delete_user'):
|
||||
lines.append(_("Note: this token may not delete users, so deleting a user here only "
|
||||
"deactivates the platform account."))
|
||||
if info.get('expires_at'):
|
||||
lines.append(_("Token expires: %s", str(info['expires_at'])[:10]))
|
||||
if missing:
|
||||
lines.append(_("Missing scopes: %s. Edit the access token on the platform.", ', '.join(missing)))
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _("Connection OK") if not missing else _("Connected, but the token lacks scopes"),
|
||||
'message': '\n'.join(lines),
|
||||
'type': 'success' if not missing else 'warning',
|
||||
'sticky': bool(missing),
|
||||
},
|
||||
}
|
||||
|
||||
def action_odoosh_sync_all(self):
|
||||
"""Re-queue every user in scope."""
|
||||
return self.env['odoosh.sync.log'].sudo().action_sync_all()
|
||||
|
||||
def action_odoosh_open_logs(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Sync queue"),
|
||||
'res_model': 'odoosh.sync.log',
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""User synchronisation: the hooks, and the platform permission fields on the user.
|
||||
|
||||
There are only three rules, all implemented here:
|
||||
1. Once the platform URL and token are configured, creating a user syncs it right away
|
||||
and turns that user's sync switch on.
|
||||
2. Updating a user syncs when the switch is on and does nothing when it is off.
|
||||
3. Archiving deactivates the platform account, deleting deletes it, and restoring an
|
||||
archived user activates it again.
|
||||
|
||||
Everything the platform knows about a user - role, organisation, quotas, switches, menu
|
||||
visibility - is a field on the user form, so there is no hidden policy in the code.
|
||||
|
||||
Syncing never issues HTTP inside a request: the hooks only write a local queue row and a
|
||||
dedicated thread pushes after commit (see odoosh_sync_log.py).'
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Fields that end up in the payload; only these are worth a push
|
||||
POLICY_FIELDS = (
|
||||
'odoosh_username', 'odoosh_role', 'odoosh_tenant', 'odoosh_tenant_role',
|
||||
'odoosh_shell_enabled', 'odoosh_can_use_enterprise', 'odoosh_auto_provision',
|
||||
'odoosh_max_envs', 'odoosh_max_dbs',
|
||||
'odoosh_menu_dashboard', 'odoosh_menu_repos', 'odoosh_menu_ai', 'odoosh_menu_backups',
|
||||
)
|
||||
WATCHED_FIELDS = {'login', 'name', 'active', 'odoosh_sync_enabled', *POLICY_FIELDS}
|
||||
# Platform username rule: starts alphanumeric, may contain . _ - , 2 to 64 characters
|
||||
USERNAME_SAFE = re.compile(r'[^A-Za-z0-9_.-]')
|
||||
# Built-in accounts that are never synced: OdooBot, the public user, the portal template
|
||||
# and the new-user default template. They are not people. Administrator is a real login
|
||||
# and is synced like anybody else.
|
||||
SYSTEM_XMLIDS = ('base.user_root', 'base.public_user', 'base.template_user', 'base.default_user')
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
# ------------------------------------------------------------------ sync control and status
|
||||
odoosh_sync_enabled = fields.Boolean(
|
||||
string='Sync to lab platform', default=True, index=True,
|
||||
help="When off, nothing about this user is pushed: no updates, and archiving or "
|
||||
"deleting leaves the platform account untouched.")
|
||||
odoosh_sync_state = fields.Selection([
|
||||
('none', 'Not synced'),
|
||||
('pending', 'Pending'),
|
||||
('synced', 'Synced'),
|
||||
('failed', 'Failed'),
|
||||
], string='Sync status', default='none', readonly=True, copy=False, index=True)
|
||||
odoosh_synced_at = fields.Datetime(string='Last synced', readonly=True, copy=False)
|
||||
odoosh_last_error = fields.Text(string='Sync error', readonly=True, copy=False)
|
||||
odoosh_payload_hash = fields.Char(string='Payload fingerprint', readonly=True, copy=False,
|
||||
help="Fingerprint of the last successful push; identical content is not resent.")
|
||||
odoosh_platform_user_id = fields.Integer(string='Platform user ID', readonly=True, copy=False)
|
||||
odoosh_platform_username = fields.Char(string='Platform username', readonly=True, copy=False,
|
||||
help="The username actually in use on the platform.")
|
||||
|
||||
# ------------------------------------------------------------------ platform permissions
|
||||
odoosh_username = fields.Char(
|
||||
string='Platform username override', copy=False,
|
||||
help="Leave empty to derive it from the login (the part before @, cleaned up). Must be unique on the platform.")
|
||||
odoosh_role = fields.Selection([
|
||||
('member', 'Regular user'),
|
||||
('admin', 'Platform administrator'),
|
||||
], string='Platform role', default='member', required=True,
|
||||
help="A platform administrator sees every organisation. Requires the access token to allow it.")
|
||||
odoosh_tenant = fields.Char(
|
||||
string='Organisation', copy=False,
|
||||
help="Slug of the platform organisation. Empty falls back to the default in the settings, "
|
||||
"then to the organisation the token is bound to. Another organisation requires the token to allow it.")
|
||||
odoosh_tenant_role = fields.Selection([
|
||||
('tester', 'Tester (open instances only)'),
|
||||
('developer', 'Developer (edit code, read logs, reset)'),
|
||||
('owner', 'Organisation manager (see every instance)'),
|
||||
], string='Role in organisation', default='developer', required=True,
|
||||
help="Must not exceed the access token's maximum grantable role.")
|
||||
odoosh_shell_enabled = fields.Boolean(string='Web Shell', default=True,
|
||||
help="Allow opening a terminal on the instance, needed for psql.")
|
||||
odoosh_can_use_enterprise = fields.Boolean(string='Enterprise edition', default=False,
|
||||
help="Allow creating enterprise edition instances.")
|
||||
odoosh_auto_provision = fields.Boolean(string='Auto-provision environment', default=True,
|
||||
help="On sync, create a lab environment if this user has none yet.")
|
||||
odoosh_max_envs = fields.Integer(string='Instance limit', default=1, help="0 means unlimited.")
|
||||
odoosh_max_dbs = fields.Integer(string='Extra database limit', default=2, help="0 means unlimited.")
|
||||
odoosh_menu_dashboard = fields.Boolean(string='Menu: Overview', default=False)
|
||||
odoosh_menu_repos = fields.Boolean(string='Menu: Git repositories', default=False)
|
||||
odoosh_menu_ai = fields.Boolean(string='Menu: AI assistant', default=True)
|
||||
odoosh_menu_backups = fields.Boolean(string='Menu: Backups', default=False)
|
||||
|
||||
# Readable by the user themselves on the preferences page, but not writable
|
||||
@property
|
||||
def SELF_READABLE_FIELDS(self):
|
||||
return super().SELF_READABLE_FIELDS + ['odoosh_sync_state', 'odoosh_synced_at', 'odoosh_platform_username']
|
||||
|
||||
# ------------------------------------------------------------------ scope
|
||||
@api.model
|
||||
def _odoosh_configured(self):
|
||||
"""Hooks stay silent until the URL, the token and the master switch are all set."""
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
return (icp.get_param('odoosh.enabled') in ('True', 'true', '1', True)
|
||||
and bool((icp.get_param('odoosh.base_url') or '').strip())
|
||||
and bool((icp.get_param('odoosh.token') or '').strip()))
|
||||
|
||||
@api.model
|
||||
def _odoosh_scope_ctx(self):
|
||||
"""Constants shared by a whole batch: the ids of the built-in accounts."""
|
||||
system_ids = set()
|
||||
for xmlid in SYSTEM_XMLIDS:
|
||||
rec = self.env.ref(xmlid, raise_if_not_found=False)
|
||||
if rec:
|
||||
system_ids.add(rec.id)
|
||||
return {'system_ids': system_ids}
|
||||
|
||||
def _odoosh_eligible(self, ctx=None):
|
||||
"""Accounts that may be synced: internal users that are not built-in (switch ignored)."""
|
||||
ctx = ctx or self._odoosh_scope_ctx()
|
||||
system_ids = ctx['system_ids']
|
||||
return self.sudo().filtered(lambda u: not u.share and u.id not in system_ids)
|
||||
|
||||
def _odoosh_filter_in_scope(self, ctx=None):
|
||||
"""Users to sync: eligible and switch on."""
|
||||
return self._odoosh_eligible(ctx).filtered('odoosh_sync_enabled')
|
||||
|
||||
def _odoosh_in_scope(self, ctx=None):
|
||||
self.ensure_one()
|
||||
return bool(self._odoosh_filter_in_scope(ctx))
|
||||
|
||||
# ------------------------------------------------------------------ payload
|
||||
def _odoosh_username_auto(self):
|
||||
"""Derive the platform username from the login: the part before @, cleaned up."""
|
||||
self.ensure_one()
|
||||
raw = (self.login or '').split('@')[0]
|
||||
cleaned = USERNAME_SAFE.sub('', raw) or ('u%s' % self.id)
|
||||
if not cleaned[0].isalnum():
|
||||
cleaned = 'u' + cleaned
|
||||
if len(cleaned) < 2:
|
||||
cleaned = 'u%s' % self.id
|
||||
return cleaned[:64]
|
||||
|
||||
def _odoosh_tenant_value(self):
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
return (self.odoosh_tenant or '').strip() or (icp.get_param('odoosh.tenant') or '').strip() or None
|
||||
|
||||
def _odoosh_payload(self, ctx=None):
|
||||
"""What is sent to the platform; every value comes from a field on this user."""
|
||||
self.ensure_one()
|
||||
payload = {
|
||||
'external_id': str(self.id),
|
||||
'username': (self.odoosh_username or '').strip() or self._odoosh_username_auto(),
|
||||
'display_name': self.name or self.login,
|
||||
'active': bool(self.active),
|
||||
'role': self.odoosh_role or 'member',
|
||||
'tenant': self._odoosh_tenant_value(),
|
||||
'tenant_role': self.odoosh_tenant_role or 'developer',
|
||||
'shell_enabled': bool(self.odoosh_shell_enabled),
|
||||
'can_use_enterprise': bool(self.odoosh_can_use_enterprise),
|
||||
'quota': {
|
||||
'max_envs': None if not self.odoosh_max_envs else int(self.odoosh_max_envs), # 0 = unlimited
|
||||
'max_dbs': None if not self.odoosh_max_dbs else int(self.odoosh_max_dbs),
|
||||
},
|
||||
'menus': {
|
||||
'dashboard': bool(self.odoosh_menu_dashboard),
|
||||
'repos': bool(self.odoosh_menu_repos),
|
||||
'ai': bool(self.odoosh_menu_ai),
|
||||
'backups': bool(self.odoosh_menu_backups),
|
||||
},
|
||||
}
|
||||
if self.odoosh_auto_provision and self.active:
|
||||
payload['provision'] = self._odoosh_env_spec()
|
||||
return payload
|
||||
|
||||
def _odoosh_env_spec(self):
|
||||
"""Specification of the auto-provisioned environment: named after the platform
|
||||
username, everything else from the settings."""
|
||||
self.ensure_one()
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
spec = {
|
||||
'name': (self.odoosh_username or '').strip() or self._odoosh_username_auto(),
|
||||
'tenant': self._odoosh_tenant_value(),
|
||||
'kind': 'dev',
|
||||
'odoo_version': icp.get_param('odoosh.default_version') or '18',
|
||||
'edition': 'community',
|
||||
'lang': icp.get_param('odoosh.default_lang') or 'en_US',
|
||||
}
|
||||
for key, param, cast in (('mem_mb', 'odoosh.default_mem_mb', int),
|
||||
('sleep_idle_hours', 'odoosh.default_sleep_hours', float)):
|
||||
raw = icp.get_param(param)
|
||||
if raw:
|
||||
try:
|
||||
spec[key] = cast(raw)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return spec
|
||||
|
||||
def _odoosh_payload_hash(self, payload=None, ctx=None):
|
||||
self.ensure_one()
|
||||
data = payload if payload is not None else self._odoosh_payload(ctx)
|
||||
raw = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(',', ':'))
|
||||
return hashlib.sha1(raw.encode('utf-8')).hexdigest()
|
||||
|
||||
# ------------------------------------------------------------------ hooks
|
||||
def _odoosh_kick(self):
|
||||
dbname = self.env.cr.dbname
|
||||
self.env.cr.postcommit.add(lambda: self.env['odoosh.sync.log'].kick_async(dbname))
|
||||
|
||||
def _odoosh_enqueue(self, operation):
|
||||
"""Queue and let the post-commit thread push. Local writes only; every exception is
|
||||
swallowed and logged, because syncing must never block user management."""
|
||||
if self.env.context.get('odoosh_no_sync'):
|
||||
return
|
||||
try:
|
||||
if not self._odoosh_configured():
|
||||
return
|
||||
targets = self._odoosh_filter_in_scope()
|
||||
if not targets:
|
||||
return
|
||||
self.env['odoosh.sync.log'].sudo().enqueue(targets, operation)
|
||||
targets.with_context(odoosh_no_sync=True).write({'odoosh_sync_state': 'pending'})
|
||||
self._odoosh_kick()
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("OdooshCN enqueue failed (ignored, user operation unaffected)")
|
||||
|
||||
def _odoosh_enqueue_known(self, operation):
|
||||
"""Deactivate or delete: only for users whose switch is on and that the platform knows."""
|
||||
if self.env.context.get('odoosh_no_sync'):
|
||||
return
|
||||
try:
|
||||
if not self._odoosh_configured():
|
||||
return
|
||||
known = self.sudo().filtered(
|
||||
lambda u: u.odoosh_sync_enabled and u.odoosh_sync_state in ('synced', 'pending', 'failed'))
|
||||
if not known:
|
||||
return
|
||||
queue = self.env['odoosh.sync.log'].sudo()
|
||||
if operation == 'delete':
|
||||
queue.enqueue_delete(known)
|
||||
else:
|
||||
queue.enqueue(known, operation)
|
||||
self._odoosh_kick()
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("OdooshCN %s enqueue failed (ignored)", operation)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
users = super().create(vals_list)
|
||||
if self._odoosh_configured():
|
||||
# Configured: sync immediately and switch the new users on
|
||||
off = users._odoosh_eligible().filtered(lambda u: not u.odoosh_sync_enabled)
|
||||
if off:
|
||||
off.with_context(odoosh_no_sync=True).write({'odoosh_sync_enabled': True})
|
||||
users._odoosh_enqueue('upsert')
|
||||
return users
|
||||
|
||||
def write(self, vals):
|
||||
result = super().write(vals)
|
||||
if self.env.context.get('odoosh_no_sync') or not (WATCHED_FIELDS & set(vals)):
|
||||
return result
|
||||
if 'odoosh_sync_enabled' in vals and not vals['odoosh_sync_enabled']:
|
||||
return result # just switched off: leave the platform as it is
|
||||
todo = self.sudo().filtered('odoosh_sync_enabled')
|
||||
if not todo:
|
||||
return result # switch off: nothing to do
|
||||
if 'active' in vals and not vals['active']:
|
||||
todo._odoosh_enqueue_known('deactivate') # archived -> deactivate on the platform
|
||||
else:
|
||||
todo._odoosh_enqueue('upsert') # edited, or restored from the archive
|
||||
return result
|
||||
|
||||
def unlink(self):
|
||||
# Deleted here means deleted there. The platform moves the user's environments to
|
||||
# its recycle bin first, so the data stays recoverable for a while.
|
||||
self.sudo().filtered('odoosh_sync_enabled')._odoosh_enqueue_known('delete')
|
||||
return super().unlink()
|
||||
|
||||
# ------------------------------------------------------------------ status write-back
|
||||
def _odoosh_mark(self, state, error=None, payload_hash=None, result=None):
|
||||
"""Called by the sync thread. The odoosh_no_sync context stops it from looping."""
|
||||
vals = {'odoosh_sync_state': state, 'odoosh_last_error': error or False}
|
||||
if state == 'synced':
|
||||
vals['odoosh_synced_at'] = fields.Datetime.now()
|
||||
if payload_hash:
|
||||
vals['odoosh_payload_hash'] = payload_hash
|
||||
if result:
|
||||
if result.get('user_id'):
|
||||
vals['odoosh_platform_user_id'] = int(result['user_id'])
|
||||
if result.get('username'):
|
||||
vals['odoosh_platform_username'] = result['username']
|
||||
self.sudo().with_context(odoosh_no_sync=True).write(vals)
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
def action_odoosh_sync_now(self):
|
||||
"""Sync the selected users straight away, without waiting for the queue."""
|
||||
queue = self.env['odoosh.sync.log'].sudo()
|
||||
if not self._odoosh_configured():
|
||||
return self._odoosh_notify(
|
||||
_("Not configured"),
|
||||
_("Set the platform URL and access token in Settings > OdooshCN Lab Environments first."),
|
||||
'warning')
|
||||
targets = self._odoosh_filter_in_scope()
|
||||
if not targets:
|
||||
ctx = self._odoosh_scope_ctx()
|
||||
reasons = []
|
||||
for u in self.sudo():
|
||||
if u.id in ctx['system_ids']:
|
||||
reasons.append(_("%s: built-in Odoo account, never synced", u.login))
|
||||
elif u.share:
|
||||
reasons.append(_("%s: portal user, never synced", u.login))
|
||||
elif not u.odoosh_sync_enabled:
|
||||
reasons.append(_("%s: sync switch is off", u.login))
|
||||
return self._odoosh_notify(_("Nothing to sync"),
|
||||
'\n'.join(reasons) or _("None of the selected users is in scope."),
|
||||
'warning')
|
||||
logs = queue.enqueue(targets, 'upsert', force=True)
|
||||
ok = sum(1 for log in logs if log._process_one())
|
||||
return self._odoosh_notify(
|
||||
_("Sync finished"),
|
||||
_("%(ok)s of %(total)s succeeded. Open the sync queue to see why the others failed.",
|
||||
ok=ok, total=len(logs)),
|
||||
'success' if ok == len(logs) else 'warning')
|
||||
|
||||
def action_odoosh_enable_sync(self):
|
||||
self.write({'odoosh_sync_enabled': True})
|
||||
return self._odoosh_notify(_("Sync enabled"),
|
||||
_("%s users will be pushed within a few seconds.", len(self)), 'success')
|
||||
|
||||
def action_odoosh_disable_sync(self):
|
||||
self.with_context(odoosh_no_sync=True).write({'odoosh_sync_enabled': False})
|
||||
return self._odoosh_notify(_("Sync disabled"),
|
||||
_("%s users will no longer be pushed; their platform accounts stay as they are.",
|
||||
len(self)), 'info')
|
||||
|
||||
def action_odoosh_envs(self):
|
||||
self.ensure_one()
|
||||
return self.env['odoosh.client'].list_envs(str(self.id)).get('envs', [])
|
||||
|
||||
@staticmethod
|
||||
def _odoosh_notify(title, message, kind='info'):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {'title': title, 'message': message, 'type': kind, 'sticky': False},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_odoosh_sync_log_manager,odoosh.sync.log.manager,model_odoosh_sync_log,group_odoosh_teacher,1,1,0,0
|
||||
access_odoosh_sync_log_system,odoosh.sync.log.system,model_odoosh_sync_log,base.group_system,1,1,1,1
|
||||
|
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="module_category_odoosh" model="ir.module.category">
|
||||
<field name="name">Lab Environments</field>
|
||||
<field name="description">OdooshCN lab environment integration</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager: can see the sync queue and every user's platform settings -->
|
||||
<record id="group_odoosh_teacher" model="res.groups">
|
||||
<field name="name">Lab Environment Manager</field>
|
||||
<field name="category_id" ref="module_category_odoosh"/>
|
||||
<field name="comment">Can inspect the sync queue and manage the platform settings of other users.</field>
|
||||
</record>
|
||||
|
||||
<!-- Only managers and system administrators may read the sync queue -->
|
||||
<record id="rule_sync_log_teacher" model="ir.rule">
|
||||
<field name="name">Sync queue: managers only</field>
|
||||
<field name="model_id" ref="model_odoosh_sync_log"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,158 @@
|
||||
/** @odoo-module **/
|
||||
/**
|
||||
* The "My Lab Environment" client action.
|
||||
*
|
||||
* Shows the current user's environments as cards, and opens the platform through a
|
||||
* passwordless URL fetched from the server.
|
||||
*
|
||||
* The console (instance list, instance detail) is embedded in an iframe on this page, so
|
||||
* students stay inside the course system; the frame keeps an "Open in a new tab" button
|
||||
* because it does stack two top bars.
|
||||
*
|
||||
* The instance's own Odoo opens in a real tab instead: it is a full Odoo interface, and
|
||||
* nesting it here would stack three navigation bars and two sidebars, leaving nobody sure
|
||||
* which layer they are in.
|
||||
*
|
||||
* The platform has to allow being framed. Its console answers with X-Frame-Options
|
||||
* SAMEORIGIN by default, and the frame then stays blank; the administrator names this Odoo
|
||||
* in the console frame-ancestors policy (see frontend/nginx-console.inc on the platform).
|
||||
*
|
||||
* The URL is short lived and single use, so it is fetched on click and never cached - the
|
||||
* "new tab" button fetches one of its own instead of reusing the framed one.
|
||||
*/
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
const STATUS_LABEL = {
|
||||
running: _t("Running"),
|
||||
sleeping: _t("Sleeping"),
|
||||
stopped: _t("Stopped"),
|
||||
waking: _t("Waking up"),
|
||||
starting: _t("Starting"),
|
||||
provisioning: _t("Creating"),
|
||||
restarting: _t("Restarting"),
|
||||
deploying: _t("Deploying"),
|
||||
failed: _t("Failed"),
|
||||
created: _t("Not created yet"),
|
||||
};
|
||||
|
||||
const STATUS_CLASS = {
|
||||
running: "text-bg-success",
|
||||
sleeping: "text-bg-warning",
|
||||
waking: "text-bg-warning",
|
||||
starting: "text-bg-info",
|
||||
provisioning: "text-bg-info",
|
||||
restarting: "text-bg-info",
|
||||
deploying: "text-bg-info",
|
||||
failed: "text-bg-danger",
|
||||
};
|
||||
|
||||
export class OdooshMyEnv extends Component {
|
||||
static template = "odoosh_connector.MyEnv";
|
||||
static props = { ...standardActionServiceProps };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
error: null,
|
||||
envs: [],
|
||||
username: null,
|
||||
opening: null,
|
||||
frame: null, // {url, target, env, title} while a page is embedded below
|
||||
});
|
||||
onWillStart(() => this.load());
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const data = await rpc("/odoosh/my/envs", {});
|
||||
this.state.error = data.error || null;
|
||||
this.state.envs = data.envs || [];
|
||||
this.state.username = data.username || null;
|
||||
} catch {
|
||||
this.state.error = _t("Cannot reach the lab platform. Try again later or ask your administrator.");
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
statusLabel(env) {
|
||||
return STATUS_LABEL[env.status] || env.status;
|
||||
}
|
||||
|
||||
statusClass(env) {
|
||||
return STATUS_CLASS[env.status] || "text-bg-secondary";
|
||||
}
|
||||
|
||||
isBusy(env) {
|
||||
return ["provisioning", "starting", "restarting", "deploying", "waking"].includes(env.status);
|
||||
}
|
||||
|
||||
/** Fetch a passwordless URL and show it. target: console or odoo.
|
||||
* newTab skips the embedded frame and opens a real tab instead. */
|
||||
async open(target, env, newTab = false) {
|
||||
if (this.state.opening) {
|
||||
return;
|
||||
}
|
||||
this.state.opening = `${target}:${env ? env.name : "-"}`;
|
||||
// Open the blank tab first: fetching the URL is async, and opening a tab inside the
|
||||
// callback would be treated as a pop-up and blocked.
|
||||
const tab = newTab ? window.open("", "_blank") : null;
|
||||
try {
|
||||
const data = await rpc("/odoosh/my/url", { target, env: env ? env.name : null });
|
||||
if (data.error) {
|
||||
if (tab) {
|
||||
tab.close();
|
||||
}
|
||||
this.notification.add(data.error, { type: "danger", title: _t("Cannot open") });
|
||||
return;
|
||||
}
|
||||
if (!newTab) {
|
||||
this.state.frame = {
|
||||
url: data.url,
|
||||
target,
|
||||
env: env || null,
|
||||
title: env ? env.name : _t("Developer console"),
|
||||
};
|
||||
} else if (tab) {
|
||||
tab.location.href = data.url;
|
||||
} else {
|
||||
window.location.href = data.url; // pop-up blocked: navigate this tab instead
|
||||
}
|
||||
} catch {
|
||||
if (tab) {
|
||||
tab.close();
|
||||
}
|
||||
this.notification.add(_t("Could not obtain the entry URL. Try again."), { type: "danger" });
|
||||
} finally {
|
||||
this.state.opening = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Leave the embedded page and go back to the cards. */
|
||||
closeFrame() {
|
||||
this.state.frame = null;
|
||||
}
|
||||
|
||||
/** Same destination as the embedded one, in a real tab. Fetches a fresh URL: the framed
|
||||
* one has already been redeemed. */
|
||||
openFramedInTab() {
|
||||
const f = this.state.frame;
|
||||
if (f) {
|
||||
this.open(f.target, f.env, true);
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("odoosh_my_env", OdooshMyEnv);
|
||||
@@ -0,0 +1,37 @@
|
||||
.o_odoosh_env {
|
||||
max-width: 1200px;
|
||||
|
||||
// 嵌入模式:铺满可用宽高,不然控制台的侧栏会被 1200px 挤成一条
|
||||
&.o_odoosh_env_framed {
|
||||
max-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.o_odoosh_frame {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 70vh; // 外层高度撑不开时的下限,免得 iframe 被压成 0
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: .5rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.o_odoosh_card {
|
||||
transition: box-shadow .15s ease, transform .15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, .08);
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: .85em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="odoosh_connector.MyEnv">
|
||||
<div class="o_odoosh_env o_action p-4" t-att-class="{'o_odoosh_env_framed': state.frame}">
|
||||
|
||||
<!-- Embedded platform page: instance list, instance detail, or the instance's Odoo -->
|
||||
<t t-if="state.frame">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<button class="btn btn-secondary" t-on-click="() => this.closeFrame()">
|
||||
<i class="fa fa-angle-left me-1"/>Back
|
||||
</button>
|
||||
<h4 class="mb-0 flex-grow-1 text-truncate" t-esc="state.frame.title"/>
|
||||
<button class="btn btn-light" t-att-disabled="state.opening"
|
||||
t-on-click="() => this.openFramedInTab()">
|
||||
<i class="fa fa-external-link me-1"/>Open in a new tab
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<iframe class="o_odoosh_frame" t-att-src="state.frame.url" t-att-title="state.frame.title"/>
|
||||
|
||||
<div class="text-muted small mt-2">
|
||||
Stays blank? The platform only lets the sites named in its console frame-ancestors
|
||||
policy embed it, and the address in your browser's bar has to match it exactly,
|
||||
scheme and port included. Ask your administrator, or use the button above.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">My Lab Environment</h2>
|
||||
<div class="text-muted small" t-if="state.username">
|
||||
Platform account: <code t-esc="state.username"/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" t-on-click="() => this.refresh()" t-att-disabled="state.loading">
|
||||
<i class="fa fa-refresh me-1"/>Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div t-if="state.loading" class="text-center text-muted py-5">
|
||||
<i class="fa fa-circle-o-notch fa-spin fa-2x"/>
|
||||
<div class="mt-3">Loading your environments...</div>
|
||||
</div>
|
||||
|
||||
<div t-elif="state.error" class="alert alert-warning">
|
||||
<t t-esc="state.error"/>
|
||||
</div>
|
||||
|
||||
<div t-elif="!state.envs.length" class="text-center text-muted py-5">
|
||||
<i class="fa fa-cube fa-3x mb-3 d-block opacity-50"/>
|
||||
<p class="mb-1">No lab environment yet.</p>
|
||||
<p class="small">
|
||||
An environment is created automatically once your account is synced, usually within
|
||||
a few minutes. Ask your administrator if it does not appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div t-else="" class="row g-3">
|
||||
<div t-foreach="state.envs" t-as="env" t-key="env.id" class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card h-100 o_odoosh_card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||
<h5 class="card-title mb-0 text-truncate" t-esc="env.name"/>
|
||||
<span class="badge ms-2 flex-shrink-0"
|
||||
t-att-class="statusClass(env)" t-esc="statusLabel(env)"/>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mb-3">
|
||||
Odoo <t t-esc="env.odoo_version"/>
|
||||
<t t-if="env.edition === 'enterprise'"> · Enterprise</t>
|
||||
<t t-if="env.kind !== 'dev'"> · <t t-esc="env.kind"/></t>
|
||||
</div>
|
||||
|
||||
<div t-if="isBusy(env) and env.status_message" class="small text-muted mb-3">
|
||||
<i class="fa fa-clock-o me-1"/><t t-esc="env.status_message"/>
|
||||
<t t-if="env.progress"> (<t t-esc="env.progress"/>%)</t>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
t-on-click="() => this.open('odoo', env, true)"
|
||||
t-att-disabled="state.opening or env.status === 'failed'">
|
||||
<i class="fa fa-external-link me-1"/>Open Odoo
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
t-on-click="() => this.open('console', env)"
|
||||
t-att-disabled="state.opening">
|
||||
<i class="fa fa-code me-1"/>Developer console
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div t-if="env.status === 'sleeping'" class="text-muted small mt-2">
|
||||
This environment is asleep to save resources. Opening it wakes it up,
|
||||
which takes a few seconds.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="!state.loading and !state.error" class="mt-4">
|
||||
<button class="btn btn-link px-0" t-on-click="() => this.open('console', null)">
|
||||
See everything in the developer console
|
||||
<i class="fa fa-angle-right ms-1"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_odoosh_my_env" model="ir.actions.client">
|
||||
<field name="name">My Lab Environment</field>
|
||||
<field name="tag">odoosh_my_env</field>
|
||||
</record>
|
||||
|
||||
<!-- Standalone error page for /odoosh/go, so the module only depends on base and web -->
|
||||
<template id="entry_error" name="Lab environment entry error">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Cannot open the lab environment</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,'Segoe UI',sans-serif">
|
||||
<div style="max-width:560px;margin:12vh auto;padding:0 20px">
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:32px 36px;box-shadow:0 1px 3px rgba(0,0,0,.05)">
|
||||
<h2 style="margin:0 0 12px;font-size:20px;color:#dc2626">Cannot open the lab environment</h2>
|
||||
<p style="color:#475569;line-height:1.8;margin:0 0 8px"><t t-esc="message"/></p>
|
||||
<p t-if="code" style="color:#94a3b8;font-size:12px;margin:0 0 20px">
|
||||
Error code: <code t-esc="code"/>
|
||||
</p>
|
||||
<a href="/odoo" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:8px 18px;border-radius:6px">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- 让「我的实训」直接打开「我的实验环境」(My Lab Environment),
|
||||
去掉中间的「实验环境」子菜单层。 -->
|
||||
<menuitem id="college.menu_yuthon_training"
|
||||
name="我的实训"
|
||||
action="action_odoosh_my_env"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="odoosh_sync_log_view_list" model="ir.ui.view">
|
||||
<field name="name">odoosh.sync.log.list</field>
|
||||
<field name="model">odoosh.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Sync queue" decoration-danger="state == 'failed'"
|
||||
decoration-muted="state in ('cancelled', 'skipped')" decoration-success="state == 'done'">
|
||||
<field name="login"/>
|
||||
<field name="external_id"/>
|
||||
<field name="operation"/>
|
||||
<field name="state"/>
|
||||
<field name="attempts"/>
|
||||
<field name="force" optional="hide"/>
|
||||
<field name="next_retry_at" optional="show"/>
|
||||
<field name="error_code" optional="show"/>
|
||||
<field name="last_error" optional="hide"/>
|
||||
<field name="synced_at" optional="hide"/>
|
||||
<button name="action_retry" type="object" string="Retry" icon="fa-refresh"
|
||||
invisible="state not in ('failed', 'cancelled')"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="odoosh_sync_log_view_form" model="ir.ui.view">
|
||||
<field name="name">odoosh.sync.log.form</field>
|
||||
<field name="model">odoosh.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Sync record">
|
||||
<header>
|
||||
<button name="action_retry" type="object" string="Retry now" class="btn-primary"
|
||||
invisible="state == 'done'"/>
|
||||
<button name="action_cancel" type="object" string="Cancel"
|
||||
invisible="state not in ('pending', 'failed')"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="pending,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="user_id"/>
|
||||
<field name="login"/>
|
||||
<field name="external_id"/>
|
||||
<field name="operation"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="attempts"/>
|
||||
<field name="next_retry_at"/>
|
||||
<field name="synced_at"/>
|
||||
<field name="error_code"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Last error" invisible="not last_error">
|
||||
<field name="last_error" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Payload">
|
||||
<field name="payload_preview" nolabel="1" readonly="1" widget="text"/>
|
||||
</page>
|
||||
<page string="Response">
|
||||
<field name="result" nolabel="1" readonly="1" widget="text"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="odoosh_sync_log_view_search" model="ir.ui.view">
|
||||
<field name="name">odoosh.sync.log.search</field>
|
||||
<field name="model">odoosh.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Sync queue">
|
||||
<field name="login"/>
|
||||
<field name="external_id"/>
|
||||
<field name="error_code"/>
|
||||
<filter name="f_pending" string="Pending" domain="[('state', '=', 'pending')]"/>
|
||||
<filter name="f_failed" string="Failed" domain="[('state', '=', 'failed')]"/>
|
||||
<filter name="f_done" string="Done" domain="[('state', '=', 'done')]"/>
|
||||
<filter name="f_skipped" string="No change" domain="[('state', '=', 'skipped')]"/>
|
||||
<separator/>
|
||||
<filter name="f_upsert" string="Create / Update" domain="[('operation', '=', 'upsert')]"/>
|
||||
<filter name="f_deactivate" string="Deactivate" domain="[('operation', '=', 'deactivate')]"/>
|
||||
<filter name="f_delete" string="Delete" domain="[('operation', '=', 'delete')]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter name="g_state" string="Status" context="{'group_by': 'state'}"/>
|
||||
<filter name="g_op" string="Operation" context="{'group_by': 'operation'}"/>
|
||||
<filter name="g_code" string="Error code" context="{'group_by': 'error_code'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_odoosh_sync_log" model="ir.actions.act_window">
|
||||
<field name="name">Sync queue</field>
|
||||
<field name="res_model">odoosh.sync.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_f_failed': 1, 'search_default_f_pending': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">The sync queue is empty</p>
|
||||
<p>Creating, updating, archiving or deleting a user adds a row here, processed in the background.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.odoosh</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//block[@name='integration']" position="after">
|
||||
<block title="OdooshCN Lab Environments" name="odoosh_settings">
|
||||
|
||||
<setting id="odoosh_enable" string="Enable sync"
|
||||
help="Once on, creating, updating, archiving and deleting users is pushed to OdooshCN in the background.">
|
||||
<field name="odoosh_enabled"/>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_connection" string="Platform connection" invisible="not odoosh_enabled"
|
||||
help="The token is created by an OdooshCN super administrator and stored on the server only. For local testing use http://127.0.0.1:port rather than localhost.">
|
||||
<div class="content-group">
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_base_url" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_base_url" placeholder="https://lab.school.edu"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_token" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_token" password="True" placeholder="Token starting with osh_"/>
|
||||
</div>
|
||||
<div class="mt8">
|
||||
<button name="action_odoosh_test" type="object"
|
||||
string="Test connection" class="btn-link" icon="fa-plug"/>
|
||||
</div>
|
||||
<field name="odoosh_token_set" invisible="1"/>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_scope" string="How syncing works" invisible="not odoosh_enabled"
|
||||
help="It takes effect as soon as the connection above is filled in; there is nothing to set up per user.">
|
||||
<div class="text-muted small">
|
||||
<div>New user: synced immediately, and its sync switch is turned on</div>
|
||||
<div>Updated user: synced when the switch is on, ignored when it is off</div>
|
||||
<div>Archived user: the platform account is deactivated</div>
|
||||
<div>Deleted user: the platform account is deleted, its environments go to the platform recycle bin</div>
|
||||
<div>Role, quotas and menus per user live on the Lab Environment tab of the user form</div>
|
||||
<div>Portal users and built-in Odoo accounts are never synced</div>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_env_defaults" string="Environment defaults" invisible="not odoosh_enabled"
|
||||
help="Used when an environment is provisioned automatically. Memory limit times the number of concurrent users should stay below the server memory.">
|
||||
<div class="content-group">
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_tenant" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_tenant" placeholder="Empty = the organisation bound to the token"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_version" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_version" placeholder="18"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_lang" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_lang" placeholder="en_US"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_mem_mb" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_mem_mb" placeholder="Empty = platform default"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_sleep_hours" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_sleep_hours" placeholder="0 = never sleep"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_actions" string="Maintenance" invisible="not odoosh_enabled"
|
||||
help="After the first setup, or after changing the defaults, push every existing user once.">
|
||||
<div class="mt8">
|
||||
<button name="action_odoosh_sync_all" type="object"
|
||||
string="Sync all users" class="btn-link" icon="fa-refresh"/>
|
||||
<button name="action_odoosh_open_logs" type="object"
|
||||
string="Open sync queue" class="btn-link" icon="fa-list"/>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
</block>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- User form: the Lab Environment tab holds every platform permission; edit and it syncs -->
|
||||
<record id="view_users_form_odoosh" model="ir.ui.view">
|
||||
<field name="name">res.users.form.inherit.odoosh</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Lab Environment" name="odoosh"
|
||||
groups="base.group_system,odoosh_connector.group_odoosh_teacher">
|
||||
<group>
|
||||
<group string="Synchronisation">
|
||||
<field name="odoosh_sync_enabled"/>
|
||||
<field name="odoosh_sync_state"/>
|
||||
<field name="odoosh_synced_at"/>
|
||||
<field name="odoosh_platform_username"/>
|
||||
</group>
|
||||
<group string="Actions">
|
||||
<div colspan="2" class="d-flex gap-2 flex-wrap">
|
||||
<button name="action_odoosh_sync_now" type="object" string="Sync now"
|
||||
class="btn-primary" icon="fa-refresh" invisible="not odoosh_sync_enabled"/>
|
||||
</div>
|
||||
<div colspan="2" class="text-muted small mt-2">
|
||||
New users are synced automatically. Switching this off stops every push for
|
||||
this user and leaves the platform account as it is.
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Last error" invisible="not odoosh_last_error">
|
||||
<field name="odoosh_last_error" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<group invisible="not odoosh_sync_enabled">
|
||||
<group string="Identity and roles">
|
||||
<field name="odoosh_username" placeholder="Empty = derived from the login"/>
|
||||
<field name="odoosh_tenant" placeholder="Empty = default organisation"/>
|
||||
<field name="odoosh_role"/>
|
||||
<field name="odoosh_tenant_role"/>
|
||||
</group>
|
||||
<group string="Permissions and quotas">
|
||||
<field name="odoosh_shell_enabled"/>
|
||||
<field name="odoosh_can_use_enterprise"/>
|
||||
<field name="odoosh_auto_provision"/>
|
||||
<field name="odoosh_max_envs"/>
|
||||
<field name="odoosh_max_dbs"/>
|
||||
</group>
|
||||
<group string="Platform menus">
|
||||
<field name="odoosh_menu_dashboard"/>
|
||||
<field name="odoosh_menu_repos"/>
|
||||
<field name="odoosh_menu_ai"/>
|
||||
<field name="odoosh_menu_backups"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_users_tree_odoosh" model="ir.ui.view">
|
||||
<field name="name">res.users.list.inherit.odoosh</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='login_date']" position="after">
|
||||
<field name="odoosh_sync_enabled" optional="hide"/>
|
||||
<field name="odoosh_sync_state" optional="hide"/>
|
||||
<field name="odoosh_tenant_role" optional="hide"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_users_search_odoosh" model="ir.ui.view">
|
||||
<field name="name">res.users.search.inherit.odoosh</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//filter[@name='Inactive']" position="after">
|
||||
<separator/>
|
||||
<filter name="odoosh_failed" string="Lab sync failed" domain="[('odoosh_sync_state', '=', 'failed')]"/>
|
||||
<filter name="odoosh_pending" string="Lab sync pending" domain="[('odoosh_sync_state', '=', 'pending')]"/>
|
||||
<filter name="odoosh_disabled" string="Lab sync disabled" domain="[('odoosh_sync_enabled', '=', False)]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Bulk actions from the user list -->
|
||||
<record id="action_server_odoosh_sync_now" model="ir.actions.server">
|
||||
<field name="name">Sync to lab platform</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list,form</field>
|
||||
<field name="groups_id" eval="[(4, ref('odoosh_connector.group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_odoosh_sync_now()</field>
|
||||
</record>
|
||||
|
||||
<record id="action_server_odoosh_enable" model="ir.actions.server">
|
||||
<field name="name">Enable lab sync</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="groups_id" eval="[(4, ref('odoosh_connector.group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_odoosh_enable_sync()</field>
|
||||
</record>
|
||||
|
||||
<record id="action_server_odoosh_disable" model="ir.actions.server">
|
||||
<field name="name">Disable lab sync</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="groups_id" eval="[(4, ref('odoosh_connector.group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_odoosh_disable_sync()</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,2 @@
|
||||
# recents_limit —— 纯前端补丁模块,无 Python 模型
|
||||
# 仅通过 JS patch 把导航栏「最近」分组上限从 50 改为 20。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
'name': 'Recents Limit (20)',
|
||||
'version': '18.0.1.0.0',
|
||||
'summary': '把导航栏「最近」分组上限从 50 改为 20(继承 udoo_om_ux,不改动线上源码)',
|
||||
'description': """
|
||||
通过前端 patch 继承 udoo_om_ux 的 OmuxNavBar,把「最近访问」的分组上限
|
||||
从默认的 50 截到 20。
|
||||
|
||||
实现方式(不改动 udoo_om_ux 与 yuthon_base 的任何源码):
|
||||
1. setup 时把历史 localStorage 中可能 >20 的 recents 截断到 20;
|
||||
2. 监听 UDOO:FRC 事件,在每次新增「最近」后把上限压回 20。
|
||||
由于整个会话期间 this.uo.recents 恒 <= 20,beforeunload 持久化到
|
||||
localStorage 时自然也 <= 20,无需再 patch beforeunload 逻辑。
|
||||
""",
|
||||
'category': 'Tools',
|
||||
'depends': ['udoo_om_ux', 'web'],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'recents_limit/static/src/js/recents_limit.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
'author': 'Yuthon',
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** @odoo-module **/
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { OmuxNavBar } from "@udoo_om_ux/webclient/navbar/navbar";
|
||||
import { useBus } from "@web/core/utils/hooks";
|
||||
|
||||
// 「最近访问」分组的目标上限(线上 udoo_om_ux 默认为 50)
|
||||
const MAX_RECENTS = 20;
|
||||
|
||||
patch(OmuxNavBar.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
// 1) 处理从历史 localStorage 加载进来的 >20 条「最近」
|
||||
if (this.uo?.recents?.length > MAX_RECENTS) {
|
||||
this.uo.recents.length = MAX_RECENTS;
|
||||
}
|
||||
|
||||
// 2) 实时:每次新增「最近」访问后,把上限压回 20。
|
||||
// 原 OmuxNavBar 在 UDOO:FRC 事件里会把上限维持在 50,
|
||||
// 这里在其之后(先注册者先触发)再截到 20,
|
||||
// 于是整个会话期间 this.uo.recents 恒 <= 20,
|
||||
// 连带 beforeunload 持久化到 localStorage 也自然 <= 20。
|
||||
useBus(this.env.bus, "UDOO:FRC", () => {
|
||||
if (this.uo?.recents?.length > MAX_RECENTS) {
|
||||
this.uo.recents.length = MAX_RECENTS;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
'name': 'student_organization',
|
||||
'name': '学生组织',
|
||||
'version': '1.3',
|
||||
'summary': '',
|
||||
'summary': '管理学生组织架构、班级、年级等基础信息',
|
||||
'sequence': 11,
|
||||
'depends': ['base','hr'],
|
||||
'data':[
|
||||
@@ -14,6 +14,5 @@
|
||||
|
||||
'installable': True,
|
||||
'application': True,
|
||||
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 3.2 KiB |