模块迁移

This commit is contained in:
李鹏宇 2026-07-28 18:50:48 +08:00
parent 36b6cb1e0e
commit 26c311d913
23 changed files with 2253 additions and 0 deletions

View File

@ -0,0 +1,3 @@
from . import models
from . import controllers
from . import wizards

View File

@ -0,0 +1,35 @@
{
'name': 'Yuthon School',
'version': '18.0.2.2.0',
'summary': 'Odoo 18 教学平台 — 体系教材 + 组织架构 + 学习跟踪',
'description': """
Odoo 18 框架学习教学平台
- 体系教材教材 章节 视频带学时统计
- 组织架构学校班级老师学生基础信息管理
- 学习跟踪学生进度跟踪教师可查看
- 树形学习目录 Odoo 18 知识体系分类
- 支持上传本地教学视频内置播放器
""",
'author': 'Yuthon',
'website': '',
'category': 'Education',
'depends': ['base', 'web', 'mail'],
'data': [
'security/ir.model.access.csv',
'views/video_category_views.xml',
'views/video_playback_views.xml',
'views/textbook_views.xml',
'views/organization_views.xml',
'views/progress_views.xml',
'views/elective_views.xml',
'views/progress_wizard_views.xml',
'views/menus.xml',
],
'demo': [
'data/demo.xml',
],
'license': 'LGPL-3',
'installable': True,
'application': True,
'auto_install': False,
}

View File

@ -0,0 +1 @@
from . import main

View File

@ -0,0 +1,77 @@
import base64
import os
import re
from odoo import http
from odoo.http import request
class VideoPlaybackController(http.Controller):
@http.route('/yuthon_school/video/<int:video_id>', type='http', auth='user', methods=['GET'])
def serve_video(self, video_id, **kwargs):
"""提供视频流服务,支持 Range 请求以实现视频进度拖拽"""
video = request.env['yuthon.school.video'].sudo().browse(video_id)
if not video.exists() or not video.video_file:
return request.not_found()
video_data = self._decode_video(video.video_file)
file_size = len(video_data)
mime_map = {
'mp4': 'video/mp4',
'webm': 'video/webm',
'ogg': 'video/ogg',
'ogv': 'video/ogg',
'mov': 'video/quicktime',
'mkv': 'video/x-matroska',
'avi': 'video/x-msvideo',
}
mime_type = 'video/mp4'
if video.video_filename:
ext = os.path.splitext(video.video_filename)[1].lower().lstrip('.')
mime_type = mime_map.get(ext, 'video/mp4')
# 处理 Range 请求(视频进度条拖拽需要)
range_header = request.httprequest.headers.get('Range')
if range_header:
range_match = re.search(r'bytes=(\d+)-(\d*)', range_header)
if range_match:
start = int(range_match.group(1))
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
if start >= file_size:
headers = [('Content-Range', f'bytes */{file_size}')]
return request.make_response(b'', headers, status=416)
end = min(end, file_size - 1)
chunk = video_data[start:end + 1]
headers = [
('Content-Type', mime_type),
('Content-Length', str(len(chunk))),
('Content-Range', f'bytes {start}-{end}/{file_size}'),
('Accept-Ranges', 'bytes'),
]
return request.make_response(chunk, headers, status=206)
# 返回完整视频内容(在线播放)
headers = [
('Content-Type', mime_type),
('Content-Length', str(file_size)),
('Accept-Ranges', 'bytes'),
]
return request.make_response(video_data, headers)
@staticmethod
def _decode_video(raw_file):
"""解码 Odoo 18 Binary 字段数据"""
if isinstance(raw_file, bytes):
try:
return base64.b64decode(raw_file, validate=True)
except Exception:
return raw_file
elif isinstance(raw_file, str):
return base64.b64decode(raw_file)
return b''

307
yuthon_school/data/demo.xml Normal file
View File

