Merge pull request '完善教育' (#24) from zhou into master
Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
'views/import_wizard_views.xml',
|
||||
'views/homework_views.xml',
|
||||
'views/training_views.xml',
|
||||
'views/course_import_wizard_views.xml',
|
||||
'data/about_pages.xml',
|
||||
'views/about_page_views.xml',
|
||||
'views/menus.xml',
|
||||
|
||||
@@ -9,3 +9,4 @@ from . import assignment_batch_wizard
|
||||
from . import import_wizard
|
||||
from . import about_page
|
||||
from . import training
|
||||
from . import course_import_wizard
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""课程批量导入向导 —— CSV 模板下载 + 批量创建课程(导入大纲即可)。
|
||||
|
||||
模板列:课程名称、课程编号、所属学院、学期、课程类型、课时、课程大纲。
|
||||
导入时直接创建 yuthon.school.course 记录。
|
||||
"""
|
||||
|
||||
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>保存为 CSV 后上传并点击「开始导入」;</li>'
|
||||
'<li>若课程编号已存在则跳过,不会重复创建。</li>'
|
||||
'</ol>'
|
||||
),
|
||||
)
|
||||
|
||||
# ─────────────────── 下载模板 ───────────────────
|
||||
|
||||
def action_download_template(self):
|
||||
"""生成 CSV 模板并提供下载。"""
|
||||
header = ['课程名称', '课程编号', '所属学院', '学期',
|
||||
'课程类型(理论/实践)', '课时', '课程大纲']
|
||||
sample = ['Python 编程基础', 'PY101', '计算机学院',
|
||||
'2025-2026第1学期', '理论', '48',
|
||||
'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
|
||||
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
|
||||
|
||||
# 编号去重
|
||||
if code:
|
||||
existing = self.env['yuthon.school.course'].sudo().search(
|
||||
[('code', '=', code)], limit=1)
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
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'] = float(hours_raw)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
syllabus = row.get('syllabus', '')
|
||||
if syllabus:
|
||||
vals['syllabus'] = syllabus
|
||||
|
||||
self.env['yuthon.school.course'].sudo().create(vals)
|
||||
created += 1
|
||||
|
||||
except Exception as exc:
|
||||
skipped += 1
|
||||
errors.append(
|
||||
_('第 %d 行「%s」导入失败:%s') % (row_idx, name, str(exc)))
|
||||
|
||||
summary = _(
|
||||
'导入完成!\n'
|
||||
'新建课程:%d\n'
|
||||
'跳过:%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': '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 ('课程大纲', 'syllabus', '大纲'):
|
||||
mapping[f] = 'syllabus'
|
||||
else:
|
||||
mapping[f] = f # 保留原名
|
||||
return mapping
|
||||
@@ -33,6 +33,8 @@ access_yuthon_school_course_chapter_attachment_student,yuthon.school.course.chap
|
||||
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
|
||||
|
||||
|
@@ -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>
|
||||
+53
-37
@@ -1,12 +1,36 @@
|
||||
<odoo>
|
||||
<!-- ===================== 我的实训(顶级菜单,位于学习管理之上) ===================== -->
|
||||
<!-- ===================== 我的实训(顶级菜单,置顶) ===================== -->
|
||||
<menuitem id="menu_yuthon_training"
|
||||
name="我的实训"
|
||||
web_icon="college,static/src/img/training.png"
|
||||
sequence="5"
|
||||
sequence="3"
|
||||
action="action_yuthon_school_training"
|
||||
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>
|
||||
|
||||
<!-- ===================== 我的课程(统一菜单,老师/学生/管理员都看到同一个入口) ===================== -->
|
||||
<menuitem id="menu_yuthon_course_my"
|
||||
name="我的课程"
|
||||
web_icon="college,static/src/img/my_course.png"
|
||||
action="action_yuthon_school_course_router"
|
||||
sequence="4"
|
||||
groups="college.group_teacher,college.group_student,base.group_system"/>
|
||||
|
||||
<!-- ===================== 隐藏 Odoo 默认「讨论」菜单 ===================== -->
|
||||
<record id="mail.menu_root_discuss" model="ir.ui.menu">
|
||||
<field name="active" eval="False"/>
|
||||
@@ -22,44 +46,22 @@
|
||||
<menuitem id="menu_yuthon_education_root"
|
||||
name="学习管理"
|
||||
web_icon="college,static/src/img/education.png"
|
||||
sequence="4"/>
|
||||
sequence="5"/>
|
||||
|
||||
<!-- ===================== 课程管理 ===================== -->
|
||||
<!-- ===================== 课程管理(直接指向全部课程的 action,不再有子菜单) ===================== -->
|
||||
<menuitem id="menu_yuthon_course_root"
|
||||
name="课程管理"
|
||||
web_icon="college,static/src/img/Course Management.png"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- 我的课程:仅老师(课程由老师创建) -->
|
||||
<menuitem id="menu_yuthon_course_my"
|
||||
name="我的课程"
|
||||
parent="menu_yuthon_course_root"
|
||||
action="action_yuthon_school_course_my"
|
||||
action="action_yuthon_school_course_all"
|
||||
sequence="10"
|
||||
groups="college.group_teacher,base.group_system"/>
|
||||
|
||||
<!-- 全部课程:仅老师(课程由老师创建;学生通过「课程学习」入口按选课过滤) -->
|
||||
<menuitem id="menu_yuthon_course_all"
|
||||
name="全部课程"
|
||||
parent="menu_yuthon_course_root"
|
||||
action="action_yuthon_school_course_all"
|
||||
sequence="20"
|
||||
groups="college.group_teacher,base.group_system"/>
|
||||
|
||||
<!-- 我的课程:学生端专用(老师完整表单 + Chatter,只看自己选课) -->
|
||||
<menuitem id="menu_yuthon_my_course"
|
||||
name="我的课程"
|
||||
web_icon="college,static/src/img/my_course.png"
|
||||
action="action_yuthon_school_my_course_student"
|
||||
sequence="2"
|
||||
groups="college.group_student"/>
|
||||
|
||||
<!-- 课程学习:学生端专用(简单表单 + 去学习按钮) -->
|
||||
<menuitem id="menu_yuthon_course_study"
|
||||
name="课程学习"
|
||||
web_icon="college,static/src/img/course_study.png"
|
||||
action="action_yuthon_school_course_study"
|
||||
sequence="3"
|
||||
sequence="2"
|
||||
groups="college.group_student"/>
|
||||
|
||||
<!-- ===================== 师生管理(顶级菜单,与「教育」平级,仅老师/管理员) ===================== -->
|
||||
@@ -86,12 +88,6 @@
|
||||
action="action_yuthon_school_student"
|
||||
sequence="30"
|
||||
groups="college.group_teacher,base.group_system"/>
|
||||
<menuitem id="menu_yuthon_import_wizard"
|
||||
name="导入师生"
|
||||
parent="menu_yuthon_org_root"
|
||||
action="action_yuthon_school_import_wizard"
|
||||
sequence="40"
|
||||
groups="college.group_teacher,base.group_system"/>
|
||||
|
||||
<!-- ===================== 学习进展(统一入口,服务端按角色路由) ===================== -->
|
||||
<menuitem id="menu_yuthon_progress"
|
||||
@@ -135,8 +131,8 @@
|
||||
sequence="40"
|
||||
groups="college.group_teacher,college.group_student,base.group_system"/>
|
||||
|
||||
<!-- ===================== 关于:内容页(系统说明 / 操作手册,指向预置的 yuthon.about.page 记录) ===================== -->
|
||||
<!-- 移除早期占位的 act_url 记录(若存在),改用 act_window 打开说明内容 -->
|
||||
<!-- ===================== 关于系统说明 ===================== -->
|
||||
<!-- 移除早期占位的 act_url 记录 -->
|
||||
<delete model="ir.actions.act_url" id="action_yuthon_about_system"/>
|
||||
<delete model="ir.actions.act_url" id="action_yuthon_about_operation"/>
|
||||
|
||||
@@ -157,7 +153,15 @@
|
||||
<field name="target">current</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 关于(顶级菜单,按截图顺序排在「我的实训」之后) ===================== -->
|
||||
<!-- 课程导入动作(关联 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"
|
||||
@@ -173,4 +177,16 @@
|
||||
parent="menu_yuthon_about"
|
||||
action="action_yuthon_about_operation"
|
||||
sequence="20"/>
|
||||
<menuitem id="menu_yuthon_import_wizard"
|
||||
name="导入师生"
|
||||
parent="menu_yuthon_about"
|
||||
action="action_yuthon_school_import_wizard"
|
||||
sequence="30"
|
||||
groups="college.group_teacher,base.group_system"/>
|
||||
<menuitem id="menu_yuthon_course_import_wizard"
|
||||
name="课程导入"
|
||||
parent="menu_yuthon_about"
|
||||
action="action_yuthon_school_course_import_wizard"
|
||||
sequence="40"
|
||||
groups="college.group_teacher,base.group_system"/>
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user