Files
shcool/college/models/import_wizard.py
T
2026-08-27 19:53:56 +08:00

355 lines
14 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.
"""师生批量导入向导 —— 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)