Files
Yamen Ahmad 6ec68160c8 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
2026-04-19 03:13:23 +04:00

261 lines
11 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, _paginate, _get_json_body,
)
_get_json = _get_json_body
_logger = logging.getLogger(__name__)
def _serialize_plan(r):
"""Build a fees-plan record with REAL paid/remaining pulled from the
linked account.move (invoice), not a hard-coded zero."""
total = float(getattr(r, 'amount', 0) or 0)
after_discount = float(getattr(r, 'after_discount_amount', 0) or total)
invoice = r.invoice_id if hasattr(r, 'invoice_id') else False
invoice_state = ''
payment_state = 'not_paid'
amount_residual = after_discount
paid_amount = 0.0
invoice_id = 0
invoice_number = ''
if invoice and invoice.exists():
invoice_id = invoice.id
invoice_state = invoice.state or ''
payment_state = getattr(invoice, 'payment_state', '') or 'not_paid'
amount_residual = float(getattr(invoice, 'amount_residual', after_discount) or 0)
amount_total = float(getattr(invoice, 'amount_total', after_discount) or 0)
paid_amount = max(0.0, amount_total - amount_residual)
invoice_number = getattr(invoice, 'name', '') or ''
return {
'id': r.id,
'student_id': r.student_id.id if getattr(r, 'student_id', False) else 0,
'student_name': (
r.student_id.partner_id.name
if getattr(r, 'student_id', False) and r.student_id.partner_id else ''
),
'course_id': r.course_id.id if getattr(r, 'course_id', False) else 0,
'course_name': r.course_id.name if getattr(r, 'course_id', False) else '',
'batch_id': r.batch_id.id if getattr(r, 'batch_id', False) else 0,
'batch_name': r.batch_id.name if getattr(r, 'batch_id', False) else '',
'product_id': r.product_id.id if getattr(r, 'product_id', False) else 0,
'product_name': r.product_id.name if getattr(r, 'product_id', False) else '',
'date': str(r.date) if getattr(r, 'date', False) else '',
'discount': float(getattr(r, 'discount', 0) or 0),
'total_amount': total,
'after_discount_amount': after_discount,
'paid_amount': round(paid_amount, 2),
'remaining_amount': round(amount_residual, 2),
'state': getattr(r, 'state', 'draft') or 'draft',
'invoice_id': invoice_id,
'invoice_number': invoice_number,
'invoice_state': invoice_state,
'payment_state': payment_state,
'currency': r.currency_id.name if getattr(r, 'currency_id', False) else '',
}
class FeesController(http.Controller):
@http.route('/api/fees-plans', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_fees_plans(self, **kw):
try:
M = request.env['op.student.fees.details'].sudo()
offset, limit, page = _paginate(kw)
domain = []
state = (kw.get('state') or '').strip()
if state:
domain.append(('state', '=', state))
payment_state = (kw.get('payment_state') or '').strip()
q = (kw.get('q') or '').strip()
if q:
domain += ['|',
('student_id.partner_id.name', 'ilike', q),
('product_id.name', 'ilike', q)]
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [_serialize_plan(r) for r in recs]
if payment_state:
items = [i for i in items if i['payment_state'] == payment_state]
return _json_response({
'items': items, 'data': items,
'total': total, 'page': page, 'size': limit,
})
except Exception as e:
_logger.exception('list_fees_plans')
return _error_response(str(e), 500)
@http.route('/api/fees-plans/<int:fid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_fees_plan(self, fid, **kw):
try:
rec = request.env['op.student.fees.details'].sudo().browse(fid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_serialize_plan(rec))
except Exception as e:
_logger.exception('get_fees_plan')
return _error_response(str(e), 500)
@http.route('/api/fees-plans/<int:fid>/create-invoice', type='http',
auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_invoice(self, fid, **kw):
"""Generate the accounting invoice for this fee line (uses the built-in
OpenEduCat `get_invoice()` model method)."""
try:
rec = request.env['op.student.fees.details'].sudo().browse(fid)
if not rec.exists():
return _error_response('Fees plan not found', 404)
if not rec.product_id:
return _error_response('This fees plan has no product; cannot invoice.', 400)
if float(rec.amount or 0) <= 0:
return _error_response('Fees amount must be positive to invoice.', 400)
# Auto-wire a default income account on the product/category if
# the Odoo chart of accounts is set up but the product was seeded
# without one. Otherwise OpenEduCat raises a UserError.
prod_tmpl = rec.product_id.sudo().product_tmpl_id
try:
fp = prod_tmpl.get_product_accounts() if hasattr(
prod_tmpl, 'get_product_accounts') else {}
except Exception:
fp = {}
if not (fp or {}).get('income'):
AA = request.env['account.account'].sudo()
# Odoo 19: account.account has a M2M `company_ids`.
try:
income_acc = AA.search([
('account_type', '=', 'income'),
('company_ids', 'in', request.env.company.id),
], limit=1)
except Exception:
income_acc = AA.browse()
if not income_acc:
income_acc = AA.search([('account_type', '=', 'income')], limit=1)
if income_acc:
try:
prod_tmpl.with_company(request.env.company).write({
'property_account_income_id': income_acc.id,
})
except Exception:
_logger.exception('auto-wire income account')
rec.get_invoice()
rec.invalidate_recordset()
return _json_response(_serialize_plan(rec))
except Exception as e:
_logger.exception('create_invoice')
return _error_response(str(e), 500)
@http.route('/api/fees-plans/<int:fid>/register-payment', type='http',
auth='public', methods=['POST'], csrf=False)
@jwt_required
def register_payment(self, fid, **kw):
"""Register a payment against the invoice linked to this fees plan.
Body: { "amount": float, "journal_id": int (optional),
"payment_date": "YYYY-MM-DD" (optional), "memo": str (optional) }"""
try:
body = _get_json() or {}
rec = request.env['op.student.fees.details'].sudo().browse(fid)
if not rec.exists():
return _error_response('Fees plan not found', 404)
invoice = rec.invoice_id
if not invoice or not invoice.exists():
return _error_response('Create an invoice before registering a payment.', 400)
if invoice.state == 'draft':
invoice.sudo().action_post()
amount = float(body.get('amount') or invoice.amount_residual or 0)
if amount <= 0:
return _error_response('Payment amount must be positive.', 400)
payment_date = body.get('payment_date') or False
memo = body.get('memo') or f"Payment for {rec.product_id.name or 'fees'}"
journal_id = body.get('journal_id')
if not journal_id:
journal = request.env['account.journal'].sudo().search(
[('type', 'in', ('bank', 'cash'))], limit=1
)
if not journal:
return _error_response(
'No bank/cash journal configured. Open Accounting → Configuration → Journals.',
400)
journal_id = journal.id
wiz_vals = {
'amount': amount,
'journal_id': int(journal_id),
'communication': memo,
}
if payment_date:
wiz_vals['payment_date'] = payment_date
wizard = request.env['account.payment.register'].sudo().with_context(
active_model='account.move', active_ids=[invoice.id]
).create(wiz_vals)
wizard.action_create_payments()
invoice.invalidate_recordset()
rec.invalidate_recordset()
return _json_response(_serialize_plan(rec))
except Exception as e:
_logger.exception('register_payment')
return _error_response(str(e), 500)
@http.route('/api/student-fees', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_student_fees(self, **kw):
try:
M = request.env['op.student.fees.details'].sudo()
offset, limit, page = _paginate(kw)
domain = []
student_id = kw.get('student_id')
if student_id:
try:
domain.append(('student_id', '=', int(student_id)))
except Exception:
pass
q = (kw.get('q') or '').strip()
if q:
domain += ['|',
('student_id.partner_id.name', 'ilike', q),
('product_id.name', 'ilike', q)]
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [_serialize_plan(r) for r in recs]
return _json_response({
'items': items, 'data': items,
'total': total, 'page': page, 'size': limit,
})
except Exception as e:
_logger.exception('list_student_fees')
return _error_response(str(e), 500)
@http.route('/api/fees-terms', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_fees_terms(self, **kw):
try:
M = request.env['op.fees.terms'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'name': r.name or '',
'no_days': getattr(r, 'no_days', 0) or 0,
'line_ids': r.line_ids.ids if hasattr(r, 'line_ids') else [],
} for r in recs]
return _json_response({
'items': items, 'data': items,
'total': total, 'page': page, 'size': limit,
})
except Exception as e:
_logger.exception('list_fees_terms')
return _error_response(str(e), 500)