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

模板说明:

' '
    ' '
  1. 点击下方「下载模板」按钮下载 CSV 模板;
  2. ' '
  3. 用 Excel / WPS 打开模板,按示例填写数据:
    ' '姓名、角色(老师/学生)、手机号 为必填;
    ' '班级、学院、性别 为选填;
  4. ' '
  5. 保存为 CSV(逗号分隔)后在此上传并点击「开始导入」;
  6. ' '
  7. 系统将自动创建登录账号(账号=姓名拼音,学生密码=学号、老师密码=工号)并关联师生档案;
  8. ' '
  9. 若账号已存在则跳过,不会重复创建。
  10. ' '
' ), ) # ─────────────────── 下载模板 ─────────────────── 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)