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: _( + '

模板说明:

' + '
    ' + '
  1. 点击下方「下载模板」按钮下载 CSV 模板;
  2. ' + '
  3. 用 Excel / WPS 打开模板,按示例填写数据:
    ' + '姓名、角色(老师/学生)、手机号 为必填;
    ' + '班级、学院、性别 为选填;
  4. ' + '
  5. 保存为 CSV(逗号分隔)后在此上传并点击「开始导入」;
  6. ' + '
  7. 系统将自动创建登录账号(账号=姓名拼音,密码=手机号)并关联师生档案;
  8. ' + '
  9. 若账号已存在则跳过,不会重复创建。
  10. ' + '
' + ), + ) + + # ─────────────────── 下载模板 ─────────────────── + + 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"/> - - - - + + action="action_yuthon_school_progress_router" + sequence="20" + groups="college.group_teacher,college.group_student,base.group_system"/> ['&', ('student_id.user_id', '=', uid), ('course_id.state', '=', 'published')] + + + 学习进展 + + code + 查看全部学习进展 +# 学生 -> 仅查看自己已发布课程的学习进展 +if env.user.has_group('college.group_teacher') or env.user.has_group('base.group_system'): + action = env.ref('college.action_yuthon_school_progress').sudo().read()[0] +else: + action = env.ref('college.action_yuthon_school_progress_student').sudo().read()[0] + ]]> + + yuthon.school.progress.list