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
261 lines
11 KiB
Python
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)
|