@ -0,0 +1,307 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Demo 演示数据
============
安装模块时勾选「加载演示数据」即可自动创建以下种子数据:
- 学习分类树4 个分类)
- 1 所学校(求知中学)
- 3 位老师
- 2 个班级(入门班 / 进阶班)
- 10 个学生(每班 5 人)
- 1 本教材5 个章节)
- 2 门选修课(演示 Many2many
- 若干学习进度记录
教学知识点XML 数据加载、noupdate 标签、外键 ref 引用、
Many2many eval 写法 [(6, 0, [ids])]
-->
<!-- ============ 学习分类树 ============ -->
<record id="demo_category_root" model="yuthon.school.category">
<field name="name">Odoo 18 开发</field>
<field name="level">beginner</field>
</record>
<record id="demo_category_basic" model="yuthon.school.category">
<field name="name">基础入门</field>
<field name="parent_id" ref="demo_category_root"/>
<field name="level">beginner</field>
</record>
<record id="demo_category_model" model="yuthon.school.category">
<field name="name">模型开发</field>
<field name="parent_id" ref="demo_category_root"/>
<field name="level">intermediate</field>
</record>
<record id="demo_category_view" model="yuthon.school.category">
<field name="name">视图开发</field>
<field name="parent_id" ref="demo_category_root"/>
<field name="level">intermediate</field>
</record>
<!-- ============ 学校 ============ -->
<record id="demo_school" model="yuthon.school.school">
<field name="name">求知中学</field>
<field name="address">北京市海淀区中关村南大街 1 号</field>
<field name="phone">010-88888001</field>
<field name="principal">赵校长</field>
</record>
<!-- ============ 老师 ============ -->
<record id="demo_teacher_zhang" model="yuthon.school.teacher">
<field name="name">张老师</field>
<field name="school_id" ref="demo_school"/>
<field name="phone">138-0000-0001</field>
<field name="subject">Odoo 模型开发</field>
</record>
<record id="demo_teacher_li" model="yuthon.school.teacher">
<field name="name">李老师</field>
<field name="school_id" ref="demo_school"/>
<field name="phone">138-0000-0002</field>
<field name="subject">Odoo 视图开发</field>
</record>
<record id="demo_teacher_wang" model="yuthon.school.teacher">
<field name="name">王老师</field>
<field name="school_id" ref="demo_school"/>
<field name="phone">138-0000-0003</field>
<field name="subject">Odoo 部署运维</field>
</record>
<!-- ============ 班级 ============ -->
<record id="demo_class_beginner" model="yuthon.school.class">
<field name="name">Odoo 18 入门班</field>
<field name="school_id" ref="demo_school"/>
<field name="teacher_id" ref="demo_teacher_zhang"/>
<field name="grade">2026 级</field>
</record>
<record id="demo_class_advanced" model="yuthon.school.class">
<field name="name">Odoo 18 进阶班</field>
<field name="school_id" ref="demo_school"/>
<field name="teacher_id" ref="demo_teacher_li"/>
<field name="grade">2026 级</field>
</record>
<!-- ============ 学生(入门班 5 人) ============ -->
<record id="demo_student_01" model="yuthon.school.student">
<field name="name">陈小明</field>
<field name="student_no">S20260001</field>
<field name="class_id" ref="demo_class_beginner"/>
<field name="gender">male</field>
<field name="phone">139-0000-0001</field>
<field name="enrollment_date">2026-03-01</field>
</record>
<record id="demo_student_02" model="yuthon.school.student">
<field name="name">林小芳</field>
<field name="student_no">S20260002</field>
<field name="class_id" ref="demo_class_beginner"/>
<field name="gender">female</field>
<field name="phone">139-0000-0002</field>
<field name="enrollment_date">2026-03-01</field>
</record>
<record id="demo_student_03" model="yuthon.school.student">
<field name="name">黄小杰</field>
<field name="student_no">S20260003</field>
<field name="class_id" ref="demo_class_beginner"/>
<field name="gender">male</field>
<field name="phone">139-0000-0003</field>
<field name="enrollment_date">2026-03-01</field>
</record>
<record id="demo_student_04" model="yuthon.school.student">
<field name="name">刘小婷</field>
<field name="student_no">S20260004</field>
<field name="class_id" ref="demo_class_beginner"/>
<field name="gender">female</field>
<field name="phone">139-0000-0004</field>
<field name="enrollment_date">2026-03-01</field>
</record>
<record id="demo_student_05" model="yuthon.school.student">
<field name="name">周小强</field>
<field name="student_no">S20260005</field>
<field name="class_id" ref="demo_class_beginner"/>
<field name="gender">male</field>
<field name="phone">139-0000-0005</field>
<field name="enrollment_date">2026-03-01</field>
</record>
<!-- ============ 学生(进阶班 5 人) ============ -->
<record id="demo_student_06" model="yuthon.school.student">
<field name="name">吴小燕</field>
<field name="student_no">S20260006</field>
<field name="class_id" ref="demo_class_advanced"/>
<field name="gender">female</field>
<field name="phone">139-0000-0006</field>
<field name="enrollment_date">2026-03-01</field>
</record>
<record id="demo_student_07" model="yuthon.school.student">
<field name="name">郑小伟</field>
<field name="student_no">S20260007</field>
<field name="class_id" ref="demo_class_advanced"/>
<field name="gender">male</field>
<field name="phone">139-0000-0007</field>
<field name="enrollment_date">2026-03-01</field>
</record>
<record id="demo_student_08" model="yuthon.school.student">
<field name="name">孙小丽</field>
<field name="student_no">S20260008</field>
<field name="class_id" ref="demo_class_advanced"/>
<field name="gender">female</field>
<field name="phone">139-0000-0008</field>
<field name="enrollment_date">2026-03-01</field>
</record>
<record id="demo_student_09" model="yuthon.school.student">
<field name="name">胡小军</field>
<field name="student_no">S20260009</field>
<field name="class_id" ref="demo_class_advanced"/>
<field name="gender">male</field>
<field name="phone">139-0000-0009</field>
<field name="enrollment_date">2026-03-01</field>
</record>
<record id="demo_student_10" model="yuthon.school.student">
<field name="name">高小静</field>
<field name="student_no">S20260010</field>
<field name="class_id" ref="demo_class_advanced"/>
<field name="gender">female</field>
<field name="phone">139-0000-0010</field>
<field name="enrollment_date">2026-03-01</field>
</record>
<!-- ============ 教材 + 章节 ============ -->
<record id="demo_textbook" model="yuthon.school.textbook">
<field name="name">Odoo 18 框架开发实战</field>
<field name="category_id" ref="demo_category_root"/>
<field name="difficulty">beginner</field>
<field name="state">published</field>
<field name="description">从零开始学习 Odoo 18 框架开发,涵盖模块结构、模型定义、视图架构、权限机制等核心知识点。</field>
</record>
<record id="demo_chapter_01" model="yuthon.school.chapter">
<field name="name">第一章 模块结构与清单文件</field>
<field name="textbook_id" ref="demo_textbook"/>
<field name="sequence">10</field>
<field name="summary">了解 Odoo 模块的目录结构和 __manifest__.py 清单文件</field>
<field name="key_points">__manifest__.py 字段、data 文件加载顺序、addons_path 配置</field>
<field name="hours">2.0</field>
</record>
<record id="demo_chapter_02" model="yuthon.school.chapter">
<field name="name">第二章 模型定义与字段类型</field>
<field name="textbook_id" ref="demo_textbook"/>
<field name="sequence">20</field>
<field name="summary">掌握 Odoo 模型定义、字段类型和关系字段</field>
<field name="key_points">Char/Integer/Float/Selection、Many2one/One2many/Many2many、compute/store</field>
<field name="hours">4.0</field>
</record>
<record id="demo_chapter_03" model="yuthon.school.chapter">
<field name="name">第三章 视图架构</field>
<field name="textbook_id" ref="demo_textbook"/>
<field name="sequence">30</field>
<field name="summary">学习 list/form/search/kanban 四种核心视图</field>
<field name="key_points">list 视图、form 视图、search 视图、kanban 看板视图</field>
<field name="hours">3.0</field>
</record>
<record id="demo_chapter_04" model="yuthon.school.chapter">
<field name="name">第四章 权限与安全机制</field>
<field name="textbook_id" ref="demo_textbook"/>
<field name="sequence">40</field>
<field name="summary">理解 ir.model.access.csv 和 ir.rule 记录规则</field>
<field name="key_points">模型访问权限、记录规则、用户组</field>
<field name="hours">2.5</field>
</record>
<record id="demo_chapter_05" model="yuthon.school.chapter">
<field name="name">第五章 控制器与路由</field>
<field name="textbook_id" ref="demo_textbook"/>
<field name="sequence">50</field>
<field name="summary">学习 HTTP 控制器和 URL 路由</field>
<field name="key_points">Controller、@http.route、请求处理、模板渲染</field>
<field name="hours">3.0</field>
</record>
<!-- ============ 选修课Many2many 演示) ============ -->
<record id="demo_elective_owl" model="yuthon.school.elective">
<field name="name">OWL 前端框架入门</field>
<field name="teacher_id" ref="demo_teacher_li"/>
<field name="description">学习 Odoo Web Library (OWL) 框架基础,理解组件化前端开发。</field>
<field name="student_ids" eval="[(6, 0, [ref('demo_student_01'), ref('demo_student_02'), ref('demo_student_03')])]"/>
</record>
<record id="demo_elective_workflow" model="yuthon.school.elective">
<field name="name">Odoo 工作流开发</field>
<field name="teacher_id" ref="demo_teacher_zhang"/>
<field name="description">深入学习 Odoo 工作流机制,掌握状态机和业务流程设计。</field>
<field name="student_ids" eval="[(6, 0, [ref('demo_student_06'), ref('demo_student_07'), ref('demo_student_08')])]"/>
</record>
<!-- ============ 学习进度记录 ============ -->
<record id="demo_progress_01" model="yuthon.school.progress">
<field name="student_id" ref="demo_student_01"/>
<field name="textbook_id" ref="demo_textbook"/>
<field name="chapter_id" ref="demo_chapter_01"/>
<field name="progress">100</field>
<field name="completed">True</field>
<field name="last_study_date">2026-07-20 10:00:00</field>
</record>
<record id="demo_progress_02" model="yuthon.school.progress">
<field name="student_id" ref="demo_student_01"/>
<field name="textbook_id" ref="demo_textbook"/>
<field name="chapter_id" ref="demo_chapter_02"/>
<field name="progress">65</field>
<field name="completed">False</field>
<field name="last_study_date">2026-07-25 14:00:00</field>
</record>
<record id="demo_progress_03" model="yuthon.school.progress">
<field name="student_id" ref="demo_student_02"/>
<field name="textbook_id" ref="demo_textbook"/>
<field name="chapter_id" ref="demo_chapter_01"/>
<field name="progress">100</field>
<field name="completed">True</field>
<field name="last_study_date">2026-07-22 09:30:00</field>
</record>
<record id="demo_progress_04" model="yuthon.school.progress">
<field name="student_id" ref="demo_student_06"/>
<field name="textbook_id" ref="demo_textbook"/>
<field name="chapter_id" ref="demo_chapter_03"/>
<field name="progress">40</field>
<field name="completed">False</field>
<field name="last_study_date">2026-07-26 16:00:00</field>
</record>
<record id="demo_progress_05" model="yuthon.school.progress">
<field name="student_id" ref="demo_student_07"/>
<field name="textbook_id" ref="demo_textbook"/>
<field name="chapter_id" ref="demo_chapter_01"/>
<field name="progress">0</field>
<field name="completed">False</field>
<field name="last_study_date">2026-07-28 08:00:00</field>
</record>
</odoo>

View File

