新增功能 #10
@@ -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',
|
||||
],
|
||||
|
||||
@@ -6,3 +6,4 @@ from . import chapter
|
||||
from . import progress
|
||||
from . import assignment
|
||||
from . import assignment_batch_wizard
|
||||
from . import import_wizard
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
@@ -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"
|
||||
|
||||
@@ -14,6 +14,21 @@
|
||||
<field name="domain">['&', ('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>
|
||||
|
||||
Reference in New Issue
Block a user