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:
Yamen Ahmad
2026-04-19 03:13:23 +04:00
parent 74d83af57f
commit 6ec68160c8
59 changed files with 9076 additions and 143 deletions

View 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)