feat(education_announcements): integrate student notices and notifications
This commit is contained in:
parent
1d40349dce
commit
bce010fcb8
@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo import http
|
||||
@ -59,6 +60,32 @@ def format_float_time(float_time):
|
||||
|
||||
class MobileStudent(http.Controller):
|
||||
|
||||
def _strip_html(self, text):
|
||||
if not text:
|
||||
return ""
|
||||
clean = re.sub(r'<[^>]+>', ' ', str(text))
|
||||
return " ".join(clean.split()).strip()
|
||||
|
||||
def _format_announcement(self, a):
|
||||
title = a.title or getattr(a, 'name', '') or 'Announcement'
|
||||
raw_content = a.content or ''
|
||||
summary = self._strip_html(raw_content)[:140]
|
||||
return {
|
||||
"id": a.id,
|
||||
"title": title,
|
||||
"name": title,
|
||||
"date": str(a.date_published or ''),
|
||||
"date_published": str(a.date_published or ''),
|
||||
"expiry_date": str(a.expiry_date) if a.expiry_date else None,
|
||||
"summary": summary,
|
||||
"content": raw_content,
|
||||
"type": a.announcement_type or 'notice',
|
||||
"priority": a.priority or '0',
|
||||
"author": a.author_id.name if a.author_id else None,
|
||||
"has_attachment": bool(a.circular_file),
|
||||
"circular_filename": a.circular_filename or None,
|
||||
}
|
||||
|
||||
def _get_rel(self, record):
|
||||
if record:
|
||||
return {"id": record.id, "name": record.name}
|
||||
@ -189,23 +216,9 @@ class MobileStudent(http.Controller):
|
||||
('academic_year_id', '=', student.academic_year_id.id),
|
||||
], fields=['id', 'name', 'state', 'start_date'], order='start_date asc', limit=5)
|
||||
|
||||
# Announcements
|
||||
ann_records = request.env['education.announcement'].sudo().search(
|
||||
[('active', '=', True)],
|
||||
order='id desc',
|
||||
limit=5
|
||||
)
|
||||
announcements = []
|
||||
for a in ann_records:
|
||||
ann_title = getattr(a, 'title', None) or getattr(a, 'name', 'Announcement')
|
||||
ann_date = getattr(a, 'date_published', None) or getattr(a, 'date', None) or ''
|
||||
ann_content = getattr(a, 'content', None) or getattr(a, 'summary', None) or ''
|
||||
announcements.append({
|
||||
'id': a.id,
|
||||
'name': ann_title,
|
||||
'date': str(ann_date),
|
||||
'summary': str(ann_content)[:100]
|
||||
})
|
||||
# Announcements (filtered by audience, state, and publication window)
|
||||
ann_records, _ = request.env['education.announcement'].get_announcements_for_student(student, limit=5)
|
||||
announcements = [self._format_announcement(a) for a in ann_records]
|
||||
|
||||
# Recent Results
|
||||
results = request.env['education.exam.result'].sudo().search([
|
||||
@ -384,3 +397,66 @@ class MobileStudent(http.Controller):
|
||||
return json_response(True, data={"results": results_data})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/announcements', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_announcements(self, student, **kwargs):
|
||||
try:
|
||||
page = int(kwargs.get('page', 1) or 1)
|
||||
limit = int(kwargs.get('limit', 20) or 20)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if limit < 1 or limit > 100:
|
||||
limit = 20
|
||||
offset = (page - 1) * limit
|
||||
|
||||
ann_type = kwargs.get('type')
|
||||
if ann_type and ann_type not in ['notice', 'circular', 'event']:
|
||||
ann_type = None
|
||||
|
||||
records, total_count = request.env['education.announcement'].get_announcements_for_student(
|
||||
student,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
announcement_type=ann_type
|
||||
)
|
||||
|
||||
announcements_data = [self._format_announcement(a) for a in records]
|
||||
|
||||
return json_response(True, data={
|
||||
"announcements": announcements_data,
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"limit": limit
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/announcements/<int:announcement_id>', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_announcement_detail(self, student, announcement_id, **kwargs):
|
||||
try:
|
||||
ann = request.env['education.announcement'].sudo().browse(announcement_id)
|
||||
if not ann.exists() or not ann.active or ann.state != 'published':
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Announcement not found or unavailable", status=404)
|
||||
|
||||
# Date window validation
|
||||
today = date.today()
|
||||
if ann.date_published > today:
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Announcement not found or unavailable", status=404)
|
||||
if ann.expiry_date and ann.expiry_date < today:
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Announcement has expired", status=404)
|
||||
|
||||
# Multi-company isolation
|
||||
if student.company_id and ann.company_id and ann.company_id.id != student.company_id.id:
|
||||
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this announcement", status=403)
|
||||
|
||||
# Audience and cohort targeting
|
||||
if not ann.check_student_audience(student):
|
||||
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this announcement", status=403)
|
||||
|
||||
return json_response(True, data={
|
||||
"announcement": self._format_announcement(ann)
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
# 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 = 'date_published desc, id desc'
|
||||
_order = 'priority desc, date_published desc, id desc'
|
||||
|
||||
title = fields.Char(string='Title', required=True, translate=True)
|
||||
content = fields.Html(string='Content', required=True)
|
||||
@ -16,7 +20,23 @@ class EducationAnnouncement(models.Model):
|
||||
('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',
|
||||
@ -26,3 +46,131 @@ class EducationAnnouncement(models.Model):
|
||||
)
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<record id="rule_announcement_company" model="ir.rule">
|
||||
<field name="name">Announcement Multi-Company Rule</field>
|
||||
<field name="model_id" ref="model_education_announcement"/>
|
||||
<field name="domain_force">[('company_id', '=', user.company_id.id)]</field>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- Portal User Record Rules -->
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
from . import test_portal
|
||||
from . import test_payment_gateway
|
||||
from . import test_announcement
|
||||
|
||||
238
addons/education_portal/tests/test_announcement.py
Normal file
238
addons/education_portal/tests/test_announcement.py
Normal file
@ -0,0 +1,238 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import ValidationError, AccessError
|
||||
|
||||
|
||||
class TestEducationAnnouncement(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestEducationAnnouncement, self).setUp()
|
||||
|
||||
self.portal_group = self.env.ref('base.group_portal')
|
||||
|
||||
# Academic infrastructure
|
||||
self.campus = self.env['education.campus'].create({'name': 'Ann Campus', 'code': 'AC'})
|
||||
self.department = self.env['education.department'].create({'name': 'Ann Dept', 'code': 'AD', 'campus_id': self.campus.id})
|
||||
self.level = self.env['education.academic.level'].create({'name': 'Ann Level', 'code': 'AL'})
|
||||
self.program_a = self.env['education.program'].create({'name': 'Program A', 'code': 'PA', 'department_id': self.department.id, 'level_id': self.level.id})
|
||||
self.program_b = self.env['education.program'].create({'name': 'Program B', 'code': 'PB', 'department_id': self.department.id, 'level_id': self.level.id})
|
||||
self.year = self.env['education.academic.year'].create({'name': 'Ann Year', 'code': 'AY', 'start_date': '2026-01-01', 'end_date': '2026-12-31'})
|
||||
self.term = self.env['education.academic.term'].create({'name': 'Ann Term', 'code': 'AT', 'academic_year_id': self.year.id, 'start_date': '2026-01-01', 'end_date': '2026-06-30'})
|
||||
self.batch = self.env['education.batch'].create({'name': 'Ann Batch', 'code': 'AB'})
|
||||
self.semester = self.env['education.semester'].create({'name': 'Ann Sem', 'code': 'AS'})
|
||||
self.section_1 = self.env['education.section'].create({'name': 'Section 1', 'code': 'SEC1'})
|
||||
self.section_2 = self.env['education.section'].create({'name': 'Section 2', 'code': 'SEC2'})
|
||||
|
||||
# Student User
|
||||
self.user_student = self.env['res.users'].create({
|
||||
'name': 'Ann Student',
|
||||
'login': 'ann.student@test.com',
|
||||
'email': 'ann.student@test.com',
|
||||
'group_ids': [(6, 0, [self.portal_group.id])],
|
||||
})
|
||||
|
||||
self.student = self.env['education.student'].create({
|
||||
'first_name': 'Ann',
|
||||
'last_name': 'Student',
|
||||
'student_id': 'ANN-STU-01',
|
||||
'admission_number': 'ANN-ADM-01',
|
||||
'roll_number': 'ANN-RL-01',
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program_a.id,
|
||||
'level_id': self.level.id,
|
||||
'academic_year_id': self.year.id,
|
||||
'academic_term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_1.id,
|
||||
'status': 'active',
|
||||
'email': 'ann.student@test.com',
|
||||
'portal_user_id': self.user_student.id,
|
||||
})
|
||||
|
||||
def test_announcement_workflow_and_constraints(self):
|
||||
"""Test draft, publish, archive states and date validation."""
|
||||
today = date.today()
|
||||
# Invalid expiry date constraint
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['education.announcement'].create({
|
||||
'title': 'Invalid Date Announcement',
|
||||
'content': '<p>Testing invalid date</p>',
|
||||
'date_published': today,
|
||||
'expiry_date': today - timedelta(days=2),
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# Valid creation
|
||||
ann = self.env['education.announcement'].create({
|
||||
'title': 'Orientation Notice',
|
||||
'content': '<p>Welcome all new students!</p>',
|
||||
'date_published': today,
|
||||
'expiry_date': today + timedelta(days=10),
|
||||
'announcement_type': 'notice',
|
||||
'priority': '1',
|
||||
'state': 'draft',
|
||||
})
|
||||
self.assertEqual(ann.state, 'draft')
|
||||
self.assertFalse(ann.notification_sent)
|
||||
|
||||
# Publish
|
||||
ann.action_publish()
|
||||
self.assertEqual(ann.state, 'published')
|
||||
self.assertTrue(ann.notification_sent)
|
||||
self.assertIsNotNone(ann.notification_sent_date)
|
||||
|
||||
# Archive
|
||||
ann.action_archive()
|
||||
self.assertEqual(ann.state, 'archived')
|
||||
|
||||
# Reset to draft
|
||||
ann.action_draft()
|
||||
self.assertEqual(ann.state, 'draft')
|
||||
|
||||
def test_audience_filtering_all_vs_cohort(self):
|
||||
"""Test that all-student and cohort-specific announcements filter accurately."""
|
||||
today = date.today()
|
||||
# Announcement for All Students
|
||||
ann_all = self.env['education.announcement'].create({
|
||||
'title': 'General Holiday Notice',
|
||||
'content': '<p>Campus closed tomorrow</p>',
|
||||
'date_published': today,
|
||||
'audience_type': 'all',
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Cohort Announcement matching Student (Program A)
|
||||
ann_prog_a = self.env['education.announcement'].create({
|
||||
'title': 'Program A Seminar',
|
||||
'content': '<p>Mandatory for Program A</p>',
|
||||
'date_published': today,
|
||||
'audience_type': 'cohort',
|
||||
'program_id': self.program_a.id,
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Cohort Announcement for Program B (different program)
|
||||
ann_prog_b = self.env['education.announcement'].create({
|
||||
'title': 'Program B Workshop',
|
||||
'content': '<p>Exclusive for Program B</p>',
|
||||
'date_published': today,
|
||||
'audience_type': 'cohort',
|
||||
'program_id': self.program_b.id,
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Cohort Announcement for Section 2 (student is in Section 1)
|
||||
ann_sec_2 = self.env['education.announcement'].create({
|
||||
'title': 'Section 2 Lab Reschedule',
|
||||
'content': '<p>Section 2 only</p>',
|
||||
'date_published': today,
|
||||
'audience_type': 'cohort',
|
||||
'section_id': self.section_2.id,
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Check individual audience checks
|
||||
self.assertTrue(ann_all.check_student_audience(self.student))
|
||||
self.assertTrue(ann_prog_a.check_student_audience(self.student))
|
||||
self.assertFalse(ann_prog_b.check_student_audience(self.student))
|
||||
self.assertFalse(ann_sec_2.check_student_audience(self.student))
|
||||
|
||||
# Check get_announcements_for_student
|
||||
records, total_count = self.env['education.announcement'].get_announcements_for_student(self.student)
|
||||
record_ids = records.ids
|
||||
self.assertIn(ann_all.id, record_ids)
|
||||
self.assertIn(ann_prog_a.id, record_ids)
|
||||
self.assertNotIn(ann_prog_b.id, record_ids)
|
||||
self.assertNotIn(ann_sec_2.id, record_ids)
|
||||
|
||||
def test_publication_window_filtering(self):
|
||||
"""Test that future-published and expired announcements are excluded."""
|
||||
today = date.today()
|
||||
|
||||
# Currently active
|
||||
ann_active = self.env['education.announcement'].create({
|
||||
'title': 'Active Notice',
|
||||
'content': '<p>Active</p>',
|
||||
'date_published': today - timedelta(days=1),
|
||||
'expiry_date': today + timedelta(days=5),
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Future notice (scheduled)
|
||||
ann_future = self.env['education.announcement'].create({
|
||||
'title': 'Future Notice',
|
||||
'content': '<p>Not yet active</p>',
|
||||
'date_published': today + timedelta(days=2),
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Expired notice
|
||||
ann_expired = self.env['education.announcement'].create({
|
||||
'title': 'Expired Notice',
|
||||
'content': '<p>Expired yesterday</p>',
|
||||
'date_published': today - timedelta(days=10),
|
||||
'expiry_date': today - timedelta(days=1),
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
records, _ = self.env['education.announcement'].get_announcements_for_student(self.student)
|
||||
record_ids = records.ids
|
||||
self.assertIn(ann_active.id, record_ids)
|
||||
self.assertNotIn(ann_future.id, record_ids)
|
||||
self.assertNotIn(ann_expired.id, record_ids)
|
||||
|
||||
def test_notification_dispatch_and_duplicate_prevention(self):
|
||||
"""Test that publishing sends notifications once and avoids duplicates unless forced."""
|
||||
ann = self.env['education.announcement'].create({
|
||||
'title': 'Annual Fest Announcement',
|
||||
'content': '<p>Registrations open</p>',
|
||||
'date_published': date.today(),
|
||||
'audience_type': 'cohort',
|
||||
'program_id': self.program_a.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
self.assertFalse(ann.notification_sent)
|
||||
|
||||
# First publish sends notification
|
||||
ann.action_publish()
|
||||
self.assertTrue(ann.notification_sent)
|
||||
first_dispatch_time = ann.notification_sent_date
|
||||
self.assertIsNotNone(first_dispatch_time)
|
||||
self.assertGreaterEqual(ann.notification_recipient_count, 1)
|
||||
|
||||
# Calling _dispatch_notifications directly should NOT re-dispatch
|
||||
ann._dispatch_notifications(force=False)
|
||||
self.assertEqual(ann.notification_sent_date, first_dispatch_time)
|
||||
|
||||
# Forced resend should re-dispatch
|
||||
ann.action_resend_notification()
|
||||
self.assertTrue(ann.notification_sent)
|
||||
|
||||
def test_student_security_access_control(self):
|
||||
"""Verify that portal student users cannot create, modify, or delete announcements."""
|
||||
today = date.today()
|
||||
ann = self.env['education.announcement'].create({
|
||||
'title': 'Admin Announcement',
|
||||
'content': '<p>Administrative text</p>',
|
||||
'date_published': today,
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Student user attempt to create
|
||||
with self.assertRaises(AccessError):
|
||||
self.env['education.announcement'].with_user(self.user_student).create({
|
||||
'title': 'Student Hack Announcement',
|
||||
'content': '<p>Hacked</p>',
|
||||
'date_published': today,
|
||||
})
|
||||
|
||||
# Student user attempt to write
|
||||
with self.assertRaises(AccessError):
|
||||
ann.with_user(self.user_student).write({'title': 'Defaced Title'})
|
||||
|
||||
# Student user attempt to unlink
|
||||
with self.assertRaises(AccessError):
|
||||
ann.with_user(self.user_student).unlink()
|
||||
@ -5,12 +5,16 @@
|
||||
<field name="model">education.announcement</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Announcements">
|
||||
<field name="priority" widget="priority"/>
|
||||
<field name="title"/>
|
||||
<field name="announcement_type" widget="badge" decoration-info="announcement_type == 'notice'" decoration-warning="announcement_type == 'circular'" decoration-success="announcement_type == 'event'"/>
|
||||
<field name="audience_type" widget="badge" decoration-primary="audience_type == 'all'" decoration-muted="audience_type == 'cohort'"/>
|
||||
<field name="date_published"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'published'" decoration-danger="state == 'archived'"/>
|
||||
<field name="notification_sent" widget="boolean_toggle" optional="show"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="active" widget="boolean_toggle" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
@ -20,20 +24,30 @@
|
||||
<field name="model">education.announcement</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Announcement">
|
||||
<header>
|
||||
<button name="action_publish" string="Publish & Notify" type="object" invisible="state == 'published'" class="oe_highlight"/>
|
||||
<button name="action_archive" string="Archive" type="object" invisible="state == 'archived'"/>
|
||||
<button name="action_draft" string="Reset to Draft" type="object" invisible="state == 'draft'"/>
|
||||
<button name="action_resend_notification" string="Resend Notification" type="object" invisible="state != 'published' or not notification_sent" confirm="Are you sure you want to re-dispatch notifications to students?"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,published,archived"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="title" class="oe_edit_only"/>
|
||||
<h1><field name="title" placeholder="Announcement Title..."/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<group string="General Information">
|
||||
<field name="announcement_type"/>
|
||||
<field name="priority" widget="priority"/>
|
||||
<field name="date_published"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="circular_filename" invisible="1"/>
|
||||
<field name="circular_file" filename="circular_filename" widget="binary" invisible="announcement_type != 'circular'"/>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Publication & Scope">
|
||||
<field name="author_id"/>
|
||||
<field name="audience_type"/>
|
||||
<field name="active"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
@ -42,6 +56,29 @@
|
||||
<page string="Announcement Details" name="content_page">
|
||||
<field name="content" placeholder="Write full details here..." widget="html" options="{'safe': True}"/>
|
||||
</page>
|
||||
<page string="Audience & Cohort Targeting" name="cohort_targeting" invisible="audience_type != 'cohort'">
|
||||
<group>
|
||||
<group string="Academic Scope">
|
||||
<field name="program_id"/>
|
||||
<field name="academic_year_id"/>
|
||||
<field name="academic_term_id"/>
|
||||
</group>
|
||||
<group string="Class & Cohort Scope">
|
||||
<field name="batch_id"/>
|
||||
<field name="semester_id"/>
|
||||
<field name="section_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Notification Audit" name="notification_audit">
|
||||
<group>
|
||||
<group>
|
||||
<field name="notification_sent"/>
|
||||
<field name="notification_sent_date"/>
|
||||
<field name="notification_recipient_count"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
@ -55,10 +92,23 @@
|
||||
<search string="Announcements">
|
||||
<field name="title"/>
|
||||
<field name="announcement_type"/>
|
||||
<field name="program_id"/>
|
||||
<field name="author_id"/>
|
||||
<filter string="Published" name="published" domain="[('state', '=', 'published')]"/>
|
||||
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="Archived" name="archived_status" domain="[('state', '=', 'archived')]"/>
|
||||
<separator/>
|
||||
<filter string="Urgent / Important" name="urgent" domain="[('priority', 'in', ['1', '2'])]"/>
|
||||
<filter string="All Students" name="all_students" domain="[('audience_type', '=', 'all')]"/>
|
||||
<filter string="Cohort Specific" name="cohort_students" domain="[('audience_type', '=', 'cohort')]"/>
|
||||
<separator/>
|
||||
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
|
||||
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
|
||||
<filter string="Inactive" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group>
|
||||
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Type" name="group_type" context="{'group_by': 'announcement_type'}"/>
|
||||
<filter string="Priority" name="group_priority" context="{'group_by': 'priority'}"/>
|
||||
<filter string="Audience" name="group_audience" context="{'group_by': 'audience_type'}"/>
|
||||
<filter string="Published Date" name="group_date" context="{'group_by': 'date_published'}"/>
|
||||
</group>
|
||||
</search>
|
||||
|
||||
@ -13,4 +13,6 @@ class ApiEndpoints {
|
||||
static const String timetable = '/api/mobile/v1/student/timetable';
|
||||
static const String exams = '/api/mobile/v1/student/exams';
|
||||
static const String results = '/api/mobile/v1/student/results';
|
||||
static const String announcements = '/api/mobile/v1/student/announcements';
|
||||
static String announcementDetail(int id) => '/api/mobile/v1/student/announcements/$id';
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import '../../features/attendance/attendance_screen.dart';
|
||||
import '../../features/timetable/timetable_screen.dart';
|
||||
import '../../features/exams/exams_screen.dart';
|
||||
import '../../features/results/results_screen.dart';
|
||||
import '../../features/announcements/announcements_screen.dart';
|
||||
import '../../features/settings/settings_screen.dart';
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
@ -49,6 +50,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
path: '/results',
|
||||
builder: (context, state) => const ResultsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/announcements',
|
||||
builder: (context, state) => const AnnouncementsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
|
||||
496
flutter_app/lib/features/announcements/announcements_screen.dart
Normal file
496
flutter_app/lib/features/announcements/announcements_screen.dart
Normal file
@ -0,0 +1,496 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/announcement.dart';
|
||||
import '../../providers/announcement_provider.dart';
|
||||
|
||||
class AnnouncementsScreen extends ConsumerWidget {
|
||||
const AnnouncementsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeFilter = ref.watch(announcementTypeFilterProvider);
|
||||
final announcementsAsync = ref.watch(announcementsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Notice Board'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Filter Chips Bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip(
|
||||
context: context,
|
||||
ref: ref,
|
||||
label: 'All',
|
||||
filterValue: null,
|
||||
isSelected: activeFilter == null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip(
|
||||
context: context,
|
||||
ref: ref,
|
||||
label: 'Notices',
|
||||
filterValue: 'notice',
|
||||
isSelected: activeFilter == 'notice',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip(
|
||||
context: context,
|
||||
ref: ref,
|
||||
label: 'Circulars',
|
||||
filterValue: 'circular',
|
||||
isSelected: activeFilter == 'circular',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip(
|
||||
context: context,
|
||||
ref: ref,
|
||||
label: 'Events',
|
||||
filterValue: 'event',
|
||||
isSelected: activeFilter == 'event',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// Main Content
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(announcementsProvider);
|
||||
await ref.read(announcementsProvider.future);
|
||||
},
|
||||
child: announcementsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded, size: 48, color: AppTheme.errorColor),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Failed to load announcements',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
err.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.refresh(announcementsProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (pageResult) {
|
||||
final announcements = pageResult.announcements;
|
||||
if (announcements.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
SizedBox(height: 100),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.campaign_outlined, size: 64, color: AppTheme.textMuted),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No announcements found',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'There are no active notices or circulars matching your criteria at this time.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: announcements.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final ann = announcements[index];
|
||||
return _buildAnnouncementCard(context, ann);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required String label,
|
||||
required String? filterValue,
|
||||
required bool isSelected,
|
||||
}) {
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
onSelected: (_) {
|
||||
ref.read(announcementTypeFilterProvider.notifier).state = filterValue;
|
||||
},
|
||||
selectedColor: AppTheme.primaryColor.withOpacity(0.15),
|
||||
checkmarkColor: AppTheme.primaryColor,
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? AppTheme.primaryColor : AppTheme.textSecondary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnnouncementCard(BuildContext context, AnnouncementItem ann) {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _showAnnouncementDetail(context, ann),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Top Badges & Date
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildTypeBadge(ann),
|
||||
if (ann.isUrgent || ann.isImportant) ...[
|
||||
const SizedBox(width: 6),
|
||||
_buildPriorityBadge(ann),
|
||||
],
|
||||
const Spacer(),
|
||||
if (ann.date.isNotEmpty)
|
||||
Text(
|
||||
ann.date,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Title
|
||||
Text(
|
||||
ann.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Summary
|
||||
if (ann.summary.isNotEmpty)
|
||||
Text(
|
||||
ann.summary,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textSecondary,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
|
||||
// Footer: Author & Attachment
|
||||
if (ann.author != null || ann.hasAttachment) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
if (ann.author != null)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.person_outline, size: 14, color: AppTheme.textMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
ann.author!,
|
||||
style: const TextStyle(fontSize: 11, color: AppTheme.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
if (ann.hasAttachment)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.attach_file, size: 14, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
ann.circularFilename ?? 'Attachment',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeBadge(AnnouncementItem ann) {
|
||||
Color bg;
|
||||
Color fg;
|
||||
switch (ann.type) {
|
||||
case 'circular':
|
||||
bg = Colors.deepPurple.shade50;
|
||||
fg = Colors.deepPurple.shade700;
|
||||
break;
|
||||
case 'event':
|
||||
bg = Colors.teal.shade50;
|
||||
fg = Colors.teal.shade700;
|
||||
break;
|
||||
case 'notice':
|
||||
default:
|
||||
bg = Colors.blue.shade50;
|
||||
fg = Colors.blue.shade700;
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
ann.typeLabel,
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: fg),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriorityBadge(AnnouncementItem ann) {
|
||||
Color bg;
|
||||
Color fg;
|
||||
if (ann.isUrgent) {
|
||||
bg = Colors.red.shade50;
|
||||
fg = Colors.red.shade700;
|
||||
} else {
|
||||
bg = Colors.amber.shade50;
|
||||
fg = Colors.amber.shade900;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
ann.priorityLabel,
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: fg),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAnnouncementDetail(BuildContext context, AnnouncementItem ann) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) {
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, scrollController) {
|
||||
return SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Handle
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Header Badges
|
||||
Row(
|
||||
children: [
|
||||
_buildTypeBadge(ann),
|
||||
if (ann.isUrgent || ann.isImportant) ...[
|
||||
const SizedBox(width: 8),
|
||||
_buildPriorityBadge(ann),
|
||||
],
|
||||
const Spacer(),
|
||||
if (ann.date.isNotEmpty)
|
||||
Text(
|
||||
'Published: ${ann.date}',
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Title
|
||||
Text(
|
||||
ann.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Expiry Date & Author
|
||||
if (ann.expiryDate != null || ann.author != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (ann.author != null)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.person_outline, size: 16, color: AppTheme.textSecondary),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Posted by: ${ann.author}',
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (ann.expiryDate != null) ...[
|
||||
if (ann.author != null) const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.event_busy, size: 16, color: AppTheme.textSecondary),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Expires on: ${ann.expiryDate}',
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Content
|
||||
const Text(
|
||||
'Details',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
ann.summary.isNotEmpty ? ann.summary : ann.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textPrimary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
|
||||
// Attachment info
|
||||
if (ann.hasAttachment) ...[
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppTheme.primaryColor.withOpacity(0.3)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: AppTheme.primaryColor.withOpacity(0.05),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.description_outlined, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
ann.circularFilename ?? 'Attached Document',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -678,6 +678,9 @@ class DashboardScreen extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
@ -693,9 +696,24 @@ class DashboardScreen extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () => context.push('/announcements'),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('View All', style: TextStyle(fontSize: 12)),
|
||||
Icon(Icons.chevron_right, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...announcements.map(
|
||||
(ann) => Container(
|
||||
(ann) => InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => context.push('/announcements'),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
@ -741,6 +759,7 @@ class DashboardScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
87
flutter_app/lib/models/announcement.dart
Normal file
87
flutter_app/lib/models/announcement.dart
Normal file
@ -0,0 +1,87 @@
|
||||
import '../core/utils/safe_convert.dart';
|
||||
|
||||
class AnnouncementItem {
|
||||
final int id;
|
||||
final String title;
|
||||
final String name;
|
||||
final String date;
|
||||
final String datePublished;
|
||||
final String? expiryDate;
|
||||
final String summary;
|
||||
final String content;
|
||||
final String type;
|
||||
final String priority;
|
||||
final String? author;
|
||||
final bool hasAttachment;
|
||||
final String? circularFilename;
|
||||
|
||||
const AnnouncementItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.name,
|
||||
required this.date,
|
||||
required this.datePublished,
|
||||
this.expiryDate,
|
||||
required this.summary,
|
||||
required this.content,
|
||||
required this.type,
|
||||
required this.priority,
|
||||
this.author,
|
||||
required this.hasAttachment,
|
||||
this.circularFilename,
|
||||
});
|
||||
|
||||
factory AnnouncementItem.fromJson(Map<String, dynamic> json) {
|
||||
final title = SafeConvert.toSafeString(
|
||||
json['title'],
|
||||
SafeConvert.toSafeString(json['name'], 'Announcement'),
|
||||
);
|
||||
final datePublished = SafeConvert.toSafeString(
|
||||
json['date_published'],
|
||||
SafeConvert.toSafeString(json['date']),
|
||||
);
|
||||
|
||||
return AnnouncementItem(
|
||||
id: SafeConvert.toSafeInt(json['id']),
|
||||
title: title,
|
||||
name: title,
|
||||
date: datePublished,
|
||||
datePublished: datePublished,
|
||||
expiryDate: SafeConvert.toNullableString(json['expiry_date']),
|
||||
summary: SafeConvert.toSafeString(json['summary']),
|
||||
content: SafeConvert.toSafeString(json['content']),
|
||||
type: SafeConvert.toSafeString(json['type'], 'notice'),
|
||||
priority: SafeConvert.toSafeString(json['priority'], '0'),
|
||||
author: SafeConvert.toNullableString(json['author']),
|
||||
hasAttachment: json['has_attachment'] == true,
|
||||
circularFilename: SafeConvert.toNullableString(json['circular_filename']),
|
||||
);
|
||||
}
|
||||
|
||||
bool get isUrgent => priority == '2';
|
||||
bool get isImportant => priority == '1';
|
||||
|
||||
String get priorityLabel {
|
||||
switch (priority) {
|
||||
case '2':
|
||||
return 'Urgent';
|
||||
case '1':
|
||||
return 'Important';
|
||||
case '0':
|
||||
default:
|
||||
return 'Normal';
|
||||
}
|
||||
}
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case 'circular':
|
||||
return 'Circular';
|
||||
case 'event':
|
||||
return 'Event';
|
||||
case 'notice':
|
||||
default:
|
||||
return 'Notice';
|
||||
}
|
||||
}
|
||||
}
|
||||
22
flutter_app/lib/providers/announcement_provider.dart
Normal file
22
flutter_app/lib/providers/announcement_provider.dart
Normal file
@ -0,0 +1,22 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/announcement.dart';
|
||||
import '../repositories/announcement_repository.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
final announcementRepositoryProvider = Provider<AnnouncementRepository>((ref) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return AnnouncementRepository(apiClient: apiClient);
|
||||
});
|
||||
|
||||
final announcementTypeFilterProvider = StateProvider.autoDispose<String?>((ref) => null);
|
||||
|
||||
final announcementsProvider = FutureProvider.autoDispose<AnnouncementPageResult>((ref) async {
|
||||
final repository = ref.watch(announcementRepositoryProvider);
|
||||
final filterType = ref.watch(announcementTypeFilterProvider);
|
||||
return await repository.getAnnouncements(type: filterType);
|
||||
});
|
||||
|
||||
final announcementDetailProvider = FutureProvider.autoDispose.family<AnnouncementItem, int>((ref, id) async {
|
||||
final repository = ref.watch(announcementRepositoryProvider);
|
||||
return await repository.getAnnouncementDetail(id);
|
||||
});
|
||||
63
flutter_app/lib/repositories/announcement_repository.dart
Normal file
63
flutter_app/lib/repositories/announcement_repository.dart
Normal file
@ -0,0 +1,63 @@
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/api/api_endpoints.dart';
|
||||
import '../core/utils/safe_convert.dart';
|
||||
import '../models/announcement.dart';
|
||||
|
||||
class AnnouncementPageResult {
|
||||
final List<AnnouncementItem> announcements;
|
||||
final int totalCount;
|
||||
final int page;
|
||||
final int limit;
|
||||
|
||||
const AnnouncementPageResult({
|
||||
required this.announcements,
|
||||
required this.totalCount,
|
||||
required this.page,
|
||||
required this.limit,
|
||||
});
|
||||
}
|
||||
|
||||
class AnnouncementRepository {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
AnnouncementRepository({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
Future<AnnouncementPageResult> getAnnouncements({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? type,
|
||||
}) async {
|
||||
final queryParameters = <String, dynamic>{
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
};
|
||||
if (type != null && type.isNotEmpty && type != 'all') {
|
||||
queryParameters['type'] = type;
|
||||
}
|
||||
|
||||
final data = await _apiClient.get(
|
||||
ApiEndpoints.announcements,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
|
||||
final map = SafeConvert.toNullableMap(data) ?? {};
|
||||
final list = SafeConvert.toSafeList(
|
||||
map['announcements'],
|
||||
(item) => AnnouncementItem.fromJson(SafeConvert.toNullableMap(item) ?? {}),
|
||||
);
|
||||
|
||||
return AnnouncementPageResult(
|
||||
announcements: list,
|
||||
totalCount: SafeConvert.toSafeInt(map['total_count'], list.length),
|
||||
page: SafeConvert.toSafeInt(map['page'], page),
|
||||
limit: SafeConvert.toSafeInt(map['limit'], limit),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AnnouncementItem> getAnnouncementDetail(int id) async {
|
||||
final data = await _apiClient.get(ApiEndpoints.announcementDetail(id));
|
||||
final map = SafeConvert.toNullableMap(data) ?? {};
|
||||
final annMap = SafeConvert.toNullableMap(map['announcement']) ?? {};
|
||||
return AnnouncementItem.fromJson(annMap);
|
||||
}
|
||||
}
|
||||
87
flutter_app/lib/services/notification_service.dart
Normal file
87
flutter_app/lib/services/notification_service.dart
Normal file
@ -0,0 +1,87 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PushNotificationMessage {
|
||||
final String id;
|
||||
final String title;
|
||||
final String body;
|
||||
final String? type;
|
||||
final Map<String, dynamic> data;
|
||||
final DateTime timestamp;
|
||||
|
||||
const PushNotificationMessage({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.type,
|
||||
this.data = const {},
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory PushNotificationMessage.fromMap(Map<String, dynamic> map) {
|
||||
return PushNotificationMessage(
|
||||
id: map['id']?.toString() ?? UniqueKey().toString(),
|
||||
title: map['title']?.toString() ?? 'Notification',
|
||||
body: map['body']?.toString() ?? '',
|
||||
type: map['type']?.toString(),
|
||||
data: map['data'] is Map<String, dynamic> ? map['data'] as Map<String, dynamic> : {},
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class NotificationService {
|
||||
Future<void> initialize();
|
||||
Future<String?> getDeviceToken();
|
||||
Stream<PushNotificationMessage> get onMessageReceived;
|
||||
Future<void> showInAppNotification(PushNotificationMessage message);
|
||||
void dispose();
|
||||
}
|
||||
|
||||
class InAppNotificationService implements NotificationService {
|
||||
final _messageController = StreamController<PushNotificationMessage>.broadcast();
|
||||
final List<PushNotificationMessage> _history = [];
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
_isInitialized = true;
|
||||
debugPrint('[NotificationService] In-App Fallback Notification Service initialized.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getDeviceToken() async {
|
||||
// In-app fallback has no cloud device push token
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<PushNotificationMessage> get onMessageReceived => _messageController.stream;
|
||||
|
||||
List<PushNotificationMessage> get history => List.unmodifiable(_history);
|
||||
|
||||
@override
|
||||
Future<void> showInAppNotification(PushNotificationMessage message) async {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
}
|
||||
_history.insert(0, message);
|
||||
if (_history.length > 50) {
|
||||
_history.removeLast();
|
||||
}
|
||||
_messageController.add(message);
|
||||
debugPrint('[NotificationService] In-app notification received: ${message.title} - ${message.body}');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.close();
|
||||
}
|
||||
}
|
||||
|
||||
final notificationServiceProvider = Provider<NotificationService>((ref) {
|
||||
final service = InAppNotificationService();
|
||||
ref.onDispose(() => service.dispose());
|
||||
return service;
|
||||
});
|
||||
163
flutter_app/test/announcements_widget_test.dart
Normal file
163
flutter_app/test/announcements_widget_test.dart
Normal file
@ -0,0 +1,163 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:metatron_student_erp/features/announcements/announcements_screen.dart';
|
||||
import 'package:metatron_student_erp/models/announcement.dart';
|
||||
import 'package:metatron_student_erp/providers/announcement_provider.dart';
|
||||
import 'package:metatron_student_erp/repositories/announcement_repository.dart';
|
||||
|
||||
void main() {
|
||||
final sampleAnnouncements = [
|
||||
const AnnouncementItem(
|
||||
id: 201,
|
||||
title: 'Fee Payment Deadline',
|
||||
name: 'Fee Payment Deadline',
|
||||
date: '2026-10-15',
|
||||
datePublished: '2026-10-15',
|
||||
expiryDate: '2026-11-15',
|
||||
summary: 'Please ensure semester fees are paid before deadline.',
|
||||
content: '<p>Please ensure semester fees are paid before deadline.</p>',
|
||||
type: 'circular',
|
||||
priority: '2',
|
||||
author: 'Finance Office',
|
||||
hasAttachment: true,
|
||||
circularFilename: 'fee_notice.pdf',
|
||||
),
|
||||
const AnnouncementItem(
|
||||
id: 202,
|
||||
title: 'Sports Day 2026',
|
||||
name: 'Sports Day 2026',
|
||||
date: '2026-10-18',
|
||||
datePublished: '2026-10-18',
|
||||
summary: 'Annual athletics and sports events registrations.',
|
||||
content: '<p>Annual athletics and sports events registrations.</p>',
|
||||
type: 'event',
|
||||
priority: '1',
|
||||
author: 'Sports Committee',
|
||||
hasAttachment: false,
|
||||
),
|
||||
];
|
||||
|
||||
Widget buildTestableScreen(List<Override> overrides) {
|
||||
return ProviderScope(
|
||||
overrides: overrides,
|
||||
child: const MaterialApp(
|
||||
home: AnnouncementsScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('AnnouncementsScreen renders loading state initially', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestableScreen([
|
||||
announcementsProvider.overrideWith((ref) => Future.delayed(
|
||||
const Duration(seconds: 1),
|
||||
() => const AnnouncementPageResult(
|
||||
announcements: [],
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
),
|
||||
)),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('AnnouncementsScreen renders empty state when no notices exist', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestableScreen([
|
||||
announcementsProvider.overrideWith((ref) => Future.value(
|
||||
const AnnouncementPageResult(
|
||||
announcements: [],
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
),
|
||||
)),
|
||||
]),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No announcements found'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('AnnouncementsScreen renders filter chips and notice cards with priority badges', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestableScreen([
|
||||
announcementsProvider.overrideWith((ref) => Future.value(
|
||||
AnnouncementPageResult(
|
||||
announcements: sampleAnnouncements,
|
||||
totalCount: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
),
|
||||
)),
|
||||
]),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify filter chips
|
||||
expect(find.text('All'), findsOneWidget);
|
||||
expect(find.text('Notices'), findsOneWidget);
|
||||
expect(find.text('Circulars'), findsOneWidget);
|
||||
expect(find.text('Events'), findsOneWidget);
|
||||
|
||||
// Verify card titles and badges
|
||||
expect(find.text('Fee Payment Deadline'), findsOneWidget);
|
||||
expect(find.text('Urgent'), findsOneWidget);
|
||||
expect(find.text('Circular'), findsOneWidget);
|
||||
expect(find.text('Sports Day 2026'), findsOneWidget);
|
||||
expect(find.text('Important'), findsOneWidget);
|
||||
expect(find.text('Event'), findsOneWidget);
|
||||
expect(find.text('fee_notice.pdf'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('AnnouncementsScreen opens detail bottom sheet when card is tapped', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestableScreen([
|
||||
announcementsProvider.overrideWith((ref) => Future.value(
|
||||
AnnouncementPageResult(
|
||||
announcements: sampleAnnouncements,
|
||||
totalCount: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
),
|
||||
)),
|
||||
]),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap the first card
|
||||
await tester.tap(find.text('Fee Payment Deadline'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Check detail sheet elements
|
||||
expect(find.text('Details'), findsOneWidget);
|
||||
expect(find.text('Posted by: Finance Office'), findsOneWidget);
|
||||
expect(find.text('Expires on: 2026-11-15'), findsOneWidget);
|
||||
expect(find.text('Close'), findsOneWidget);
|
||||
|
||||
// Close the sheet
|
||||
await tester.tap(find.text('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Details'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('AnnouncementsScreen renders error state and retry button on failure', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestableScreen([
|
||||
announcementsProvider.overrideWith((ref) => Future.error('Connection timeout')),
|
||||
]),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Failed to load announcements'), findsOneWidget);
|
||||
expect(find.text('Retry'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@ -2,6 +2,7 @@ import 'dart:io';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:metatron_student_erp/core/api/api_client.dart';
|
||||
import 'package:metatron_student_erp/core/storage/secure_storage.dart';
|
||||
import 'package:metatron_student_erp/repositories/announcement_repository.dart';
|
||||
import 'package:metatron_student_erp/repositories/attendance_repository.dart';
|
||||
import 'package:metatron_student_erp/repositories/auth_repository.dart';
|
||||
import 'package:metatron_student_erp/repositories/exam_repository.dart';
|
||||
@ -45,6 +46,7 @@ void main() {
|
||||
late TimetableRepository timetableRepo;
|
||||
late ExamRepository examRepo;
|
||||
late ResultRepository resultRepo;
|
||||
late AnnouncementRepository announcementRepo;
|
||||
|
||||
setUpAll(() async {
|
||||
storage = TestSecureStorage();
|
||||
@ -55,6 +57,7 @@ void main() {
|
||||
timetableRepo = TimetableRepository(apiClient: apiClient);
|
||||
examRepo = ExamRepository(apiClient: apiClient);
|
||||
resultRepo = ResultRepository(apiClient: apiClient);
|
||||
announcementRepo = AnnouncementRepository(apiClient: apiClient);
|
||||
});
|
||||
|
||||
test('1. Flutter Login against live Odoo backend returns session and user', () async {
|
||||
@ -131,7 +134,23 @@ void main() {
|
||||
stdout.writeln('--> Results verified: ${results.length} results found');
|
||||
});
|
||||
|
||||
test('9. POST /api/mobile/v1/auth/logout ends session', () async {
|
||||
test('9. GET /api/mobile/v1/student/announcements and detail return valid notice board data', () async {
|
||||
final res = await announcementRepo.getAnnouncements();
|
||||
expect(res.announcements, isNotEmpty);
|
||||
expect(res.totalCount, isPositive);
|
||||
final firstAnn = res.announcements.first;
|
||||
expect(firstAnn.id, isPositive);
|
||||
expect(firstAnn.title, isNotEmpty);
|
||||
expect(firstAnn.type, isNotEmpty);
|
||||
stdout.writeln('--> Announcements verified: ${res.announcements.length} loaded (first: "${firstAnn.title}")');
|
||||
|
||||
final detail = await announcementRepo.getAnnouncementDetail(firstAnn.id);
|
||||
expect(detail.id, firstAnn.id);
|
||||
expect(detail.title, firstAnn.title);
|
||||
stdout.writeln('--> Announcement detail verified: #${detail.id} "${detail.title}"');
|
||||
});
|
||||
|
||||
test('10. POST /api/mobile/v1/auth/logout ends session', () async {
|
||||
await authRepo.logout();
|
||||
final cookie = await storage.getSessionCookie();
|
||||
expect(cookie, isNull);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:metatron_student_erp/core/utils/safe_convert.dart';
|
||||
import 'package:metatron_student_erp/models/attendance.dart';
|
||||
import 'package:metatron_student_erp/models/announcement.dart';
|
||||
import 'package:metatron_student_erp/models/dashboard.dart';
|
||||
import 'package:metatron_student_erp/models/exam.dart';
|
||||
import 'package:metatron_student_erp/models/result.dart';
|
||||
@ -371,4 +372,74 @@ void main() {
|
||||
expect(adminUser.isStudent, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('AnnouncementItem Model Tests', () {
|
||||
test('parses full announcement JSON with all attributes', () {
|
||||
final json = {
|
||||
'id': 101,
|
||||
'title': 'Semester Fees Circular',
|
||||
'date_published': '2026-10-01',
|
||||
'expiry_date': '2026-11-01',
|
||||
'summary': 'Notice to pay fees on time.',
|
||||
'content': '<p>Notice to pay fees on time.</p>',
|
||||
'type': 'circular',
|
||||
'priority': '2',
|
||||
'author': 'Finance Office',
|
||||
'has_attachment': true,
|
||||
'circular_filename': 'fee_structure.pdf',
|
||||
};
|
||||
|
||||
final item = AnnouncementItem.fromJson(json);
|
||||
expect(item.id, 101);
|
||||
expect(item.title, 'Semester Fees Circular');
|
||||
expect(item.name, 'Semester Fees Circular');
|
||||
expect(item.datePublished, '2026-10-01');
|
||||
expect(item.expiryDate, '2026-11-01');
|
||||
expect(item.type, 'circular');
|
||||
expect(item.typeLabel, 'Circular');
|
||||
expect(item.priority, '2');
|
||||
expect(item.isUrgent, isTrue);
|
||||
expect(item.isImportant, isFalse);
|
||||
expect(item.priorityLabel, 'Urgent');
|
||||
expect(item.author, 'Finance Office');
|
||||
expect(item.hasAttachment, isTrue);
|
||||
expect(item.circularFilename, 'fee_structure.pdf');
|
||||
});
|
||||
|
||||
test('handles missing, nullable, and Odoo false attributes gracefully', () {
|
||||
final json = {
|
||||
'id': 102,
|
||||
'name': 'Sports Day',
|
||||
'date': '2026-10-15',
|
||||
'expiry_date': false,
|
||||
'type': false,
|
||||
'priority': '1',
|
||||
'author': false,
|
||||
'has_attachment': false,
|
||||
'circular_filename': null,
|
||||
};
|
||||
|
||||
final item = AnnouncementItem.fromJson(json);
|
||||
expect(item.id, 102);
|
||||
expect(item.title, 'Sports Day');
|
||||
expect(item.expiryDate, isNull);
|
||||
expect(item.type, 'notice');
|
||||
expect(item.typeLabel, 'Notice');
|
||||
expect(item.priority, '1');
|
||||
expect(item.isImportant, isTrue);
|
||||
expect(item.isUrgent, isFalse);
|
||||
expect(item.priorityLabel, 'Important');
|
||||
expect(item.author, isNull);
|
||||
expect(item.hasAttachment, isFalse);
|
||||
expect(item.circularFilename, isNull);
|
||||
});
|
||||
|
||||
test('defaults priority label to Normal when priority is 0 or unrecognized', () {
|
||||
final json = {'id': 103, 'title': 'General Notice', 'priority': '0'};
|
||||
final item = AnnouncementItem.fromJson(json);
|
||||
expect(item.priorityLabel, 'Normal');
|
||||
expect(item.isUrgent, isFalse);
|
||||
expect(item.isImportant, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user