From 26c311d9138346efe80aa7f8c3e7822b0e7d0582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?= <9664676+pengyuthon@user.noreply.gitee.com> Date: Tue, 28 Jul 2026 18:50:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9D=97=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yuthon_school/__init__.py | 3 + yuthon_school/__manifest__.py | 35 ++ yuthon_school/controllers/__init__.py | 1 + yuthon_school/controllers/main.py | 77 ++++ yuthon_school/data/demo.xml | 307 ++++++++++++++++ yuthon_school/models/__init__.py | 6 + yuthon_school/models/elective.py | 41 +++ yuthon_school/models/organization.py | 167 +++++++++ yuthon_school/models/progress.py | 84 +++++ yuthon_school/models/textbook.py | 147 ++++++++ yuthon_school/models/video_category.py | 74 ++++ yuthon_school/models/video_playback.py | 147 ++++++++ yuthon_school/security/ir.model.access.csv | 12 + yuthon_school/views/elective_views.xml | 90 +++++ yuthon_school/views/menus.xml | 76 ++++ yuthon_school/views/organization_views.xml | 336 ++++++++++++++++++ yuthon_school/views/progress_views.xml | 143 ++++++++ yuthon_school/views/progress_wizard_views.xml | 41 +++ yuthon_school/views/textbook_views.xml | 174 +++++++++ yuthon_school/views/video_category_views.xml | 108 ++++++ yuthon_school/views/video_playback_views.xml | 114 ++++++ yuthon_school/wizards/__init__.py | 1 + yuthon_school/wizards/progress_wizard.py | 69 ++++ 23 files changed, 2253 insertions(+) create mode 100644 yuthon_school/__init__.py create mode 100644 yuthon_school/__manifest__.py create mode 100644 yuthon_school/controllers/__init__.py create mode 100644 yuthon_school/controllers/main.py create mode 100644 yuthon_school/data/demo.xml create mode 100644 yuthon_school/models/__init__.py create mode 100644 yuthon_school/models/elective.py create mode 100644 yuthon_school/models/organization.py create mode 100644 yuthon_school/models/progress.py create mode 100644 yuthon_school/models/textbook.py create mode 100644 yuthon_school/models/video_category.py create mode 100644 yuthon_school/models/video_playback.py create mode 100644 yuthon_school/security/ir.model.access.csv create mode 100644 yuthon_school/views/elective_views.xml create mode 100644 yuthon_school/views/menus.xml create mode 100644 yuthon_school/views/organization_views.xml create mode 100644 yuthon_school/views/progress_views.xml create mode 100644 yuthon_school/views/progress_wizard_views.xml create mode 100644 yuthon_school/views/textbook_views.xml create mode 100644 yuthon_school/views/video_category_views.xml create mode 100644 yuthon_school/views/video_playback_views.xml create mode 100644 yuthon_school/wizards/__init__.py create mode 100644 yuthon_school/wizards/progress_wizard.py diff --git a/yuthon_school/__init__.py b/yuthon_school/__init__.py new file mode 100644 index 0000000..b6de227 --- /dev/null +++ b/yuthon_school/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import controllers +from . import wizards diff --git a/yuthon_school/__manifest__.py b/yuthon_school/__manifest__.py new file mode 100644 index 0000000..882eeec --- /dev/null +++ b/yuthon_school/__manifest__.py @@ -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, +} diff --git a/yuthon_school/controllers/__init__.py b/yuthon_school/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/yuthon_school/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/yuthon_school/controllers/main.py b/yuthon_school/controllers/main.py new file mode 100644 index 0000000..09025f4 --- /dev/null +++ b/yuthon_school/controllers/main.py @@ -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/', 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'' diff --git a/yuthon_school/data/demo.xml b/yuthon_school/data/demo.xml new file mode 100644 index 0000000..6f88dba --- /dev/null +++ b/yuthon_school/data/demo.xml @@ -0,0 +1,307 @@ + + + + + + + + Odoo 18 开发 + beginner + + + + 基础入门 + + beginner + + + + 模型开发 + + intermediate + + + + 视图开发 + + intermediate + + + + + + 求知中学 + 北京市海淀区中关村南大街 1 号 + 010-88888001 + 赵校长 + + + + + + 张老师 + + 138-0000-0001 + Odoo 模型开发 + + + + 李老师 + + 138-0000-0002 + Odoo 视图开发 + + + + 王老师 + + 138-0000-0003 + Odoo 部署运维 + + + + + + Odoo 18 入门班 + + + 2026 级 + + + + Odoo 18 进阶班 + + + 2026 级 + + + + + + 陈小明 + S20260001 + + male + 139-0000-0001 + 2026-03-01 + + + + 林小芳 + S20260002 + + female + 139-0000-0002 + 2026-03-01 + + + + 黄小杰 + S20260003 + + male + 139-0000-0003 + 2026-03-01 + + + + 刘小婷 + S20260004 + + female + 139-0000-0004 + 2026-03-01 + + + + 周小强 + S20260005 + + male + 139-0000-0005 + 2026-03-01 + + + + + + 吴小燕 + S20260006 + + female + 139-0000-0006 + 2026-03-01 + + + + 郑小伟 + S20260007 + + male + 139-0000-0007 + 2026-03-01 + + + + 孙小丽 + S20260008 + + female + 139-0000-0008 + 2026-03-01 + + + + 胡小军 + S20260009 + + male + 139-0000-0009 + 2026-03-01 + + + + 高小静 + S20260010 + + female + 139-0000-0010 + 2026-03-01 + + + + + + Odoo 18 框架开发实战 + + beginner + published + 从零开始学习 Odoo 18 框架开发,涵盖模块结构、模型定义、视图架构、权限机制等核心知识点。 + + + + 第一章 模块结构与清单文件 + + 10 + 了解 Odoo 模块的目录结构和 __manifest__.py 清单文件 + __manifest__.py 字段、data 文件加载顺序、addons_path 配置 + 2.0 + + + + 第二章 模型定义与字段类型 + + 20 + 掌握 Odoo 模型定义、字段类型和关系字段 + Char/Integer/Float/Selection、Many2one/One2many/Many2many、compute/store + 4.0 + + + + 第三章 视图架构 + + 30 + 学习 list/form/search/kanban 四种核心视图 + list 视图、form 视图、search 视图、kanban 看板视图 + 3.0 + + + + 第四章 权限与安全机制 + + 40 + 理解 ir.model.access.csv 和 ir.rule 记录规则 + 模型访问权限、记录规则、用户组 + 2.5 + + + + 第五章 控制器与路由 + + 50 + 学习 HTTP 控制器和 URL 路由 + Controller、@http.route、请求处理、模板渲染 + 3.0 + + + + + + OWL 前端框架入门 + + 学习 Odoo Web Library (OWL) 框架基础,理解组件化前端开发。 + + + + + Odoo 工作流开发 + + 深入学习 Odoo 工作流机制,掌握状态机和业务流程设计。 + + + + + + + + + + 100 + True + 2026-07-20 10:00:00 + + + + + + + 65 + False + 2026-07-25 14:00:00 + + + + + + + 100 + True + 2026-07-22 09:30:00 + + + + + + + 40 + False + 2026-07-26 16:00:00 + + + + + + + 0 + False + 2026-07-28 08:00:00 + + + diff --git a/yuthon_school/models/__init__.py b/yuthon_school/models/__init__.py new file mode 100644 index 0000000..25596e2 --- /dev/null +++ b/yuthon_school/models/__init__.py @@ -0,0 +1,6 @@ +from . import video_playback +from . import video_category +from . import textbook +from . import organization +from . import progress +from . import elective diff --git a/yuthon_school/models/elective.py b/yuthon_school/models/elective.py new file mode 100644 index 0000000..a560dc2 --- /dev/null +++ b/yuthon_school/models/elective.py @@ -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) diff --git a/yuthon_school/models/organization.py b/yuthon_school/models/organization.py new file mode 100644 index 0000000..5215b1d --- /dev/null +++ b/yuthon_school/models/organization.py @@ -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, + ) diff --git a/yuthon_school/models/progress.py b/yuthon_school/models/progress.py new file mode 100644 index 0000000..cc803a2 --- /dev/null +++ b/yuthon_school/models/progress.py @@ -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 之间" + ) diff --git a/yuthon_school/models/textbook.py b/yuthon_school/models/textbook.py new file mode 100644 index 0000000..23543bd --- /dev/null +++ b/yuthon_school/models/textbook.py @@ -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 diff --git a/yuthon_school/models/video_category.py b/yuthon_school/models/video_category.py new file mode 100644 index 0000000..c7b1cf2 --- /dev/null +++ b/yuthon_school/models/video_category.py @@ -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) diff --git a/yuthon_school/models/video_playback.py b/yuthon_school/models/video_playback.py new file mode 100644 index 0000000..10f3f9b --- /dev/null +++ b/yuthon_school/models/video_playback.py @@ -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 = ( + '
' + '
' + ) + record.video_player = markupsafe.Markup(html) + else: + record.video_player = markupsafe.Markup( + '
' + '