@ -0,0 +1,6 @@
from . import video_playback
from . import video_category
from . import textbook
from . import organization
from . import progress
from . import elective

View File

@ -0,0 +1,41 @@
from odoo import fields, models, api
class Elective(models.Model):
_name = 'yuthon.school.elective'
_description = '选修课'
name = fields.Char(
string='选修课名称',
required=True,
help='Odoo 工作流开发、OWL 前端框架入门',
)
description = fields.Text(
string='课程简介',
)
teacher_id = fields.Many2one(
'yuthon.school.teacher',
string='授课教师',
)
student_ids = fields.Many2many(
'yuthon.school.student',
'yuthon_elective_student_rel',
'elective_id',
'student_id',
string='选课学生',
help='选择选修该课程的学生Many2many 关系演示)',
)
student_count = fields.Integer(
string='选课人数',
compute='_compute_student_count',
store=True,
)
active = fields.Boolean(
string='启用',
default=True,
)
@api.depends('student_ids')
def _compute_student_count(self):
for record in self:
record.student_count = len(record.student_ids)

View File

@ -0,0 +1,167 @@
from odoo import fields, models, api
class School(models.Model):
_name = 'yuthon.school.school'
_description = '学校'
name = fields.Char(
string='学校名称',
required=True,
)
address = fields.Char(
string='地址',
)
phone = fields.Char(
string='联系电话',
)
principal = fields.Char(
string='校长',
)
class_ids = fields.One2many(
'yuthon.school.class',
'school_id',
string='班级',
)
class_count = fields.Integer(
string='班级数',
compute='_compute_counts',
)
teacher_ids = fields.One2many(
'yuthon.school.teacher',
'school_id',
string='教师',
)
teacher_count = fields.Integer(
string='教师数',
compute='_compute_counts',
)
active = fields.Boolean(
string='启用',
default=True,
)
@api.depends('class_ids', 'teacher_ids')
def _compute_counts(self):
for record in self:
record.class_count = len(record.class_ids)
record.teacher_count = len(record.teacher_ids)
class SchoolClass(models.Model):
_name = 'yuthon.school.class'
_description = '班级'
name = fields.Char(
string='班级名称',
required=True,
)
school_id = fields.Many2one(
'yuthon.school.school',
string='所属学校',
required=True,
ondelete='cascade',
)
teacher_id = fields.Many2one(
'yuthon.school.teacher',
string='班主任',
)
grade = fields.Char(
string='年级',
)
student_ids = fields.One2many(
'yuthon.school.student',
'class_id',
string='学生',
)
student_count = fields.Integer(
string='学生数',
compute='_compute_student_count',
store=True,
)
active = fields.Boolean(
string='启用',
default=True,
)
@api.depends('student_ids')
def _compute_student_count(self):
for record in self:
record.student_count = len(record.student_ids)
class Teacher(models.Model):
_name = 'yuthon.school.teacher'
_description = '老师'
name = fields.Char(
string='姓名',
required=True,
)
school_id = fields.Many2one(
'yuthon.school.school',
string='所属学校',
required=True,
ondelete='cascade',
)
phone = fields.Char(
string='联系电话',
)
subject = fields.Char(
string='教授方向',
help='Odoo 模型开发、视图开发、部署运维等',
)
class_ids = fields.One2many(
'yuthon.school.class',
'teacher_id',
string='负责班级',
)
active = fields.Boolean(
string='启用',
default=True,
)
class Student(models.Model):
_name = 'yuthon.school.student'
_description = '学生'
name = fields.Char(
string='姓名',
required=True,
)
student_no = fields.Char(
string='学号',
)
class_id = fields.Many2one(
'yuthon.school.class',
string='所属班级',
ondelete='restrict',
)
school_id = fields.Many2one(
'yuthon.school.school',
string='所属学校',
related='class_id.school_id',
store=True,
)
gender = fields.Selection(
[('male', ''), ('female', '')],
string='性别',
)
phone = fields.Char(
string='联系电话',
)
enrollment_date = fields.Date(
string='入学日期',
)
elective_ids = fields.Many2many(
'yuthon.school.elective',
'yuthon_elective_student_rel',
'student_id',
'elective_id',
string='已选选修课',
)
active = fields.Boolean(
string='启用',
default=True,
)

View File

@ -0,0 +1,84 @@
from odoo import fields, models, api
from odoo.exceptions import ValidationError
class StudyProgress(models.Model):
_name = 'yuthon.school.progress'
_description = '学习进度'
_order = 'student_id, textbook_id, chapter_id, sequence'
_inherit = ['mail.thread', 'mail.activity.mixin']
student_id = fields.Many2one(
'yuthon.school.student',
string='学生',
required=True,
ondelete='cascade',
)
textbook_id = fields.Many2one(
'yuthon.school.textbook',
string='教材',
ondelete='restrict',
)
chapter_id = fields.Many2one(
'yuthon.school.chapter',
string='章节',
ondelete='restrict',
)
video_id = fields.Many2one(
'yuthon.school.video',
string='视频',
ondelete='restrict',
)
sequence = fields.Integer(
string='排序',
default=10,
)
progress = fields.Float(
string='进度(%)',
default=0.0,
help='0-100表示该视频的学习完成百分比',
)
completed = fields.Boolean(
string='已完成',
default=False,
)
state = fields.Selection(
[('not_started', '未开始'), ('in_progress', '进行中'), ('completed', '已完成')],
string='学习状态',
compute='_compute_state',
store=True,
)
last_study_date = fields.Datetime(
string='最后学习时间',
)
teacher_id = fields.Many2one(
'yuthon.school.teacher',
string='跟踪教师',
related='student_id.class_id.teacher_id',
store=True,
help='该学生所在班级的班主任,用于教师查看进度',
)
active = fields.Boolean(
string='启用',
default=True,
)
@api.depends('progress', 'completed')
def _compute_state(self):
"""根据进度和完成标记自动计算学习状态"""
for record in self:
if record.completed or record.progress >= 100:
record.state = 'completed'
elif record.progress > 0:
record.state = 'in_progress'
else:
record.state = 'not_started'
@api.constrains('progress')
def _check_progress_range(self):
"""验证进度值必须在 0-100 之间"""
for record in self:
if record.progress < 0 or record.progress > 100:
raise ValidationError(
f"进度值 {record.progress} 无效,必须在 0 到 100 之间"
)

View File

