"""课程批量导入向导 —— 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: _( '

模板说明:

' '
    ' '
  1. 点击下方「下载模板」按钮下载 CSV 模板;
  2. ' '
  3. 用 Excel / WPS 打开模板,按示例填写数据:
    ' '课程名称、课程编号 为必填;
    ' '所属学院、学期、课程类型、课时、课程介绍、业务情景 为选填;
  4. ' '
  5. 课程类型可选:理论 / 实践
  6. ' '
  7. 章节 / 任务(可选):填「章节标题」会自动建/复用章节,' '填「任务名称」会在该章节下建一条任务列表明细(是否完成填 是/否);' '同一课程、同一章节多行会自动合并,不会重复创建;
  8. ' '
  9. 保存为 CSV 后上传并点击「开始导入」;
  10. ' '
  11. 若课程名称已存在则复用,不会重复创建课程。
  12. ' '
' ), ) # ─────────────────── 下载模板 ─────────────────── 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