请上传视频文件并保存后查看播放。

' + ) + + @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}' + ) + diff --git a/yuthon_school/security/ir.model.access.csv b/yuthon_school/security/ir.model.access.csv new file mode 100644 index 0000000..ab882ff --- /dev/null +++ b/yuthon_school/security/ir.model.access.csv @@ -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 diff --git a/yuthon_school/views/elective_views.xml b/yuthon_school/views/elective_views.xml new file mode 100644 index 0000000..43db093 --- /dev/null +++ b/yuthon_school/views/elective_views.xml @@ -0,0 +1,90 @@ + + + + + + yuthon.school.elective.list + yuthon.school.elective + + + + + + + + + + + + + yuthon.school.elective.form + yuthon.school.elective + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + yuthon.school.elective.search + yuthon.school.elective + + + + + + + + + + + + + + + + 选修课 + yuthon.school.elective + list,form + + +

+ 暂无选修课记录 +

+

+ 选修课与学生之间是 Many2many(多对多)关系, + 一个学生可选多门选修课,一门选修课可有多个学生。 +

+
+
+ +
diff --git a/yuthon_school/views/menus.xml b/yuthon_school/views/menus.xml new file mode 100644 index 0000000..6957683 --- /dev/null +++ b/yuthon_school/views/menus.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/yuthon_school/views/organization_views.xml b/yuthon_school/views/organization_views.xml new file mode 100644 index 0000000..88de3dc --- /dev/null +++ b/yuthon_school/views/organization_views.xml @@ -0,0 +1,336 @@ + + + + + + + yuthon.school.school.list + yuthon.school.school + + + + + + + + + + + + + yuthon.school.school.form + yuthon.school.school + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + yuthon.school.school.search + yuthon.school.school + + + + + + + + + + 学校管理 + yuthon.school.school + list,form + + +

