feat: institutional + support + training admin sections (backend + frontend)
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
This commit is contained in:
402
custom_addons/encoach_lms_api/controllers/communication.py
Normal file
402
custom_addons/encoach_lms_api/controllers/communication.py
Normal file
@@ -0,0 +1,402 @@
|
||||
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_board(b):
|
||||
return {
|
||||
'id': b.id,
|
||||
'name': b.name or '',
|
||||
'course_id': b.course_id.id if b.course_id else 0,
|
||||
'course_name': b.course_id.name if b.course_id else '',
|
||||
'batch_id': b.batch_id.id if b.batch_id else None,
|
||||
'batch_name': b.batch_id.name if b.batch_id else None,
|
||||
'chapter_id': b.chapter_id.id if b.chapter_id else None,
|
||||
'chapter_name': b.chapter_id.name if b.chapter_id else None,
|
||||
'is_enabled': b.is_enabled,
|
||||
'allow_student_posts': b.allow_student_posts,
|
||||
'post_count': b.post_count or 0,
|
||||
'assignment_id': None,
|
||||
}
|
||||
|
||||
|
||||
def _ser_post(p):
|
||||
return {
|
||||
'id': p.id,
|
||||
'board_id': p.board_id.id,
|
||||
'parent_id': p.parent_id.id if p.parent_id else None,
|
||||
'author_id': p.author_id.id,
|
||||
'author_name': p.author_id.name or '',
|
||||
'author_role': '',
|
||||
'title': p.title or '',
|
||||
'content': p.content or '',
|
||||
'attachment_ids': [],
|
||||
'attachment_names': [],
|
||||
'is_pinned': p.is_pinned,
|
||||
'is_resolved': p.is_resolved,
|
||||
'reply_count': len(p.child_ids),
|
||||
'created_at': str(p.create_date) if p.create_date else '',
|
||||
}
|
||||
|
||||
|
||||
def _ser_announcement(a):
|
||||
return {
|
||||
'id': a.id,
|
||||
'title': a.title or '',
|
||||
'content': a.content or '',
|
||||
'author_id': a.author_id.id,
|
||||
'author_name': a.author_id.name or '',
|
||||
'course_id': a.course_id.id if a.course_id else None,
|
||||
'course_name': a.course_id.name if a.course_id else None,
|
||||
'batch_id': a.batch_id.id if a.batch_id else None,
|
||||
'batch_name': a.batch_id.name if a.batch_id else None,
|
||||
'priority': a.priority or 'normal',
|
||||
'is_published': a.is_published,
|
||||
'published_at': str(a.published_at) if a.published_at else None,
|
||||
'expires_at': str(a.expires_at) if a.expires_at else None,
|
||||
'send_email': a.send_email,
|
||||
'attachment_ids': [],
|
||||
'attachment_names': [],
|
||||
}
|
||||
|
||||
|
||||
def _ser_message(m):
|
||||
return {
|
||||
'id': m.id,
|
||||
'sender_id': m.sender_id.id,
|
||||
'sender_name': m.sender_id.name or '',
|
||||
'recipient_id': m.recipient_id.id,
|
||||
'recipient_name': m.recipient_id.name or '',
|
||||
'subject': m.subject or '',
|
||||
'content': m.content or '',
|
||||
'is_read': m.is_read,
|
||||
'read_at': str(m.read_at) if m.read_at else None,
|
||||
'attachment_ids': [],
|
||||
'attachment_names': [],
|
||||
'send_email_copy': m.send_email_copy,
|
||||
'created_at': str(m.create_date) if m.create_date else '',
|
||||
}
|
||||
|
||||
|
||||
class CommunicationController(http.Controller):
|
||||
|
||||
# ── Discussion Boards ────────────────────────────────────────────
|
||||
|
||||
@http.route('/api/discussion-boards', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_boards(self, **kw):
|
||||
try:
|
||||
M = request.env['encoach.discussion.board'].sudo()
|
||||
domain = []
|
||||
if kw.get('course_id'):
|
||||
domain.append(('course_id', '=', int(kw['course_id'])))
|
||||
if kw.get('batch_id'):
|
||||
domain.append(('batch_id', '=', int(kw['batch_id'])))
|
||||
recs = M.search(domain, limit=200, order='id desc')
|
||||
return _json_response([_ser_board(r) for r in recs])
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/discussion-boards', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_board(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
vals = {'name': body.get('name', '')}
|
||||
if body.get('course_id'):
|
||||
vals['course_id'] = int(body['course_id'])
|
||||
if body.get('batch_id'):
|
||||
vals['batch_id'] = int(body['batch_id'])
|
||||
rec = request.env['encoach.discussion.board'].sudo().create(vals)
|
||||
return _json_response(_ser_board(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/discussion-boards/<int:bid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_board(self, bid, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.discussion.board'].sudo().browse(bid)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
if 'name' in body:
|
||||
vals['name'] = body['name']
|
||||
if 'is_enabled' in body:
|
||||
vals['is_enabled'] = bool(body['is_enabled'])
|
||||
if 'allow_student_posts' in body:
|
||||
vals['allow_student_posts'] = bool(body['allow_student_posts'])
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response(_ser_board(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── Posts ────────────────────────────────────────────────────────
|
||||
|
||||
@http.route('/api/discussion-boards/<int:bid>/posts', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_posts(self, bid, **kw):
|
||||
try:
|
||||
M = request.env['encoach.discussion.post'].sudo()
|
||||
domain = [('board_id', '=', bid), ('parent_id', '=', False)]
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = M.search_count(domain)
|
||||
recs = M.search(domain, offset=offset, limit=limit, order='is_pinned desc, create_date desc')
|
||||
items = [_ser_post(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/discussion-boards/<int:bid>/posts', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_post(self, bid, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
vals = {
|
||||
'board_id': bid,
|
||||
'author_id': request.env.uid,
|
||||
'content': body.get('content', ''),
|
||||
}
|
||||
if body.get('title'):
|
||||
vals['title'] = body['title']
|
||||
if body.get('parent_id'):
|
||||
vals['parent_id'] = int(body['parent_id'])
|
||||
rec = request.env['encoach.discussion.post'].sudo().create(vals)
|
||||
return _json_response(_ser_post(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/posts/<int:pid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_post(self, pid, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.discussion.post'].sudo().browse(pid)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
if 'content' in body:
|
||||
vals['content'] = body['content']
|
||||
if 'title' in body:
|
||||
vals['title'] = body['title']
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response(_ser_post(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/posts/<int:pid>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_post(self, pid, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.discussion.post'].sudo().browse(pid)
|
||||
if rec.exists():
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/posts/<int:pid>/pin', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def pin_post(self, pid, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.discussion.post'].sudo().browse(pid)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
body = _get_json_body()
|
||||
pinned = body.get('pinned', not rec.is_pinned)
|
||||
rec.write({'is_pinned': pinned})
|
||||
return _json_response(_ser_post(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/posts/<int:pid>/resolve', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def resolve_post(self, pid, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.discussion.post'].sudo().browse(pid)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
rec.write({'is_resolved': True})
|
||||
return _json_response(_ser_post(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── Announcements ────────────────────────────────────────────────
|
||||
|
||||
@http.route('/api/announcements', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_announcements(self, **kw):
|
||||
try:
|
||||
M = request.env['encoach.announcement'].sudo()
|
||||
domain = []
|
||||
if kw.get('course_id'):
|
||||
domain.append(('course_id', '=', int(kw['course_id'])))
|
||||
if kw.get('priority'):
|
||||
domain.append(('priority', '=', kw['priority']))
|
||||
recs = M.search(domain, limit=200, order='create_date desc')
|
||||
return _json_response([_ser_announcement(r) for r in recs])
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/announcements', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_announcement(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
vals = {
|
||||
'title': body.get('title', ''),
|
||||
'content': body.get('content', ''),
|
||||
'author_id': request.env.uid,
|
||||
'priority': body.get('priority', 'normal'),
|
||||
'send_email': body.get('send_email', False),
|
||||
}
|
||||
if body.get('course_id'):
|
||||
vals['course_id'] = int(body['course_id'])
|
||||
if body.get('batch_id'):
|
||||
vals['batch_id'] = int(body['batch_id'])
|
||||
if body.get('expires_at'):
|
||||
vals['expires_at'] = body['expires_at']
|
||||
rec = request.env['encoach.announcement'].sudo().create(vals)
|
||||
return _json_response(_ser_announcement(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/announcements/<int:aid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_announcement(self, aid, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.announcement'].sudo().browse(aid)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
for k in ('title', 'content', 'priority', 'expires_at'):
|
||||
if k in body:
|
||||
vals[k] = body[k]
|
||||
if 'send_email' in body:
|
||||
vals['send_email'] = bool(body['send_email'])
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response(_ser_announcement(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/announcements/<int:aid>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_announcement(self, aid, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.announcement'].sudo().browse(aid)
|
||||
if rec.exists():
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/announcements/<int:aid>/publish', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def publish_announcement(self, aid, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.announcement'].sudo().browse(aid)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
from datetime import datetime
|
||||
rec.write({'is_published': True, 'published_at': str(datetime.now())})
|
||||
return _json_response(_ser_announcement(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── Messages ─────────────────────────────────────────────────────
|
||||
|
||||
@http.route('/api/messages', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_messages(self, **kw):
|
||||
try:
|
||||
M = request.env['encoach.message'].sudo()
|
||||
uid = request.env.uid
|
||||
domain = [('recipient_id', '=', uid)]
|
||||
if kw.get('is_read') == 'false':
|
||||
domain.append(('is_read', '=', False))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = M.search_count(domain)
|
||||
recs = M.search(domain, offset=offset, limit=limit, order='create_date desc')
|
||||
items = [_ser_message(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/messages/sent', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_sent_messages(self, **kw):
|
||||
try:
|
||||
M = request.env['encoach.message'].sudo()
|
||||
uid = request.env.uid
|
||||
offset, limit, page = _paginate(kw)
|
||||
domain = [('sender_id', '=', uid)]
|
||||
total = M.search_count(domain)
|
||||
recs = M.search(domain, offset=offset, limit=limit, order='create_date desc')
|
||||
items = [_ser_message(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/messages', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def send_message(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
vals = {
|
||||
'sender_id': request.env.uid,
|
||||
'recipient_id': int(body.get('recipient_id', 0)),
|
||||
'subject': body.get('subject', ''),
|
||||
'content': body.get('content', ''),
|
||||
'send_email_copy': body.get('send_email_copy', False),
|
||||
}
|
||||
rec = request.env['encoach.message'].sudo().create(vals)
|
||||
return _json_response(_ser_message(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/messages/<int:mid>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_message(self, mid, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.message'].sudo().browse(mid)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
if not rec.is_read and rec.recipient_id.id == request.env.uid:
|
||||
from datetime import datetime
|
||||
rec.write({'is_read': True, 'read_at': str(datetime.now())})
|
||||
return _json_response(_ser_message(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/messages/<int:mid>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_message(self, mid, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.message'].sudo().browse(mid)
|
||||
if rec.exists():
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/messages/unread-count', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def unread_count(self, **kw):
|
||||
try:
|
||||
count = request.env['encoach.message'].sudo().search_count([
|
||||
('recipient_id', '=', request.env.uid),
|
||||
('is_read', '=', False),
|
||||
])
|
||||
return _json_response({'count': count})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
Reference in New Issue
Block a user