@ -0,0 +1,147 @@
from odoo import fields, models, api
class Textbook(models.Model):
_name = 'yuthon.school.textbook'
_description = '教材'
_order = 'category_id, sequence, name'
name = fields.Char(
string='教材名称',
required=True,
help='Odoo 18 基础入门、Odoo 18 视图开发实战',
)
sequence = fields.Integer(
string='排序',
default=10,
)
category_id = fields.Many2one(
'yuthon.school.category',
string='学习分类',
ondelete='restrict',
help='该教材所属的学习分类',
)
description = fields.Text(
string='教材简介',
help='教材的内容概要和适用人群',
)
difficulty = fields.Selection(
[('beginner', '入门'), ('intermediate', '进阶'), ('advanced', '高级')],
string='难度等级',
default='beginner',
)
state = fields.Selection(
[('draft', '草稿'), ('published', '已发布')],
string='状态',
default='draft',
)
total_hours = fields.Float(
string='总学时',
compute='_compute_total_hours',
store=True,
help='自动统计各章学时之和',
)
total_chapters = fields.Integer(
string='章节数',
compute='_compute_total_chapters',
store=True,
)
total_videos = fields.Integer(
string='视频数',
compute='_compute_total_videos',
store=True,
)
chapter_ids = fields.One2many(
'yuthon.school.chapter',
'textbook_id',
string='章节',
)
active = fields.Boolean(
string='启用',
default=True,
)
@api.depends('chapter_ids.hours')
def _compute_total_hours(self):
for record in self:
record.total_hours = sum(record.chapter_ids.mapped('hours'))
@api.depends('chapter_ids')
def _compute_total_chapters(self):
for record in self:
record.total_chapters = len(record.chapter_ids)
@api.depends('chapter_ids.video_ids')
def _compute_total_videos(self):
for record in self:
record.total_videos = sum(len(ch.video_ids) for ch in record.chapter_ids)
def action_confirm(self):
"""发布教材:将状态从草稿改为已发布"""
self.write({'state': 'published'})
def action_set_draft(self):
"""退回草稿:将状态从已发布改回草稿"""
self.write({'state': 'draft'})
class Chapter(models.Model):
_name = 'yuthon.school.chapter'
_description = '章节'
_order = 'textbook_id, sequence, name'
_sql_constraints = [
('unique_chapter_seq',
'unique(textbook_id, sequence)',
'同一教材下章节序号不能重复!'),
]
name = fields.Char(
string='章节标题',
required=True,
)
sequence = fields.Integer(
string='排序',
default=10,
)
textbook_id = fields.Many2one(
'yuthon.school.textbook',
string='所属教材',
required=True,
ondelete='cascade',
)
summary = fields.Text(
string='内容概述',
help='本章涵盖的内容概述',
)
key_points = fields.Text(
string='知识要点',
help='本章需要掌握的关键知识点',
)
hours = fields.Float(
string='学时',
default=1.0,
help='本章预计学习时长(小时)',
)
video_ids = fields.One2many(
'yuthon.school.video',
'chapter_id',
string='视频',
)
video_count = fields.Integer(
string='视频数',
compute='_compute_video_count',
store=True,
)
@api.depends('video_ids')
def _compute_video_count(self):
for record in self:
record.video_count = len(record.video_ids)
@api.onchange('video_ids')
def _onchange_video_ids(self):
"""当视频列表变化时,自动建议学时(每个视频按 0.5 学时计算)"""
if self.video_ids:
suggested = max(1.0, len(self.video_ids) * 0.5)
self.hours = suggested

View File

@ -0,0 +1,74 @@
from odoo import fields, models, api
class VideoCategory(models.Model):
_name = 'yuthon.school.category'
_description = '学习目录'
_order = 'complete_name'
_parent_store = True
name = fields.Char(
string='目录名称',
required=True,
help='Odoo 基础入门、模型开发、视图开发等',
)
parent_id = fields.Many2one(
'yuthon.school.category',
string='上级目录',
index=True,
ondelete='restrict',
help='选择上级目录以建立层级结构',
)
parent_path = fields.Char(
index=True,
unaccent=False,
)
child_ids = fields.One2many(
'yuthon.school.category',
'parent_id',
string='下级目录',
)
complete_name = fields.Char(
string='完整路径',
compute='_compute_complete_name',
store=True,
recursive=True,
help='显示完整路径Odoo 开发 > 模型开发 > 关系字段',
)
level = fields.Selection(
[('beginner', '入门'), ('intermediate', '进阶'), ('advanced', '高级')],
string='难度等级',
default='beginner',
help='该目录对应的学习难度',
)
description = fields.Text(
string='目录说明',
help='该学习目录的详细说明',
)
video_ids = fields.One2many(
'yuthon.school.video',
'category_id',
string='教学视频',
)
video_count = fields.Integer(
string='视频数量',
compute='_compute_video_count',
store=True,
)
active = fields.Boolean(
string='启用',
default=True,
)
@api.depends('name', 'parent_id.complete_name')
def _compute_complete_name(self):
for record in self:
if record.parent_id:
record.complete_name = f'{record.parent_id.complete_name} > {record.name}'
else:
record.complete_name = record.name
@api.depends('video_ids')
def _compute_video_count(self):
for record in self:
record.video_count = len(record.video_ids)

View File

@ -0,0 +1,147 @@
import markupsafe
from odoo import fields, models, api
from odoo.exceptions import ValidationError
class VideoPlayback(models.Model):
_name = 'yuthon.school.video'
_description = '教学视频'
_order = 'category_id, sequence, name'
name = fields.Char(
string='视频标题',
required=True,
help='Odoo 18 模块结构详解、Binary 字段用法等',
)
sequence = fields.Integer(
string='排序',
default=10,
help='同一目录下的视频排序序号',
)
category_id = fields.Many2one(
'yuthon.school.category',
string='学习目录',
ondelete='restrict',
help='该视频所属的学习目录分类',
)
chapter_id = fields.Many2one(
'yuthon.school.chapter',
string='所属章节',
ondelete='restrict',
help='该视频所属的教材章节',
)
difficulty = fields.Selection(
[('beginner', '入门'), ('intermediate', '进阶'), ('advanced', '高级')],
string='难度等级',
default='beginner',
help='该视频的学习难度',
)
duration = fields.Char(
string='视频时长',
help='15:30 表示 15 分钟 30 秒',
)
video_file = fields.Binary(
string='视频文件',
attachment=True,
required=True,
help='上传本地教学视频(支持 MP4、WebM、OGG 等格式)',
)
video_filename = fields.Char(
string='文件名',
help='视频文件的原始文件名',
)
file_size_display = fields.Char(
string='文件大小',
compute='_compute_file_info',
help='视频文件的大小',
)
description = fields.Text(
string='视频简介',
help='该教学视频的内容概要',
)
key_points = fields.Text(
string='知识要点',
help='该视频涵盖的关键知识点',
)
active = fields.Boolean(
string='启用',
default=True,
)
video_player = fields.Html(
string='视频播放器',
compute='_compute_video_player',
sanitize=False,
)
@api.depends('video_file', 'video_filename')
def _compute_file_info(self):
"""计算文件大小,显示为可读格式"""
for record in self:
if record.video_file:
encoded = record.video_file
if isinstance(encoded, bytes):
padding = 2 if encoded.endswith(b'==') else (1 if encoded.endswith(b'=') else 0)
else:
padding = 2 if encoded.endswith('==') else (1 if encoded.endswith('=') else 0)
size = (len(encoded) * 3) // 4 - padding
if size < 1024:
record.file_size_display = f'{size} B'
elif size < 1024 * 1024:
record.file_size_display = f'{size / 1024:.1f} KB'
elif size < 1024 * 1024 * 1024:
record.file_size_display = f'{size / (1024 * 1024):.1f} MB'
else:
record.file_size_display = f'{size / (1024 * 1024 * 1024):.1f} GB'
else:
record.file_size_display = '0 B'
@api.depends('video_file', 'video_filename')
def _compute_video_player(self):
"""生成视频播放器HTML"""
mime_map = {
'mp4': 'video/mp4',
'webm': 'video/webm',
'ogg': 'video/ogg',
'ogv': 'video/ogg',
'mov': 'video/quicktime',
'mkv': 'video/x-matroska',
'avi': 'video/x-msvideo',
}
for record in self:
if record.video_file and record.id:
mime_type = 'video/mp4'
if record.video_filename:
ext = record.video_filename.rsplit('.', 1)[-1].lower() if '.' in record.video_filename else ''
mime_type = mime_map.get(ext, 'video/mp4')
html = (
'<div style="text-align: center; padding: 15px; '
'background: #000; border-radius: 8px;">'
'<video controls preload="metadata" '
'style="width: 100%; max-width: 900px; border-radius: 4px;">'
f'<source src="/yuthon_school/video/{record.id}" type="{mime_type}">'
'<p style="color: #fff; padding: 20px;">'
'您的浏览器不支持视频播放。</p>'
'</video></div>'
)
record.video_player = markupsafe.Markup(html)
else:
record.video_player = markupsafe.Markup(
'<div style="text-align: center; color: #999; '
'padding: 30px; background: #f5f5f5; border-radius: 8px;">'
'<p>请上传视频文件并保存后查看播放。</p></div>'
)
@api.constrains('video_filename')
def _check_video_file(self):
"""验证上传的文件是否为视频格式"""
allowed_extensions = ['mp4', 'webm', 'ogg', 'ogv', 'mov', 'mkv', 'avi']
for record in self:
if record.video_filename:
ext = record.video_filename.rsplit('.', 1)[-1].lower() if '.' in record.video_filename else ''
if ext and ext not in allowed_extensions:
raise ValidationError(
f'请上传视频文件支持格式MP4、WebM、OGG、MOV、MKV、AVI当前文件格式为 .{ext}'
)