+ 创建第一所学校 +

+
+
+ + + + + yuthon.school.class.list + yuthon.school.class + + + + + + + + + + + + + yuthon.school.class.form + yuthon.school.class + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + yuthon.school.class.search + yuthon.school.class + + + + + + + + + + + + + + + 班级管理 + yuthon.school.class + list,form + + +

+ 创建第一个班级 +

+
+
+ + + + + yuthon.school.teacher.list + yuthon.school.teacher + + + + + + + + + + + + yuthon.school.teacher.form + yuthon.school.teacher + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + yuthon.school.teacher.search + yuthon.school.teacher + + + + + + + + + + + + + + + 老师管理 + yuthon.school.teacher + list,form + + +

+ 添加第一位老师 +

+
+
+ + + + + yuthon.school.student.list + yuthon.school.student + + + + + + + + + + + + + + + yuthon.school.student.form + yuthon.school.student + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + yuthon.school.student.search + yuthon.school.student + + + + + + + + + + + + + + + + + + + + 学生管理 + yuthon.school.student + list,form + + +

+ 添加第一位学生 +

+
+
+ +
diff --git a/yuthon_school/views/progress_views.xml b/yuthon_school/views/progress_views.xml new file mode 100644 index 0000000..d3ae1ed --- /dev/null +++ b/yuthon_school/views/progress_views.xml @@ -0,0 +1,143 @@ + + + + + + yuthon.school.progress.kanban + yuthon.school.progress + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + + + yuthon.school.progress.list + yuthon.school.progress + + + + + + + + + + + + + + + + + + yuthon.school.progress.form + yuthon.school.progress + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+
+
+
+ + + + yuthon.school.progress.search + yuthon.school.progress + + + + + + + + + + + + + + + + + + + + + + + + + 学习进度 + yuthon.school.progress + kanban,list,form + + +

