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