View File

@ -0,0 +1,12 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_yuthon_school_video_user,yuthon.school.video.user,model_yuthon_school_video,base.group_user,1,1,1,1
access_yuthon_school_category_user,yuthon.school.category.user,model_yuthon_school_category,base.group_user,1,1,1,1
access_yuthon_school_textbook_user,yuthon.school.textbook.user,model_yuthon_school_textbook,base.group_user,1,1,1,1
access_yuthon_school_chapter_user,yuthon.school.chapter.user,model_yuthon_school_chapter,base.group_user,1,1,1,1
access_yuthon_school_school_user,yuthon.school.school.user,model_yuthon_school_school,base.group_user,1,1,1,1
access_yuthon_school_class_user,yuthon.school.class.user,model_yuthon_school_class,base.group_user,1,1,1,1
access_yuthon_school_teacher_user,yuthon.school.teacher.user,model_yuthon_school_teacher,base.group_user,1,1,1,1
access_yuthon_school_student_user,yuthon.school.student.user,model_yuthon_school_student,base.group_user,1,1,1,1
access_yuthon_school_progress_user,yuthon.school.progress.user,model_yuthon_school_progress,base.group_user,1,1,1,1
access_yuthon_school_elective_user,yuthon.school.elective.user,model_yuthon_school_elective,base.group_user,1,1,1,1
access_yuthon_school_progress_wizard_user,yuthon.school.progress.wizard.user,model_yuthon_school_progress_wizard,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yuthon_school_video_user yuthon.school.video.user model_yuthon_school_video base.group_user 1 1 1 1
3 access_yuthon_school_category_user yuthon.school.category.user model_yuthon_school_category base.group_user 1 1 1 1
4 access_yuthon_school_textbook_user yuthon.school.textbook.user model_yuthon_school_textbook base.group_user 1 1 1 1
5 access_yuthon_school_chapter_user yuthon.school.chapter.user model_yuthon_school_chapter base.group_user 1 1 1 1
6 access_yuthon_school_school_user yuthon.school.school.user model_yuthon_school_school base.group_user 1 1 1 1
7 access_yuthon_school_class_user yuthon.school.class.user model_yuthon_school_class base.group_user 1 1 1 1
8 access_yuthon_school_teacher_user yuthon.school.teacher.user model_yuthon_school_teacher base.group_user 1 1 1 1
9 access_yuthon_school_student_user yuthon.school.student.user model_yuthon_school_student base.group_user 1 1 1 1
10 access_yuthon_school_progress_user yuthon.school.progress.user model_yuthon_school_progress base.group_user 1 1 1 1
11 access_yuthon_school_elective_user yuthon.school.elective.user model_yuthon_school_elective base.group_user 1 1 1 1
12 access_yuthon_school_progress_wizard_user yuthon.school.progress.wizard.user model_yuthon_school_progress_wizard base.group_user 1 1 1 1

View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- List View -->
<record id="view_yuthon_school_elective_list" model="ir.ui.view">
<field name="name">yuthon.school.elective.list</field>
<field name="model">yuthon.school.elective</field>
<field name="arch" type="xml">
<list string="选修课">
<field name="name"/>
<field name="teacher_id"/>
<field name="student_count"/>
<field name="student_ids" widget="many2many_tags"
options="{'color_field': 'none'}"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_yuthon_school_elective_form" model="ir.ui.view">
<field name="name">yuthon.school.elective.form</field>
<field name="model">yuthon.school.elective</field>
<field name="arch" type="xml">
<form string="选修课">
<sheet>
<group>
<group>
<field name="name"/>
<field name="teacher_id"/>
</group>
<group>
<field name="student_count"/>
<field name="active" invisible="1"/>
</group>
</group>
<group>
<field name="description"/>
</group>
<notebook>
<page string="选课学生" name="student_ids">
<field name="student_ids">
<list editable="bottom">
<field name="name"/>
<field name="student_no"/>
<field name="class_id"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_yuthon_school_elective_search" model="ir.ui.view">
<field name="name">yuthon.school.elective.search</field>
<field name="model">yuthon.school.elective</field>
<field name="arch" type="xml">
<search string="搜索选修课">
<field name="name"/>
<field name="teacher_id"/>
<field name="student_ids"/>
<separator/>
<group expand="0" string="分组">
<filter name="groupby_teacher" string="授课教师"
context="{'group_by': 'teacher_id'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_yuthon_school_elective" model="ir.actions.act_window">
<field name="name">选修课</field>
<field name="res_model">yuthon.school.elective</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_yuthon_school_elective_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
暂无选修课记录
</p>
<p>
选修课与学生之间是 Many2many多对多关系
一个学生可选多门选修课,一门选修课可有多个学生。
</p>
</field>
</record>
</odoo>

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Root menu -->
<menuitem id="menu_yuthon_school_root"
name="Yuthon School"
web_icon="yuthon_school,static/description/icon.png"
sequence="10"/>
<!-- ============ 体系教材 ============ -->
<menuitem id="menu_yuthon_school_curriculum"
name="体系教材"
parent="menu_yuthon_school_root"
sequence="10"/>
<menuitem id="menu_yuthon_school_category"
name="学习分类"
parent="menu_yuthon_school_curriculum"
action="action_yuthon_school_category"
sequence="10"/>
<menuitem id="menu_yuthon_school_textbook"
name="教材管理"
parent="menu_yuthon_school_curriculum"
action="action_yuthon_school_textbook"
sequence="20"/>
<menuitem id="menu_yuthon_school_video"
name="视频管理"
parent="menu_yuthon_school_curriculum"
action="action_yuthon_school_video"
sequence="30"/>
<menuitem id="menu_yuthon_school_progress"
name="学习进度"
parent="menu_yuthon_school_curriculum"
action="action_yuthon_school_progress"
sequence="40"/>
<!-- ============ 组织架构 ============ -->
<menuitem id="menu_yuthon_school_organization"
name="组织架构"
parent="menu_yuthon_school_root"
sequence="20"/>
<menuitem id="menu_yuthon_school_school"
name="学校管理"
parent="menu_yuthon_school_organization"
action="action_yuthon_school_school"
sequence="10"/>
<menuitem id="menu_yuthon_school_class"
name="班级管理"
parent="menu_yuthon_school_organization"
action="action_yuthon_school_class"
sequence="20"/>
<menuitem id="menu_yuthon_school_teacher"
name="老师管理"
parent="menu_yuthon_school_organization"
action="action_yuthon_school_teacher"
sequence="30"/>
<menuitem id="menu_yuthon_school_student"
name="学生管理"
parent="menu_yuthon_school_organization"
action="action_yuthon_school_student"
sequence="40"/>
<menuitem id="menu_yuthon_school_elective"
name="选修课"
parent="menu_yuthon_school_organization"
action="action_yuthon_school_elective"
sequence="50"/>
</odoo>

View File