+ 暂无学习进度记录 +

+

+ 学生的学习进度会在此显示,教师可按学生或教材查看 +

+
+
+ +
diff --git a/yuthon_school/views/progress_wizard_views.xml b/yuthon_school/views/progress_wizard_views.xml new file mode 100644 index 0000000..d816d4e --- /dev/null +++ b/yuthon_school/views/progress_wizard_views.xml @@ -0,0 +1,41 @@ + + + + + + yuthon.school.progress.wizard.form + yuthon.school.progress.wizard + +
+ + + + + + + + + + + +
+
+
+
+
+ + + + 批量更新进度 + yuthon.school.progress.wizard + form + new + + list,form + + +
diff --git a/yuthon_school/views/textbook_views.xml b/yuthon_school/views/textbook_views.xml new file mode 100644 index 0000000..c163f01 --- /dev/null +++ b/yuthon_school/views/textbook_views.xml @@ -0,0 +1,174 @@ + + + + + + + + yuthon.school.textbook.list + yuthon.school.textbook + + + + + + + + + + + + + + + + + yuthon.school.textbook.form + yuthon.school.textbook + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + yuthon.school.textbook.search + yuthon.school.textbook + + + + + + + + + + + + + + + + + + + + + + + 教材管理 + yuthon.school.textbook + list,form + + +

+ 创建第一本教材 +

+

+ 教材是完整课程的载体,下设章节和视频,带学时统计 +

+
+
+ + + + + yuthon.school.chapter.form + yuthon.school.chapter + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
diff --git a/yuthon_school/views/video_category_views.xml b/yuthon_school/views/video_category_views.xml new file mode 100644 index 0000000..2cdc462 --- /dev/null +++ b/yuthon_school/views/video_category_views.xml @@ -0,0 +1,108 @@ + + + + + + yuthon.school.category.list + yuthon.school.category + + + + + + + + + + + + + yuthon.school.category.form + yuthon.school.category + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + yuthon.school.category.search + yuthon.school.category + + + + + + + + + + + + + + + + + 学习目录 + yuthon.school.category + list,form + + +

+ 创建第一个学习目录 +

+

+ 按 Odoo 18 知识体系建立树形目录,如:基础入门 > 模块结构 > 字段类型 +

+
+
+ +
diff --git a/yuthon_school/views/video_playback_views.xml b/yuthon_school/views/video_playback_views.xml new file mode 100644 index 0000000..1a76797 --- /dev/null +++ b/yuthon_school/views/video_playback_views.xml @@ -0,0 +1,114 @@ + + + + + + yuthon.school.video.list + yuthon.school.video + + + + + + + + + + + + + + + + + + yuthon.school.video.form + yuthon.school.video + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + yuthon.school.video.search + yuthon.school.video + + + + + + + + + + + + + + + + + + + + + + 教学视频 + yuthon.school.video + list,form + + +

+ 创建第一个教学视频 +

+

+ 上传 Odoo 18 教学视频,按学习目录分类组织 +

+
+
+ +
diff --git a/yuthon_school/wizards/__init__.py b/yuthon_school/wizards/__init__.py new file mode 100644 index 0000000..233f877 --- /dev/null +++ b/yuthon_school/wizards/__init__.py @@ -0,0 +1 @@ +from . import progress_wizard diff --git a/yuthon_school/wizards/progress_wizard.py b/yuthon_school/wizards/progress_wizard.py new file mode 100644 index 0000000..efb03e8 --- /dev/null +++ b/yuthon_school/wizards/progress_wizard.py @@ -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'}