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