From a2bd6a5c78e3c58180a0b06c3e3f7ad490aeed16 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?=
<9664676+pengyuthon@user.noreply.gitee.com>
Date: Thu, 6 Aug 2026 19:07:15 +0800
Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9D=97=E8=BF=81=E7=A7=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
college/__manifest__.py | 13 +-
college/models/__init__.py | 1 +
college/models/import_wizard.py | 331 ++++++++++++++++++++++++++
college/security/ir.model.access.csv | 2 +
college/views/import_wizard_views.xml | 44 ++++
college/views/menus.xml | 26 +-
college/views/progress_views.xml | 15 ++
7 files changed, 415 insertions(+), 17 deletions(-)
create mode 100644 college/models/import_wizard.py
create mode 100644 college/views/import_wizard_views.xml
diff --git a/college/__manifest__.py b/college/__manifest__.py
index ba73898..1b97ea0 100644
--- a/college/__manifest__.py
+++ b/college/__manifest__.py
@@ -1,13 +1,14 @@
{
'name': '教育',
'version': '18.0.3.0.0',
- 'summary': 'Odoo 18 教学管理平台 — 组织架构 + 课程管理 + 作业/进展(老师·学生双角色)',
+ 'summary': 'Odoo 18 教学管理平台 — 师生管理 + 课程管理 + 作业/进展(老师·学生双角色)',
'description': """
Odoo 18 教学管理平台(面向老师与学生,双角色权限)
- 组织架构:
- - 班级 / 老师 / 学生;学生可关联 Odoo 登录账号
- - 选课学员直接取自组织架构中的学生
+ 师生管理:
+ - 班级 / 老师 / 学生;支持批量导入(CSV 模板)
+ - 导入时自动创建登录账号(账号=姓名拼音,密码=手机号)
+ - 选课学员直接取自师���管理中的学生
课程管理:
- 由老师创建并独立管理(学生仅可读,不能新建)
@@ -24,6 +25,9 @@
'category': 'Education',
'sequence': 20,
'depends': ['base', 'web'],
+ 'external_dependencies': {
+ 'python': ['pypinyin'],
+ },
'data': [
'security/groups.xml',
'security/ir.model.access.csv',
@@ -34,6 +38,7 @@
'views/chapter_views.xml',
'views/progress_views.xml',
'views/assignment_batch_wizard_views.xml',
+ 'views/import_wizard_views.xml',
'views/homework_views.xml',
'views/menus.xml',
],
diff --git a/college/models/__init__.py b/college/models/__init__.py
index 2baa425..49153d6 100644
--- a/college/models/__init__.py
+++ b/college/models/__init__.py
@@ -6,3 +6,4 @@ from . import chapter
from . import progress
from . import assignment
from . import assignment_batch_wizard
+from . import import_wizard
diff --git a/college/models/import_wizard.py b/college/models/import_wizard.py
new file mode 100644
index 0000000..6d3ded5
--- /dev/null
+++ b/college/models/import_wizard.py
@@ -0,0 +1,331 @@
+"""师生批量导入向导 —— 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', '计算机2024级1班', '计算机学院', '男']
+
+ 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_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()
+ 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')
+
+ try:
+ # 1. 创建/获取 res.users
+ login = _to_login(name)
+ if not login:
+ skipped += 1
+ errors.append(_('第 %d 行:无法生成登录账号,跳过。') % row_idx)
+ continue
+
+ user = self._get_or_create_user(login, name, phone, 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)
+ created_teachers += 1
+ else:
+ self._create_student(
+ user, name, phone, class_name, college_name, gender_raw)
+ 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 ('班级', '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, phone, 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)
+ Portal = self.env.ref('base.group_portal', raise_if_not_found=False)
+
+ existing = self.env['res.users'].sudo().search(
+ [('login', '=', login)], limit=1)
+ if existing:
+ # 已存在:检查角色兼容
+ 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)
+ if Portal:
+ groups.append(Portal.id)
+
+ return self.env['res.users'].sudo().create({
+ 'name': name,
+ 'login': login,
+ 'password': phone,
+ 'groups_id': [(6, 0, groups)],
+ })
+
+ def _create_teacher(self, user, name, phone):
+ """创建老师档案(去重)。"""
+ existing = self.env['yuthon.school.teacher'].sudo().search(
+ [('user_id', '=', user.id)], limit=1)
+ if existing:
+ return existing
+ return self.env['yuthon.school.teacher'].sudo().create({
+ 'name': name,
+ 'user_id': user.id,
+ 'phone': phone,
+ })
+
+ def _create_student(self, user, name, phone, class_name, college_name, gender_raw):
+ """创建学生档案(去重)��"""
+ existing = self.env['yuthon.school.student'].sudo().search(
+ [('user_id', '=', user.id)], limit=1)
+ if existing:
+ return existing
+
+ vals = {
+ 'name': name,
+ 'user_id': user.id,
+ 'phone': phone,
+ }
+ 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)
diff --git a/college/security/ir.model.access.csv b/college/security/ir.model.access.csv
index cbc0a4a..ecf967c 100644
--- a/college/security/ir.model.access.csv
+++ b/college/security/ir.model.access.csv
@@ -22,6 +22,8 @@ access_yuthon_school_assignment_submission_teacher,yuthon.school.assignment.subm
access_yuthon_school_assignment_submission_student,yuthon.school.assignment.submission.student,model_yuthon_school_assignment_submission,college.group_student,1,1,1,0
access_yuthon_school_assignment_submission_batch_wizard_admin,yuthon.school.assignment.submission.batch.wizard.admin,model_yuthon_school_assignment_submission_batch_wizard,base.group_system,1,1,1,1
access_yuthon_school_assignment_submission_batch_wizard_teacher,yuthon.school.assignment.submission.batch.wizard.teacher,model_yuthon_school_assignment_submission_batch_wizard,college.group_teacher,1,1,1,1
+access_yuthon_school_import_wizard_admin,yuthon.school.import.wizard.admin,model_yuthon_school_import_wizard,base.group_system,1,1,1,1
+access_yuthon_school_import_wizard_teacher,yuthon.school.import.wizard.teacher,model_yuthon_school_import_wizard,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_admin,yuthon.school.course.chapter.admin,model_yuthon_school_course_chapter,base.group_system,1,1,1,1
access_yuthon_school_course_chapter_teacher,yuthon.school.course.chapter.teacher,model_yuthon_school_course_chapter,college.group_teacher,1,1,1,1
access_yuthon_school_course_chapter_student,yuthon.school.course.chapter.student,model_yuthon_school_course_chapter,college.group_student,1,0,0,0
diff --git a/college/views/import_wizard_views.xml b/college/views/import_wizard_views.xml
new file mode 100644
index 0000000..d032273
--- /dev/null
+++ b/college/views/import_wizard_views.xml
@@ -0,0 +1,44 @@
+
+
+
+ yuthon.school.import.wizard.form
+ yuthon.school.import.wizard
+
+
+
+
+
+
+
+ 导入师生
+ yuthon.school.import.wizard
+ form
+ new
+
+
diff --git a/college/views/menus.xml b/college/views/menus.xml
index e255ed8..4b5455a 100644
--- a/college/views/menus.xml
+++ b/college/views/menus.xml
@@ -35,9 +35,9 @@
sequence="11"
groups="college.group_student,base.group_system"/>
-
+
@@ -59,20 +59,20 @@
action="action_yuthon_school_student"
sequence="30"
groups="college.group_teacher,base.group_system"/>
-
-
-
-