Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs. Backend (new `encoach_lms_api` addon + existing addons): - Institutional: academic years/terms, departments, admission registers & admissions, courses/batches, lessons, fees (terms + student fees + invoicing with income-account auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset), student leave, result templates + marksheets (incl. delete-with-cascade). - Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived from `op.student.fees.details` and `account.move`; platform settings backed by `encoach.code` and `ir.config_parameter` (packages + grading config). - Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models) with CRUD, pagination, search/level filters, and upsert-style progress endpoints. Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`; `ValidationError`/`UserError` mapped to HTTP 400. Frontend: - Rewire institutional admin pages (Academic Year Manager, Admissions, Courses, Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets, Taxonomy, Resources) to real APIs with React Query invalidation and dialogs. - New typed services: `payments.service.ts`, `platformSettings.service.ts`, `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/ resources/student-progress/generation` services + related types. - Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`, `TicketsPage` to consume live data with search/filter/progress/CRUD flows. - New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`. - Favicons/branding assets and misc. UX polish across teacher/student pages. Tooling & QA: - Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent, covers institutional + support + training fixtures incl. income-account wiring). - API write-flow test suites: `test_write_flows.py` (institutional), `test_support_flows.py` (support), `test_training_flows.py` (training), `test_ai_full.py`. All suites pass end-to-end. - Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA. - `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`, `frontend/node_modules/`. Made-with: Cursor
204 lines
8.3 KiB
Python
204 lines
8.3 KiB
Python
import logging
|
|
from odoo import http
|
|
from odoo.http import request
|
|
from odoo.addons.encoach_api.controllers.base import (
|
|
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _ser_notif(n):
|
|
return {
|
|
'id': n.id,
|
|
'title': n.title or '',
|
|
'message': n.message or '',
|
|
'type': n.type or 'info',
|
|
'is_read': n.is_read,
|
|
'read_at': str(n.read_at) if n.read_at else None,
|
|
'reference_model': n.reference_model or None,
|
|
'reference_id': n.reference_id or None,
|
|
'created_at': str(n.create_date) if n.create_date else '',
|
|
}
|
|
|
|
|
|
def _ser_rule(r):
|
|
return {
|
|
'id': r.id,
|
|
'name': r.name or '',
|
|
'event_type': r.event_type or '',
|
|
'template': r.template or '',
|
|
'is_active': r.is_active,
|
|
'recipients': r.recipients or 'all',
|
|
'channels': r.channels or '',
|
|
}
|
|
|
|
|
|
class NotificationsController(http.Controller):
|
|
|
|
@http.route('/api/notifications', type='http', auth='public', methods=['GET'], csrf=False)
|
|
@jwt_required
|
|
def list_notifications(self, **kw):
|
|
try:
|
|
M = request.env['encoach.notification'].sudo()
|
|
domain = [('user_id', '=', request.env.uid)]
|
|
if kw.get('type'):
|
|
domain.append(('type', '=', kw['type']))
|
|
if kw.get('is_read') == 'false':
|
|
domain.append(('is_read', '=', False))
|
|
elif kw.get('is_read') == 'true':
|
|
domain.append(('is_read', '=', True))
|
|
offset, limit, page = _paginate(kw)
|
|
total = M.search_count(domain)
|
|
recs = M.search(domain, offset=offset, limit=limit, order='create_date desc')
|
|
items = [_ser_notif(r) for r in recs]
|
|
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
|
|
except Exception as e:
|
|
return _error_response(str(e), 500)
|
|
|
|
@http.route('/api/notifications/<int:nid>/read', type='http', auth='public', methods=['POST'], csrf=False)
|
|
@jwt_required
|
|
def mark_read(self, nid, **kw):
|
|
try:
|
|
rec = request.env['encoach.notification'].sudo().browse(nid)
|
|
if not rec.exists():
|
|
return _error_response('Not found', 404)
|
|
from datetime import datetime
|
|
rec.write({'is_read': True, 'read_at': str(datetime.now())})
|
|
return _json_response(_ser_notif(rec))
|
|
except Exception as e:
|
|
return _error_response(str(e), 500)
|
|
|
|
@http.route('/api/notifications/read-all', type='http', auth='public', methods=['POST'], csrf=False)
|
|
@jwt_required
|
|
def mark_all_read(self, **kw):
|
|
try:
|
|
from datetime import datetime
|
|
recs = request.env['encoach.notification'].sudo().search([
|
|
('user_id', '=', request.env.uid), ('is_read', '=', False)
|
|
])
|
|
recs.write({'is_read': True, 'read_at': str(datetime.now())})
|
|
return _json_response({'success': True})
|
|
except Exception as e:
|
|
return _error_response(str(e), 500)
|
|
|
|
@http.route('/api/notifications/unread-count', type='http', auth='public', methods=['GET'], csrf=False)
|
|
@jwt_required
|
|
def unread_count(self, **kw):
|
|
try:
|
|
count = request.env['encoach.notification'].sudo().search_count([
|
|
('user_id', '=', request.env.uid), ('is_read', '=', False)
|
|
])
|
|
return _json_response({'count': count})
|
|
except Exception as e:
|
|
return _error_response(str(e), 500)
|
|
|
|
# ── Rules ────────────────────────────────────────────────────────
|
|
|
|
@http.route('/api/notification-rules', type='http', auth='public', methods=['GET'], csrf=False)
|
|
@jwt_required
|
|
def list_rules(self, **kw):
|
|
try:
|
|
recs = request.env['encoach.notification.rule'].sudo().search([], order='id desc')
|
|
return _json_response([_ser_rule(r) for r in recs])
|
|
except Exception as e:
|
|
return _error_response(str(e), 500)
|
|
|
|
@http.route('/api/notification-rules', type='http', auth='public', methods=['POST'], csrf=False)
|
|
@jwt_required
|
|
def create_rule(self, **kw):
|
|
try:
|
|
body = _get_json_body()
|
|
vals = {
|
|
'name': body.get('name', ''),
|
|
'event_type': body.get('event_type', ''),
|
|
}
|
|
if body.get('template'):
|
|
vals['template'] = body['template']
|
|
if body.get('recipients'):
|
|
vals['recipients'] = body['recipients']
|
|
if body.get('channels'):
|
|
vals['channels'] = body['channels']
|
|
if 'is_active' in body:
|
|
vals['is_active'] = bool(body['is_active'])
|
|
rec = request.env['encoach.notification.rule'].sudo().create(vals)
|
|
return _json_response(_ser_rule(rec))
|
|
except Exception as e:
|
|
return _error_response(str(e), 500)
|
|
|
|
@http.route('/api/notification-rules/<int:rid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
|
@jwt_required
|
|
def update_rule(self, rid, **kw):
|
|
try:
|
|
rec = request.env['encoach.notification.rule'].sudo().browse(rid)
|
|
if not rec.exists():
|
|
return _error_response('Not found', 404)
|
|
body = _get_json_body()
|
|
vals = {}
|
|
for k in ('name', 'event_type', 'template', 'recipients', 'channels'):
|
|
if k in body:
|
|
vals[k] = body[k]
|
|
if 'is_active' in body:
|
|
vals['is_active'] = bool(body['is_active'])
|
|
if vals:
|
|
rec.write(vals)
|
|
return _json_response(_ser_rule(rec))
|
|
except Exception as e:
|
|
return _error_response(str(e), 500)
|
|
|
|
@http.route('/api/notification-rules/<int:rid>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
|
@jwt_required
|
|
def delete_rule(self, rid, **kw):
|
|
try:
|
|
rec = request.env['encoach.notification.rule'].sudo().browse(rid)
|
|
if rec.exists():
|
|
rec.unlink()
|
|
return _json_response({'success': True})
|
|
except Exception as e:
|
|
return _error_response(str(e), 500)
|
|
|
|
# ── Preferences ──────────────────────────────────────────────────
|
|
|
|
@http.route('/api/notification-preferences', type='http', auth='public', methods=['GET'], csrf=False)
|
|
@jwt_required
|
|
def get_preferences(self, **kw):
|
|
try:
|
|
M = request.env['encoach.notification.preference'].sudo()
|
|
rec = M.search([('user_id', '=', request.env.uid)], limit=1)
|
|
if not rec:
|
|
rec = M.create({'user_id': request.env.uid})
|
|
return _json_response({
|
|
'email_enabled': rec.email_enabled,
|
|
'push_enabled': rec.push_enabled,
|
|
'in_app_enabled': rec.in_app_enabled,
|
|
'digest_frequency': rec.digest_frequency,
|
|
})
|
|
except Exception as e:
|
|
return _error_response(str(e), 500)
|
|
|
|
@http.route('/api/notification-preferences', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
|
@jwt_required
|
|
def update_preferences(self, **kw):
|
|
try:
|
|
M = request.env['encoach.notification.preference'].sudo()
|
|
rec = M.search([('user_id', '=', request.env.uid)], limit=1)
|
|
if not rec:
|
|
rec = M.create({'user_id': request.env.uid})
|
|
body = _get_json_body()
|
|
vals = {}
|
|
for k in ('email_enabled', 'push_enabled', 'in_app_enabled'):
|
|
if k in body:
|
|
vals[k] = bool(body[k])
|
|
if 'digest_frequency' in body:
|
|
vals['digest_frequency'] = body['digest_frequency']
|
|
if vals:
|
|
rec.write(vals)
|
|
return _json_response({
|
|
'email_enabled': rec.email_enabled,
|
|
'push_enabled': rec.push_enabled,
|
|
'in_app_enabled': rec.in_app_enabled,
|
|
'digest_frequency': rec.digest_frequency,
|
|
})
|
|
except Exception as e:
|
|
return _error_response(str(e), 500)
|