Files
shcool/college/models/course_import_wizard.py
T
2026-09-18 17:22:16 +08:00

302 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""课程批量导入向导 —— 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