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'⬇ 下载' ) 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 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) # 视频与附件 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) # 随堂笔记:老师按章节记录的富文本笔记(与章节编辑表单共用同一字段) 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'' ) 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': 'new', } 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()