@ -0,0 +1,336 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 学校 ============ -->
<record id="view_yuthon_school_school_list" model="ir.ui.view">
<field name="name">yuthon.school.school.list</field>
<field name="model">yuthon.school.school</field>
<field name="arch" type="xml">
<list string="学校">
<field name="name"/>
<field name="principal"/>
<field name="phone"/>
<field name="class_count"/>
<field name="teacher_count"/>
</list>
</field>
</record>
<record id="view_yuthon_school_school_form" model="ir.ui.view">
<field name="name">yuthon.school.school.form</field>
<field name="model">yuthon.school.school</field>
<field name="arch" type="xml">
<form string="学校">
<sheet>
<group>
<group>
<field name="name"/>
<field name="principal"/>
<field name="phone"/>
</group>
<group>
<field name="address"/>
<field name="class_count"/>
<field name="teacher_count"/>
<field name="active" invisible="1"/>
</group>
</group>
<notebook>
<page string="班级" name="class_page">
<field name="class_ids" nolabel="1">
<list>
<field name="name"/>
<field name="grade"/>
<field name="teacher_id"/>
<field name="student_count"/>
</list>
</field>
</page>
<page string="教师" name="teacher_page">
<field name="teacher_ids" nolabel="1">
<list>
<field name="name"/>
<field name="phone"/>
<field name="subject"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_yuthon_school_school_search" model="ir.ui.view">
<field name="name">yuthon.school.school.search</field>
<field name="model">yuthon.school.school</field>
<field name="arch" type="xml">
<search string="搜索学校">
<field name="name"/>
<field name="principal"/>
</search>
</field>
</record>
<record id="action_yuthon_school_school" model="ir.actions.act_window">
<field name="name">学校管理</field>
<field name="res_model">yuthon.school.school</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_yuthon_school_school_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
创建第一所学校
</p>
</field>
</record>
<!-- ============ 班级 ============ -->
<record id="view_yuthon_school_class_list" model="ir.ui.view">
<field name="name">yuthon.school.class.list</field>
<field name="model">yuthon.school.class</field>
<field name="arch" type="xml">
<list string="班级">
<field name="name"/>
<field name="school_id"/>
<field name="grade"/>
<field name="teacher_id"/>
<field name="student_count"/>
</list>
</field>
</record>
<record id="view_yuthon_school_class_form" model="ir.ui.view">
<field name="name">yuthon.school.class.form</field>
<field name="model">yuthon.school.class</field>
<field name="arch" type="xml">
<form string="班级">
<sheet>
<group>
<group>
<field name="name"/>
<field name="school_id"/>
<field name="teacher_id"/>
<field name="grade"/>
</group>
<group>
<field name="student_count"/>
<field name="active" invisible="1"/>
</group>
</group>
<notebook>
<page string="学生" name="student_page">
<field name="student_ids" nolabel="1">
<list editable="bottom">
<field name="student_no"/>
<field name="name"/>
<field name="gender"/>
<field name="phone"/>
<field name="enrollment_date"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_yuthon_school_class_search" model="ir.ui.view">
<field name="name">yuthon.school.class.search</field>
<field name="model">yuthon.school.class</field>
<field name="arch" type="xml">
<search string="搜索班级">
<field name="name"/>
<field name="school_id"/>
<field name="teacher_id"/>
<separator/>
<group expand="0" string="分组">
<filter name="groupby_school" string="学校"
context="{'group_by': 'school_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_yuthon_school_class" model="ir.actions.act_window">
<field name="name">班级管理</field>
<field name="res_model">yuthon.school.class</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_yuthon_school_class_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
创建第一个班级
</p>
</field>
</record>
<!-- ============ 老师 ============ -->
<record id="view_yuthon_school_teacher_list" model="ir.ui.view">
<field name="name">yuthon.school.teacher.list</field>
<field name="model">yuthon.school.teacher</field>
<field name="arch" type="xml">
<list string="老师">
<field name="name"/>
<field name="school_id"/>
<field name="subject"/>
<field name="phone"/>
</list>
</field>
</record>
<record id="view_yuthon_school_teacher_form" model="ir.ui.view">
<field name="name">yuthon.school.teacher.form</field>
<field name="model">yuthon.school.teacher</field>
<field name="arch" type="xml">
<form string="老师">
<sheet>
<group>
<group>
<field name="name"/>
<field name="school_id"/>
</group>
<group>
<field name="phone"/>
<field name="subject"/>
<field name="active" invisible="1"/>
</group>
</group>
<notebook>
<page string="负责班级" name="class_page">
<field name="class_ids" nolabel="1">
<list>
<field name="name"/>
<field name="grade"/>
<field name="student_count"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_yuthon_school_teacher_search" model="ir.ui.view">
<field name="name">yuthon.school.teacher.search</field>
<field name="model">yuthon.school.teacher</field>
<field name="arch" type="xml">
<search string="搜索老师">
<field name="name"/>
<field name="school_id"/>
<field name="subject"/>
<separator/>
<group expand="0" string="分组">
<filter name="groupby_school" string="学校"
context="{'group_by': 'school_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_yuthon_school_teacher" model="ir.actions.act_window">
<field name="name">老师管理</field>
<field name="res_model">yuthon.school.teacher</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_yuthon_school_teacher_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
添加第一位老师
</p>
</field>
</record>
<!-- ============ 学生 ============ -->
<record id="view_yuthon_school_student_list" model="ir.ui.view">
<field name="name">yuthon.school.student.list</field>
<field name="model">yuthon.school.student</field>
<field name="arch" type="xml">
<list string="学生">
<field name="student_no"/>
<field name="name"/>
<field name="school_id"/>
<field name="class_id"/>
<field name="gender"/>
<field name="phone"/>
<field name="enrollment_date"/>
</list>
</field>
</record>
<record id="view_yuthon_school_student_form" model="ir.ui.view">
<field name="name">yuthon.school.student.form</field>
<field name="model">yuthon.school.student</field>
<field name="arch" type="xml">
<form string="学生">
<sheet>
<group>
<group>
<field name="name"/>
<field name="student_no"/>
<field name="class_id"/>
<field name="school_id"/>
</group>
<group>
<field name="gender"/>
<field name="phone"/>
<field name="enrollment_date"/>
<field name="active" invisible="1"/>
</group>
</group>
<notebook>
<page string="选修课" name="elective_page">
<field name="elective_ids">
<list>
<field name="name"/>
<field name="teacher_id"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_yuthon_school_student_search" model="ir.ui.view">
<field name="name">yuthon.school.student.search</field>
<field name="model">yuthon.school.student</field>
<field name="arch" type="xml">
<search string="搜索学生">
<field name="name"/>
<field name="student_no"/>
<field name="class_id"/>
<field name="school_id"/>
<separator/>
<filter name="filter_male" string="男"
domain="[('gender', '=', 'male')]"/>
<filter name="filter_female" string="女"
domain="[('gender', '=', 'female')]"/>
<separator/>
<group expand="0" string="分组">
<filter name="groupby_class" string="班级"
context="{'group_by': 'class_id'}"/>
<filter name="groupby_school" string="学校"
context="{'group_by': 'school_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_yuthon_school_student" model="ir.actions.act_window">
<field name="name">学生管理</field>
<field name="res_model">yuthon.school.student</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_yuthon_school_student_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
添加第一位学生
</p>
</field>
</record>
</odoo>

View File

