461 lines
18 KiB
Python
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()
|