Files
2026-09-16 15:34:00 +08:00

461 lines
18 KiB
Python

import re
from odoo import api, fields, models
class CourseChapterAttachment(models.Model):
"""课程章节附件(每个章节可挂多个文件:PDF / PPT / 资料等)。"""
_name = 'yuthon.school.course.chapter.attachment'
_description = '课程章节附件'
_order = 'sequence, id'
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节', ondelete='cascade')
file = fields.Binary(string='文件', attachment=True)
name = fields.Char(string='文件名')
download_url = fields.Char(string='下载链接', compute='_compute_download_url')
download_link_html = fields.Html(
string='下载', compute='_compute_download_link_html', sanitize=False)
sequence = fields.Integer(string='排序', default=10)
@api.depends('file')
def _compute_download_url(self):
"""生成附件下载直链。"""
Attachment = self.env['ir.attachment'].sudo()
for rec in self:
if rec.file:
attachment = Attachment.search([
('res_model', '=', 'yuthon.school.course.chapter.attachment'),
('res_field', '=', 'file'),
('res_id', '=', rec.id)
], limit=1, order='id desc')
rec.download_url = (
f'/web/content/{attachment.id}?download=1'
if attachment else '')
else:
rec.download_url = ''
@api.depends('download_url', 'file')
def _compute_download_link_html(self):
for rec in self:
if rec.download_url:
rec.download_link_html = (
f'<a href="{rec.download_url}" target="_blank" '
f'class="btn btn-sm btn-primary">⬇ 下载</a>'
)
else:
rec.download_link_html = ''
@api.model_create_multi
def create(self, vals_list):
return super().create(vals_list)
def write(self, vals):
return super().write(vals)
class CourseChapterExercise(models.Model):
"""章节训练习题——老师按章节布置的随堂简答题。
一个章节可挂多道;学生可以自由作答(不计分、不强制),
学员的回答存于 `yuthon.school.course.chapter.exercise.answer`。
"""
_name = 'yuthon.school.course.chapter.exercise'
_description = '章节训练习题'
_order = 'chapter_id, sequence, id'
_rec_name = 'exercise_label'
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(string='排序', default=10)
question = fields.Html(string='题目', sanitize=True, required=True)
analysis = fields.Html(
string='参考解析', sanitize=True,
help='给学员的参考答案/思路(仅展示,不参与评分)')
exercise_label = fields.Char(
string='显示名', compute='_compute_exercise_label',
help='内嵌列表与表单标题用:第 N 题')
answer_ids = fields.One2many(
'yuthon.school.course.chapter.exercise.answer', 'exercise_id',
string='学员作答')
answer_count = fields.Integer(
string='作答人数', compute='_compute_answer_count', store=True)
course_id = fields.Many2one(
'yuthon.school.course', string='课程',
related='chapter_id.course_id', store=True, index=True)
# 当前学员的回答(depends_context('uid') 跟随当前登录用户切换)
my_answer_content = fields.Html(
string='我的回答(全文)',
compute='_compute_my_answer', depends_context='uid')
my_answer_preview = fields.Char(
string='我的回答',
compute='_compute_my_answer_preview', depends_context='uid',
help='去除 Html 标签后的纯文本预览,便于在列表里紧凑显示')
@api.depends('sequence')
def _compute_exercise_label(self):
for rec in self:
rec.exercise_label = f'第 {rec.sequence} 题'
@api.depends('answer_ids')
def _compute_answer_count(self):
for rec in self:
rec.answer_count = len(rec.answer_ids)
@api.depends_context('uid')
def _compute_my_answer(self):
"""当前登录学员对本习题的回答(未作答则为 False)。"""
Answer = self.env['yuthon.school.course.chapter.exercise.answer']
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
answers = {}
if student:
for a in Answer.search([
('student_id', '=', student.id),
('exercise_id', 'in', self.ids),
]):
answers[a.exercise_id.id] = a
for rec in self:
ans = answers.get(rec.id)
rec.my_answer_content = ans.content if ans else False
@api.depends_context('uid')
def _compute_my_answer_preview(self):
"""去除 Html 标签后的纯文本预览(80 字以内)。"""
for rec in self:
content = rec.my_answer_content or ''
text = re.sub(r'<[^>]+>', ' ', content)
text = re.sub(r'\s+', ' ', text).strip()
if not text:
rec.my_answer_preview = ''
elif len(text) > 80:
rec.my_answer_preview = text[:80] + '…'
else:
rec.my_answer_preview = text
def action_answer(self):
"""学生端:弹窗回答本题。
若当前用户已对本题作答过,则打开已有作答;否则新建一条空作答。
"""
self.ensure_one()
Answer = self.env['yuthon.school.course.chapter.exercise.answer']
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
if not student:
return {'type': 'ir.actions.act_window_close'}
answer = Answer.search([
('exercise_id', '=', self.id),
('student_id', '=', student.id),
], limit=1)
if not answer:
answer = Answer.create({
'exercise_id': self.id,
'student_id': student.id,
})
return {
'type': 'ir.actions.act_window',
'name': '作答',
'res_model': 'yuthon.school.course.chapter.exercise.answer',
'res_id': answer.id,
'view_mode': 'form',
'view_id': self.env.ref(
'college.yuthon_school_course_chapter_exercise_answer_form').id,
'views': [(self.env.ref(
'college.yuthon_school_course_chapter_exercise_answer_form').id,
'form')],
'target': 'new',
}
def action_view_answers(self):
"""老师端:弹窗查看本题所有学员作答。
弹出只读列表(学员 / 回答 / 作答时间),不可编辑(不参与评分)。
"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'学员作答 · {self.exercise_label}',
'res_model': 'yuthon.school.course.chapter.exercise.answer',
'view_mode': 'list,form',
'domain': [('exercise_id', '=', self.id)],
'views': [
(self.env.ref(
'college.yuthon_school_course_chapter_exercise_answer_view_tree_teacher').id,
'list'),
(False, 'form'),
],
'target': 'new',
'context': {'default_exercise_id': self.id},
}
class CourseChapterExerciseAnswer(models.Model):
"""学员对章节训练习题的回答(不计分、自由提交、可修改)。"""
_name = 'yuthon.school.course.chapter.exercise.answer'
_description = '习题作答'
_order = 'exercise_id, student_id, id'
_rec_name = 'exercise_id'
exercise_id = fields.Many2one(
'yuthon.school.course.chapter.exercise', string='习题',
required=True, ondelete='cascade', index=True)
student_id = fields.Many2one(
'yuthon.school.student', string='学员',
required=True, ondelete='cascade', index=True)
user_id = fields.Many2one(
'res.users', string='账号',
related='student_id.user_id', store=True, index=True)
content = fields.Html(
string='我的回答', sanitize=True,
help='学员的作答内容,可随时修改(不计分)')
submit_time = fields.Datetime(
string='最后作答时间', default=fields.Datetime.now)
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节',
related='exercise_id.chapter_id', store=True, index=True)
course_id = fields.Many2one(
'yuthon.school.course', string='课程',
related='exercise_id.course_id', store=True, index=True)
question_display = fields.Html(
string='题目', related='exercise_id.question', readonly=True)
@api.model_create_multi
def create(self, vals_list):
vals_list = [dict(v, submit_time=fields.Datetime.now())
for v in vals_list]
return super().create(vals_list)
def write(self, vals):
if 'content' in vals:
vals['submit_time'] = fields.Datetime.now()
return super().write(vals)
class CourseChapterTask(models.Model):
"""章节任务清单——老师按章节维护的学习任务,学生端可见但不可编辑。"""
_name = 'yuthon.school.course.chapter.task'
_description = '章节任务'
_order = 'chapter_id, sequence, id'
chapter_id = fields.Many2one(
'yuthon.school.course.chapter', string='章节',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(string='排序', default=10)
name = fields.Char(string='任务名称', required=True)
is_done = fields.Boolean(string='已完成', default=False)
class CourseChapter(models.Model):
"""课程章节——老师端在课程内容里维护,学生端作为学习目录。
章节与课程同源:老师填写的视频、附件直接驱动学生端「课程学习」页面,
天然联动,无需额外同步。
"""
_name = 'yuthon.school.course.chapter'
_description = '课程章节'
_order = 'course_id, sequence, id'
course_id = fields.Many2one(
'yuthon.school.course', string='课程', required=True, ondelete='cascade')
name = fields.Char(string='章节标题', required=True, index=True)
sequence = fields.Integer(string='排序', default=10)
description = fields.Html(string='章节说明', sanitize=True)
class_hours = fields.Integer(string='课时', default=1, help='本章所占课时数')
# 视频与附件
video_file = fields.Binary(string='视频文件', attachment=True)
video_filename = fields.Char(string='视频文件名')
video_filename_display = fields.Char(
string='视频文件名(显示)', compute='_compute_video_filename_display',
store=True)
video_embed_html = fields.Html(
string='视频播放', compute='_compute_video_embed_html', sanitize=False)
attachment_ids = fields.One2many(
'yuthon.school.course.chapter.attachment', 'chapter_id', string='附件')
# 训练习题:一个章节可挂多道简答题(学生可自由作答,不计分)
exercise_ids = fields.One2many(
'yuthon.school.course.chapter.exercise', 'chapter_id',
string='训练习题')
exercise_count = fields.Integer(
string='习题数', compute='_compute_exercise_count', store=True)
@api.depends('exercise_ids')
def _compute_exercise_count(self):
for chapter in self:
chapter.exercise_count = len(chapter.exercise_ids)
# 任务列表:老师按章节维护的任务清单
task_ids = fields.One2many(
'yuthon.school.course.chapter.task', 'chapter_id',
string='任务列表')
# 随堂笔记:老师按章节记录的富文本笔记(与章节编辑表单共用同一字段)
notes = fields.Html(string='随堂笔记', sanitize=True)
@api.depends('video_file', 'video_filename')
def _compute_video_filename_display(self):
"""显示用:从 ir.attachment 读取视频真实文件名并持久化。"""
for chapter in self:
chapter.video_filename_display = chapter.video_filename or ''
@api.depends('video_file')
def _compute_video_embed_html(self):
"""学生端:生成可直接播放的视频 HTML。"""
Attachment = self.env['ir.attachment'].sudo()
for chapter in self:
if not chapter.video_file:
chapter.video_embed_html = False
continue
attachment = Attachment.search([
('res_model', '=', 'yuthon.school.course.chapter'),
('res_field', '=', 'video_file'),
('res_id', '=', chapter.id)
], limit=1, order='id desc')
if attachment:
chapter.video_embed_html = (
f'<video controls style="width:100%;" '
f'preload="metadata">'
f'<source src="/web/content/{attachment.id}" '
f'type="video/mp4">'
f'</video>'
)
else:
chapter.video_embed_html = False
# 已完成学员(标记学习完成的学生)
completed_student_ids = fields.Many2many(
'yuthon.school.student', string='已完成学员')
# 当前用户是否已完成(仅用于列表/表单展示,不入库)
is_done_for_me = fields.Boolean(
string='我已学完', compute='_compute_is_done_for_me', store=False)
@api.depends_context('uid')
def _compute_is_done_for_me(self):
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
for r in self:
r.is_done_for_me = bool(student and student in r.completed_student_ids)
def action_open_study(self):
"""学生端:在当前窗口打开当前章节的学习页(带视频播放/附件下载)。
从弹窗改为全屏表单,避免视频播放区域过窄。
"""
self.ensure_one()
study_form = self.env.ref(
'college.yuthon_school_course_chapter_study_form')
return {
'type': 'ir.actions.act_window',
'name': '学习章节',
'res_model': 'yuthon.school.course.chapter',
'res_id': self.id,
'view_mode': 'form',
'views': [(study_form.id, 'form')],
'target': 'current',
}
def action_open_edit(self):
"""老师端:弹窗打开当前章节的编辑页。"""
self.ensure_one()
edit_form = self.env.ref(
'college.yuthon_school_course_chapter_form_view')
return {
'type': 'ir.actions.act_window',
'name': '编辑章节',
'res_model': 'yuthon.school.course.chapter',
'res_id': self.id,
'view_mode': 'form',
'view_id': edit_form.id,
'views': [(edit_form.id, 'form')],
'target': 'new',
}
def action_toggle_complete(self):
"""学生端:标记 / 取消标记本章节为已完成。"""
self.ensure_one()
student = self.env['yuthon.school.student'].sudo().search(
[('user_id', '=', self.env.uid)], limit=1)
if not student:
return {'type': 'ir.actions.act_window_close'}
if student in self.completed_student_ids:
self.sudo().write({'completed_student_ids': [(3, student.id)]})
else:
self.sudo().write({'completed_student_ids': [(4, student.id)]})
# 同步更新学习进展的最近学习日期与状态
progress = self.env['yuthon.school.progress'].sudo().search([
('course_id', '=', self.course_id.id),
('student_id', '=', student.id),
], limit=1)
if progress:
progress.sudo().write({'last_study_date': fields.Date.context_today(self)})
progress.sudo().recompute_status()
# 重新打开本章节表单,刷新已完成状态
return {
'type': 'ir.actions.act_window',
'res_model': 'yuthon.school.course.chapter',
'res_id': self.id,
'view_mode': 'form',
'target': 'current',
}
@api.model_create_multi
def create(self, vals_list):
chapters = super().create(vals_list)
chapters._sync_video_filename()
chapters._refresh_course_progress()
return chapters
def write(self, vals):
res = super().write(vals)
if 'video_file' in vals:
self._sync_video_filename()
return res
def _sync_video_filename(self):
"""保持 video_filename 与 ir.attachment.name 一致:
- 视频被清空:清空 video_filename
- 已存在 attachment 且 chapter.video_filename 与之不一致:把附件名同步为 chapter.video_filename
"""
Attachment = self.env['ir.attachment'].sudo()
for chapter in self:
if not chapter.video_file:
# 视频被清空,连带清空文件名
if chapter.video_filename:
chapter.video_filename = ''
continue
attachment = Attachment.search([
('res_model', '=', 'yuthon.school.course.chapter'),
('res_field', '=', 'video_file'),
('res_id', '=', chapter.id)
], limit=1, order='id desc')
if attachment and chapter.video_filename \
and attachment.name != chapter.video_filename:
attachment.sudo().write({'name': chapter.video_filename})
def unlink(self):
courses = self.mapped('course_id')
res = super().unlink()
Progress = self.env['yuthon.school.progress'].sudo()
for course in courses:
Progress.search([('course_id', '=', course.id)]).recompute_status()
return res
def _refresh_course_progress(self):
"""章节创建后,刷新所属课程所有学员的进展状态。"""
Progress = self.env['yuthon.school.progress'].sudo()
for course in self.mapped('course_id'):
Progress.search([('course_id', '=', course.id)]).recompute_status()