@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Kanban View -->
<record id="view_yuthon_school_progress_kanban" model="ir.ui.view">
<field name="name">yuthon.school.progress.kanban</field>
<field name="model">yuthon.school.progress</field>
<field name="arch" type="xml">
<kanban default_group_by="state" class="o_kanban_small_column">
<field name="state"/>
<field name="student_id"/>
<field name="textbook_id"/>
<field name="chapter_id"/>
<field name="video_id"/>
<field name="progress"/>
<field name="completed"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_card oe_kanban_global_click o_kanban_record_has_image_fill">
<div class="o_kanban_record_title">
<field name="student_id"/>
</div>
<div class="o_kanban_record_body">
<div><field name="textbook_id"/></div>
<div><field name="chapter_id"/></div>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
<field name="progress" widget="progressbar"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- List View -->
<record id="view_yuthon_school_progress_list" model="ir.ui.view">
<field name="name">yuthon.school.progress.list</field>
<field name="model">yuthon.school.progress</field>
<field name="arch" type="xml">
<list string="学习进度" default_order="student_id, textbook_id">
<field name="student_id"/>
<field name="teacher_id"/>
<field name="textbook_id"/>
<field name="chapter_id"/>
<field name="video_id"/>
<field name="progress" widget="progressbar"/>
<field name="completed" widget="boolean_toggle"/>
<field name="state" widget="badge"
decoration-muted="state == 'not_started'"
decoration-info="state == 'in_progress'"
decoration-success="state == 'completed'"/>
<field name="last_study_date"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_yuthon_school_progress_form" model="ir.ui.view">
<field name="name">yuthon.school.progress.form</field>
<field name="model">yuthon.school.progress</field>
<field name="arch" type="xml">
<form string="学习进度">
<sheet>
<group>
<group string="学习内容">
<field name="student_id"/>
<field name="textbook_id"/>
<field name="chapter_id"/>
<field name="video_id"/>
</group>
<group string="进度信息">
<field name="progress" widget="progressbar"/>
<field name="completed" widget="boolean_toggle"/>
<field name="last_study_date"/>
<field name="teacher_id"/>
<field name="active" invisible="1"/>
</group>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_yuthon_school_progress_search" model="ir.ui.view">
<field name="name">yuthon.school.progress.search</field>
<field name="model">yuthon.school.progress</field>
<field name="arch" type="xml">
<search string="搜索学习进度">
<field name="student_id"/>
<field name="textbook_id"/>
<field name="teacher_id"/>
<separator/>
<filter name="filter_not_started" string="未开始"
domain="[('state', '=', 'not_started')]"/>
<filter name="filter_in_progress" string="进行中"
domain="[('state', '=', 'in_progress')]"/>
<filter name="filter_completed" string="已完成"
domain="[('state', '=', 'completed')]"/>
<separator/>
<filter name="filter_low_progress" string="进度不足30%"
domain="[('progress', '&lt;', 30)]"/>
<separator/>
<group expand="0" string="分组">
<filter name="groupby_state" string="学习状态"
context="{'group_by': 'state'}"/>
<filter name="groupby_student" string="学生"
context="{'group_by': 'student_id'}"/>
<filter name="groupby_textbook" string="教材"
context="{'group_by': 'textbook_id'}"/>
<filter name="groupby_teacher" string="教师"
context="{'group_by': 'teacher_id'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_yuthon_school_progress" model="ir.actions.act_window">
<field name="name">学习进度</field>
<field name="res_model">yuthon.school.progress</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_yuthon_school_progress_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
暂无学习进度记录
</p>
<p>
学生的学习进度会在此显示,教师可按学生或教材查看
</p>
</field>
</record>
</odoo>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Wizard Form View -->
<record id="view_yuthon_school_progress_wizard_form" model="ir.ui.view">
<field name="name">yuthon.school.progress.wizard.form</field>
<field name="model">yuthon.school.progress.wizard</field>
<field name="arch" type="xml">
<form string="批量更新学习进度">
<group>
<group string="选择范围">
<field name="class_id"/>
<field name="chapter_id"/>
<field name="textbook_id"/>
</group>
<group string="进度设置">
<field name="progress_value"/>
<field name="completed"/>
</group>
</group>
<footer>
<button name="action_apply" type="object"
string="确认更新" class="btn-primary"/>
<button special="cancel" string="取消"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<!-- Wizard Action -->
<record id="action_yuthon_school_progress_wizard" model="ir.actions.act_window">
<field name="name">批量更新进度</field>
<field name="res_model">yuthon.school.progress.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_yuthon_school_progress"/>
<field name="binding_view_types">list,form</field>
</record>
</odoo>

View File

@ -0,0 +1,174 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 教材 ============ -->
<!-- List View -->
<record id="view_yuthon_school_textbook_list" model="ir.ui.view">
<field name="name">yuthon.school.textbook.list</field>
<field name="model">yuthon.school.textbook</field>
<field name="arch" type="xml">
<list string="教材" default_order="category_id, sequence">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="category_id"/>
<field name="difficulty" widget="badge"
decoration-info="difficulty == 'beginner'"
decoration-warning="difficulty == 'intermediate'"
decoration-danger="difficulty == 'advanced'"/>
<field name="total_chapters"/>
<field name="total_videos"/>
<field name="total_hours"/>
<field name="state" widget="badge"
decoration-success="state == 'published'"
decoration-muted="state == 'draft'"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_yuthon_school_textbook_form" model="ir.ui.view">
<field name="name">yuthon.school.textbook.form</field>
<field name="model">yuthon.school.textbook</field>
<field name="arch" type="xml">
<form string="教材">
<header>
<button name="action_confirm" string="发布" type="object"
class="oe_highlight" invisible="state != 'draft'"/>
<button name="action_set_draft" string="退回草稿" type="object"
invisible="state != 'published'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,published"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="category_id"/>
<field name="difficulty" widget="radio"/>
<field name="sequence"/>
</group>
<group>
<field name="total_hours"/>
<field name="total_chapters"/>
<field name="total_videos"/>
<field name="active" invisible="1"/>
</group>
</group>
<group string="教材简介">
<field name="description" nolabel="1"
placeholder="教材的内容概要和适用人群..."/>
</group>
<notebook>
<page string="章节" name="chapter_page">
<field name="chapter_ids" nolabel="1">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="hours"/>
<field name="video_count"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_yuthon_school_textbook_search" model="ir.ui.view">
<field name="name">yuthon.school.textbook.search</field>
<field name="model">yuthon.school.textbook</field>
<field name="arch" type="xml">
<search string="搜索教材">
<field name="name"/>
<field name="category_id"/>
<separator/>
<filter name="filter_beginner" string="入门"
domain="[('difficulty', '=', 'beginner')]"/>
<filter name="filter_intermediate" string="进阶"
domain="[('difficulty', '=', 'intermediate')]"/>
<filter name="filter_advanced" string="高级"
domain="[('difficulty', '=', 'advanced')]"/>
<separator/>
<filter name="filter_draft" string="草稿"
domain="[('state', '=', 'draft')]"/>
<filter name="filter_published" string="已发布"
domain="[('state', '=', 'published')]"/>
<separator/>
<group expand="0" string="分组">
<filter name="groupby_category" string="学习分类"
context="{'group_by': 'category_id'}"/>
<filter name="groupby_difficulty" string="难度"
context="{'group_by': 'difficulty'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_yuthon_school_textbook" model="ir.actions.act_window">
<field name="name">教材管理</field>
<field name="res_model">yuthon.school.textbook</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_yuthon_school_textbook_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
创建第一本教材
</p>
<p>
教材是完整课程的载体,下设章节和视频,带学时统计
</p>
</field>
</record>
<!-- ============ 章节(独立表单视图,用于弹窗打开) ============ -->
<record id="view_yuthon_school_chapter_form" model="ir.ui.view">
<field name="name">yuthon.school.chapter.form</field>
<field name="model">yuthon.school.chapter</field>
<field name="arch" type="xml">
<form string="章节">
<sheet>
<group>
<group>
<field name="name"/>
<field name="textbook_id"/>
<field name="sequence"/>
<field name="hours"/>
</group>
<group>
<field name="video_count"/>
</group>
</group>
<group string="内容概述">
<field name="summary" nolabel="1"
placeholder="本章涵盖的内容概述..."/>
</group>
<group string="知识要点">
<field name="key_points" nolabel="1"
placeholder="本章需要掌握的关键知识点..."/>
</group>
<notebook>
<page string="视频" name="video_page">
<field name="video_ids" nolabel="1">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="difficulty" widget="badge"
decoration-info="difficulty == 'beginner'"
decoration-warning="difficulty == 'intermediate'"
decoration-danger="difficulty == 'advanced'"/>
<field name="duration"/>
<field name="file_size_display"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- List View -->
<record id="view_yuthon_school_category_list" model="ir.ui.view">
<field name="name">yuthon.school.category.list</field>
<field name="model">yuthon.school.category</field>
<field name="arch" type="xml">
<list string="学习目录">
<field name="complete_name"/>
<field name="level" widget="badge"
decoration-info="level == 'beginner'"
decoration-warning="level == 'intermediate'"
decoration-danger="level == 'advanced'"/>
<field name="video_count"/>
<field name="active" widget="boolean_toggle" invisible="1"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_yuthon_school_category_form" model="ir.ui.view">
<field name="name">yuthon.school.category.form</field>
<field name="model">yuthon.school.category</field>
<field name="arch" type="xml">
<form string="学习目录">
<sheet>
<widget name="web_ribbon" title="已归档" bg_color="bg-danger" invisible="active"/>
<group>
<group>
<field name="name"/>
<field name="parent_id"/>
<field name="complete_name" readonly="1"/>
<field name="level" widget="radio"/>
</group>
<group>
<field name="video_count"/>
<field name="active" invisible="1"/>
</group>
</group>
<group string="目录说明">
<field name="description" nolabel="1" placeholder="描述该学习目录涵盖的内容..."/>
</group>
<notebook>
<page string="下级目录" name="child_page">
<field name="child_ids" nolabel="1">
<list editable="bottom">
<field name="name"/>
<field name="level"/>
<field name="video_count"/>
</list>
</field>
</page>
<page string="教学视频" name="video_page">
<field name="video_ids" nolabel="1">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="difficulty" widget="badge"
decoration-info="difficulty == 'beginner'"
decoration-warning="difficulty == 'intermediate'"
decoration-danger="difficulty == 'advanced'"/>
<field name="duration"/>
<field name="file_size_display"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_yuthon_school_category_search" model="ir.ui.view">
<field name="name">yuthon.school.category.search</field>
<field name="model">yuthon.school.category</field>
<field name="arch" type="xml">
<search string="搜索目录">
<field name="name"/>
<field name="complete_name"/>
<separator/>
<filter name="filter_beginner" string="入门" domain="[('level', '=', 'beginner')]"/>
<filter name="filter_intermediate" string="进阶" domain="[('level', '=', 'intermediate')]"/>
<filter name="filter_advanced" string="高级" domain="[('level', '=', 'advanced')]"/>
<separator/>
<filter name="archived" string="已归档" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<!-- Action -->
<record id="action_yuthon_school_category" model="ir.actions.act_window">
<field name="name">学习目录</field>
<field name="res_model">yuthon.school.category</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_yuthon_school_category_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
创建第一个学习目录
</p>
<p>
按 Odoo 18 知识体系建立树形目录,如:基础入门 > 模块结构 > 字段类型
</p>
</field>
</record>
</odoo>

