Merge pull request '新增功能' (#10) from zhou into master

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
zhouxufan
2026-08-06 19:15:05 +08:00
7 changed files with 415 additions and 17 deletions
+9 -4
View File
@@ -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',
],
+1
View File
@@ -6,3 +6,4 @@ from . import chapter
from . import progress
from . import assignment
from . import assignment_batch_wizard
from . import import_wizard
+331
View File
@@ -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: _(
'<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>系统将自动创建登录账号(账号=姓名拼音,密码=手机号)并关联师生档案;</li>'
'<li>若账号已存在则跳过,不会重复创建。</li>'
'</ol>'
),
)
# ─────────────────── 下载模板 ───────────────────
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)
+2
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
22 access_yuthon_school_assignment_submission_student yuthon.school.assignment.submission.student model_yuthon_school_assignment_submission college.group_student 1 1 1 0
23 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
24 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
25 access_yuthon_school_import_wizard_admin yuthon.school.import.wizard.admin model_yuthon_school_import_wizard base.group_system 1 1 1 1
26 access_yuthon_school_import_wizard_teacher yuthon.school.import.wizard.teacher model_yuthon_school_import_wizard college.group_teacher 1 1 1 1
27 access_yuthon_school_course_chapter_admin yuthon.school.course.chapter.admin model_yuthon_school_course_chapter base.group_system 1 1 1 1
28 access_yuthon_school_course_chapter_teacher yuthon.school.course.chapter.teacher model_yuthon_school_course_chapter college.group_teacher 1 1 1 1
29 access_yuthon_school_course_chapter_student yuthon.school.course.chapter.student model_yuthon_school_course_chapter college.group_student 1 0 0 0
+44
View File
@@ -0,0 +1,44 @@
<odoo>
<!-- ===================== 师生批量导入向导 ===================== -->
<record id="yuthon_school_import_wizard_view_form" model="ir.ui.view">
<field name="name">yuthon.school.import.wizard.form</field>
<field name="model">yuthon.school.import.wizard</field>
<field name="arch" type="xml">
<form string="导入师生">
<sheet>
<field name="note" widget="html" nolabel="1"/>
<group string="下载模板">
<div class="d-flex align-items-center"
style="padding: 12px 0;">
<button name="action_download_template"
type="object" string="📥 下载模板"
class="btn-primary"/>
<span class="text-muted ms-2 small">
模板为 CSV 格式,可用 Excel / WPS 打开编辑
</span>
</div>
</group>
<group string="上传文件">
<field name="data_file" filename="file_name"/>
<field name="file_name" invisible="1"/>
</group>
</sheet>
<footer>
<button name="action_import" type="object"
string="开始导入" class="btn-primary"
invisible="not data_file"/>
<button string="取消" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- 导入动作 -->
<record id="action_yuthon_school_import_wizard" model="ir.actions.act_window">
<field name="name">导入师生</field>
<field name="res_model">yuthon.school.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>
+13 -13
View File
@@ -35,9 +35,9 @@
sequence="11"
groups="college.group_student,base.group_system"/>
<!-- ===================== 组织架构(顶级菜单,与「教育」平级,仅老师/管理员) ===================== -->
<!-- ===================== 师生管理(顶级菜单,与「教育」平级,仅老师/管理员) ===================== -->
<menuitem id="menu_yuthon_org_root"
name="组织架构"
name="师生管理"
web_icon="college,static/src/img/organization.png"
sequence="20"
groups="college.group_teacher,base.group_system"/>
@@ -59,20 +59,20 @@
action="action_yuthon_school_student"
sequence="30"
groups="college.group_teacher,base.group_system"/>
<!-- ===================== 学习进展(老师/学生各自独立入口,action domain 过滤) ===================== -->
<menuitem id="menu_yuthon_progress_teacher"
name="学习进展"
parent="menu_yuthon_education_root"
action="action_yuthon_school_progress"
sequence="20"
<menuitem id="menu_yuthon_import_wizard"
name="导入师生"
parent="menu_yuthon_org_root"
action="action_yuthon_school_import_wizard"
sequence="40"
groups="college.group_teacher,base.group_system"/>
<menuitem id="menu_yuthon_progress_student"
<!-- ===================== 学习进展(统一入口,服务端按角色路由) ===================== -->
<menuitem id="menu_yuthon_progress"
name="学习进展"
parent="menu_yuthon_education_root"
action="action_yuthon_school_progress_student"
sequence="21"
groups="college.group_student,base.group_system"/>
action="action_yuthon_school_progress_router"
sequence="20"
groups="college.group_teacher,college.group_student,base.group_system"/>
<!-- ===================== 作业习题 ===================== -->
<menuitem id="menu_yuthon_homework"
+15
View File
@@ -14,6 +14,21 @@
<field name="domain">['&amp;', ('student_id.user_id', '=', uid), ('course_id.state', '=', 'published')]</field>
</record>
<!-- ===================== 学习进展:统一入口(按角色动态路由) ===================== -->
<record id="action_yuthon_school_progress_router" model="ir.actions.server">
<field name="name">学习进展</field>
<field name="model_id" ref="model_yuthon_school_progress"/>
<field name="state">code</field>
<field name="code"><![CDATA[
# 老师 / 管理员 -> 查看全部学习进展
# 学生 -> 仅查看自己已发布课程的学习进展
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]
]]></field>
</record>
<!-- ===================== 学习进展:列表 ===================== -->
<record id="yuthon_school_progress_view_list" model="ir.ui.view">
<field name="name">yuthon.school.progress.list</field>