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:
260
custom_addons/encoach_lms_api/controllers/fees.py
Normal file
260
custom_addons/encoach_lms_api/controllers/fees.py
Normal file
@@ -0,0 +1,260 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user