# pyrefly: ignore [missing-import] import logging from odoo import api, fields, models from odoo.exceptions import ValidationError _logger = logging.getLogger(__name__) class EducationAnnouncement(models.Model): _name = 'education.announcement' _description = 'Education Announcement' _order = 'priority desc, date_published desc, id desc' title = fields.Char(string='Title', required=True, translate=True) content = fields.Html(string='Content', required=True) date_published = fields.Date(string='Date Published', required=True, default=fields.Date.context_today) expiry_date = fields.Date(string='Expiry Date') announcement_type = fields.Selection([ ('notice', 'Notice'), ('circular', 'Circular'), ('event', 'Event') ], string='Type', required=True, default='notice') priority = fields.Selection([ ('0', 'Normal'), ('1', 'Important'), ('2', 'Urgent') ], string='Priority', default='0', index=True) state = fields.Selection([ ('draft', 'Draft'), ('published', 'Published'), ('archived', 'Archived') ], string='Status', default='published', required=True) active = fields.Boolean(default=True) author_id = fields.Many2one( 'res.users', string='Author', default=lambda self: self.env.user, readonly=True ) company_id = fields.Many2one( 'res.company', string='Company', required=True, default=lambda self: self.env.company, index=True, ) circular_file = fields.Binary(string='Circular File', attachment=True) circular_filename = fields.Char(string='Circular Filename') # Audience & Cohort Targeting audience_type = fields.Selection([ ('all', 'All Students'), ('cohort', 'Specific Academic Cohort') ], string='Target Audience', default='all', required=True) program_id = fields.Many2one('education.program', string='Target Program', index=True) academic_year_id = fields.Many2one('education.academic.year', string='Target Academic Year', index=True) academic_term_id = fields.Many2one('education.academic.term', string='Target Academic Term', index=True) batch_id = fields.Many2one('education.batch', string='Target Batch', index=True) semester_id = fields.Many2one('education.semester', string='Target Semester', index=True) section_id = fields.Many2one('education.section', string='Target Section', index=True) # Push Notification Audit notification_sent = fields.Boolean(string='Notification Dispatched', default=False, copy=False) notification_sent_date = fields.Datetime(string='Dispatched At', readonly=True, copy=False) notification_recipient_count = fields.Integer(string='Recipients Count', readonly=True, copy=False) @api.constrains('date_published', 'expiry_date') def _check_dates(self): for rec in self: if rec.expiry_date and rec.expiry_date < rec.date_published: raise ValidationError("Expiry date must be on or after the publication date.") def action_publish(self): for rec in self: if rec.expiry_date and rec.expiry_date < rec.date_published: raise ValidationError("Expiry date must be on or after the publication date.") rec.state = 'published' rec._dispatch_notifications() def action_archive(self): for rec in self: rec.state = 'archived' def action_draft(self): for rec in self: rec.state = 'draft' def action_resend_notification(self): for rec in self: rec._dispatch_notifications(force=True) def check_student_audience(self, student): """Verify whether an announcement applies to a given student.""" self.ensure_one() if self.audience_type == 'all': return True if self.program_id and self.program_id.id != student.program_id.id: return False if self.academic_year_id and self.academic_year_id.id != student.academic_year_id.id: return False if self.academic_term_id and self.academic_term_id.id != student.academic_term_id.id: return False if self.batch_id and self.batch_id.id != student.batch_id.id: return False if self.semester_id and self.semester_id.id != student.semester_id.id: return False if self.section_id and self.section_id.id != student.section_id.id: return False return True @api.model def get_announcements_for_student(self, student, limit=None, offset=0, announcement_type=None): """Retrieve active published announcements valid for the student within the effective date window.""" today = fields.Date.context_today(self) domain = [ ('active', '=', True), ('state', '=', 'published'), ('date_published', '<=', today), '|', ('expiry_date', '=', False), ('expiry_date', '>=', today), ] if student.company_id: domain.append(('company_id', '=', student.company_id.id)) if announcement_type: domain.append(('announcement_type', '=', announcement_type)) records = self.sudo().search(domain, order="priority desc, date_published desc, id desc") filtered = records.filtered(lambda a: a.check_student_audience(student)) total_count = len(filtered) if offset: filtered = filtered[offset:] if limit: filtered = filtered[:limit] return filtered, total_count def _dispatch_notifications(self, force=False): """Provider-independent notification dispatcher with in-app logging and duplicate prevention.""" for rec in self: if rec.state != 'published': continue if rec.notification_sent and not force: _logger.info("Notification already dispatched for announcement #%s ('%s'). Skipping duplicate.", rec.id, rec.title) continue # Resolve targeted student recipients domain = [] if rec.company_id: domain.append(('company_id', '=', rec.company_id.id)) if rec.audience_type == 'cohort': if rec.program_id: domain.append(('program_id', '=', rec.program_id.id)) if rec.academic_year_id: domain.append(('academic_year_id', '=', rec.academic_year_id.id)) if rec.academic_term_id: domain.append(('academic_term_id', '=', rec.academic_term_id.id)) if rec.batch_id: domain.append(('batch_id', '=', rec.batch_id.id)) if rec.semester_id: domain.append(('semester_id', '=', rec.semester_id.id)) if rec.section_id: domain.append(('section_id', '=', rec.section_id.id)) students = self.env['education.student'].sudo().search(domain) recipient_count = len(students) # In-App / Decoupled Notification Dispatch Log _logger.info( "DISPATCH NOTIFICATION: Announcement #%s ('%s', Priority: %s, Audience: %s) dispatched to %s students.", rec.id, rec.title, rec.priority, rec.audience_type, recipient_count ) rec.write({ 'notification_sent': True, 'notification_sent_date': fields.Datetime.now(), 'notification_recipient_count': recipient_count, })