View File

@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- List View -->
<record id="view_yuthon_school_video_list" model="ir.ui.view">
<field name="name">yuthon.school.video.list</field>
<field name="model">yuthon.school.video</field>
<field name="arch" type="xml">
<list string="教学视频" default_order="category_id, sequence">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="category_id"/>
<field name="chapter_id"/>
<field name="difficulty" widget="badge"
decoration-info="difficulty == 'beginner'"
decoration-warning="difficulty == 'intermediate'"
decoration-danger="difficulty == 'advanced'"/>
<field name="duration"/>
<field name="video_filename"/>
<field name="file_size_display"/>
<field name="active" widget="boolean_toggle" invisible="1"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_yuthon_school_video_form" model="ir.ui.view">
<field name="name">yuthon.school.video.form</field>
<field name="model">yuthon.school.video</field>
<field name="arch" type="xml">
<form string="教学视频">
<sheet>
<widget name="web_ribbon" title="已归档" bg_color="bg-danger" invisible="active"/>
<group>
<group>
<field name="name"/>
<field name="category_id"/>
<field name="chapter_id"/>
<field name="difficulty" widget="radio"/>
<field name="duration"/>
<field name="sequence"/>
</group>
<group>
<field name="video_filename" invisible="1"/>
<field name="file_size_display"/>
<field name="active" invisible="1"/>
</group>
</group>
<notebook>
<page string="视频文件" name="video_file_page">
<group>
<field name="video_file" filename="video_filename" nolabel="1"
help="上传本地教学视频文件"/>
</group>
</page>
<page string="视频播放" name="video_player_page" invisible="not id">
<group>
<field name="video_player" widget="html" nolabel="1" readonly="1"/>
</group>
</page>
<page string="内容说明" name="description_page">
<group string="视频简介">
<field name="description" nolabel="1" placeholder="该教学视频的内容概要..."/>
</group>
<group string="知识要点">
<field name="key_points" nolabel="1" placeholder="列出该视频涵盖的关键知识点..."/>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_yuthon_school_video_search" model="ir.ui.view">
<field name="name">yuthon.school.video.search</field>
<field name="model">yuthon.school.video</field>
<field name="arch" type="xml">
<search string="搜索视频">
<field name="name"/>
<field name="category_id"/>
<field name="chapter_id"/>
<field name="video_filename"/>
<separator/>
<filter name="filter_beginner" string="入门" domain="[('difficulty', '=', 'beginner')]"/>
<filter name="filter_intermediate" string="进阶" domain="[('difficulty', '=', 'intermediate')]"/>
<filter name="filter_advanced" string="高级" domain="[('difficulty', '=', 'advanced')]"/>
<separator/>
<group expand="0" string="按目录分组">
<filter name="groupby_category" string="学习目录" context="{'group_by': 'category_id'}"/>
</group>
<filter name="archived" string="已归档" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<!-- Action -->
<record id="action_yuthon_school_video" model="ir.actions.act_window">
<field name="name">教学视频</field>
<field name="res_model">yuthon.school.video</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_yuthon_school_video_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
创建第一个教学视频
</p>
<p>
上传 Odoo 18 教学视频,按学习目录分类组织
</p>
</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import progress_wizard

View File

@ -0,0 +1,69 @@
from odoo import fields, models
class ProgressWizard(models.TransientModel):
"""批量更新学习进度向导TransientModel 演示)
TransientModel 的特点
1. 数据存储在临时表中定期自动清理
2. 用于弹出式向导交互不保留持久数据
3. 权限模型与普通 Model 不同通常不需要 unlink 权限
"""
_name = 'yuthon.school.progress.wizard'
_description = '批量更新学习进度'
class_id = fields.Many2one(
'yuthon.school.class',
string='班级',
required=True,
)
chapter_id = fields.Many2one(
'yuthon.school.chapter',
string='章节',
required=True,
)
textbook_id = fields.Many2one(
'yuthon.school.textbook',
string='所属教材',
related='chapter_id.textbook_id',
readonly=True,
)
progress_value = fields.Float(
string='设置进度(%)',
default=100.0,
help='将该班级所有学生此章节的进度设置为该值0-100',
)
completed = fields.Boolean(
string='标记为已完成',
default=True,
)
def action_apply(self):
"""批量更新该班级所有学生的指定章节进度"""
self.ensure_one()
Progress = self.env['yuthon.school.progress']
students = self.class_id.student_ids
now = fields.Datetime.now()
for student in students:
existing = Progress.search([
('student_id', '=', student.id),
('chapter_id', '=', self.chapter_id.id),
], limit=1)
if existing:
existing.write({
'progress': self.progress_value,
'completed': self.completed,
'last_study_date': now,
})
else:
Progress.create({
'student_id': student.id,
'chapter_id': self.chapter_id.id,
'textbook_id': self.chapter_id.textbook_id.id,
'progress': self.progress_value,
'completed': self.completed,
'last_study_date': now,
})
return {'type': 'ir.actions.act_window_close'}