fix(config): align Configuration pages with platform logic

Audit of the admin Configuration section (FAQ Manager, Notification Rules,
Approval Config) surfaced three contract mismatches and one missing schema.

FAQ
  * Backend `encoach.faq.category.audience` / `encoach.faq.item.audience`
    rejected the `both` and `entity` values emitted by the FAQ Manager
    UI. Widened the Selection so the UI's full vocabulary round-trips.
  * FAQ item `video_url` was already accepted by the UI but silently
    dropped by the controller; now persisted via a new `video_url`
    field on `encoach.faq.item` and serialized on list/get responses.

Notification Rules
  * Added `days_before`, `frequency`, `channel`, `entity_id` to
    `encoach.notification.rule`. These are the exact fields the admin
    form collects; prior to this change they were serialized into the
    JSON body, ignored by the controller, and omitted from responses,
    leaving the Active switch permanently off and the table columns
    blank.
  * Controller now translates `active` <-> `is_active` both ways so the
    new frontend contract and legacy callers coexist. Missing required
    fields return HTTP 400 instead of 500.

Approval Workflows
  * The controller had been hand-rolling raw SQL against three tables
    (`encoach_approval_workflow`, `_stage`, `_request`) that no Odoo
    model declared, so the tables never existed. List() was guarded and
    returned empty; create() would 500 with "relation does not exist".
  * Introduced real ORM models in `encoach_exam_template/models/approval.py`
    plus access rights, which auto-provision the tables on -u.
  * Rewrote the controller to use ORM, added PATCH, and emitted both
    `items` and `results` in the list envelope so the frontend's
    PaginatedResponse reader and legacy callers both work. Step
    payloads now carry `max_days`, `auto_escalate`, and
    `notification_email` end to end.
  * Frontend `approvalsService` and `ApprovalWorkflowConfig` updated to
    send the full stage shape + `allow_bypass`, tolerate both envelope
    keys, and validate at least one approver before submit.

Schema delta applied via `./run.sh -u encoach_exam_template,encoach_lms_api`.
Verified with new `test_config_flows.py`: 24/24 passing. Regression runs
on `test_support_flows.py` (29/29) and `test_training_flows.py` (26/26)
remain green.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 10:58:43 +04:00
parent 6ec68160c8
commit 96f419d653
8 changed files with 430 additions and 236 deletions

View File

@@ -1,4 +1,10 @@
import json """Approval Workflow API.
Replaces the earlier raw-SQL implementation with a standard Odoo ORM
controller. Backing models live in ``encoach_exam_template/models/approval.py``.
Serves the admin "Approval Config" page (``/admin/approval-config``).
"""
import logging import logging
from datetime import datetime from datetime import datetime
@@ -11,86 +17,133 @@ from odoo.addons.encoach_api.controllers.base import (
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
def _cr(): # ── Serializers ─────────────────────────────────────────────────────────────
return request.env.cr
def _ser_stage(stage):
"""Stages are written as ``sequence`` (ORM) but the admin UI speaks
``order`` (1-based, dense). We emit both so either side works."""
return {
'id': stage.id,
'order': stage.sequence,
'sequence': stage.sequence,
'approver_id': stage.approver_id.id or None,
'approver_name': stage.approver_id.name if stage.approver_id else '',
'max_days': stage.max_days or 0,
'auto_escalate': bool(stage.auto_escalate),
'notification_email': stage.notification_email or '',
'status': stage.status or 'pending',
'comment': stage.comment or '',
'acted_at': stage.acted_at.isoformat() if stage.acted_at else None,
}
def _table_exists(cr, name): def _ser_workflow(wf):
cr.execute( return {
"SELECT to_regclass(%s) IS NOT NULL", (f"public.{name}",) 'id': wf.id,
) 'name': wf.name or '',
row = cr.fetchone() 'type': wf.type or 'custom',
return bool(row and row[0]) 'status': wf.status or 'draft',
'allow_bypass': bool(wf.allow_bypass),
'entity_id': wf.entity_id.id or None,
'entity_name': wf.entity_id.name if wf.entity_id else None,
'steps': [_ser_stage(s) for s in wf.stage_ids.sorted('sequence')],
'stage_count': wf.stage_count,
'request_count': wf.request_count,
'created_at': wf.create_date.isoformat() if wf.create_date else None,
# Legacy shape kept for any older callers that read `created`.
'created': wf.create_date.strftime('%Y-%m-%d') if wf.create_date else '',
}
def _user_name(cr, user_id): def _ser_request(req):
if not user_id: stage = req.current_stage_id
return '' return {
cr.execute( 'id': req.id,
"SELECT rp.name FROM res_users ru " 'workflow_id': req.workflow_id.id or None,
"JOIN res_partner rp ON rp.id = ru.partner_id " 'workflow_name': req.workflow_id.name if req.workflow_id else '',
"WHERE ru.id = %s", (user_id,)) 'res_model': req.res_model or '',
row = cr.fetchone() 'res_id': req.res_id or 0,
return row[0] if row else '' 'state': req.state or 'draft',
'requester_id': req.requester_id.id or None,
'requester_name': req.requester_id.name if req.requester_id else '',
'current_stage_id': stage.id or None,
'current_stage_sequence': stage.sequence if stage else None,
'bypass_reason': req.bypass_reason or '',
'created_at': req.created_at.isoformat() if req.created_at else None,
}
# ── Write helpers ───────────────────────────────────────────────────────────
def _apply_workflow_writes(body, vals):
for k in ('name', 'type'):
if k in body and body[k] is not None:
vals[k] = body[k]
if 'status' in body and body['status'] in ('draft', 'active', 'archived'):
vals['status'] = body['status']
if 'allow_bypass' in body:
vals['allow_bypass'] = bool(body['allow_bypass'])
# Frontend sends `bypass_enabled` from the workflow-builder dialog.
if 'bypass_enabled' in body:
vals['allow_bypass'] = bool(body['bypass_enabled'])
if 'entity_id' in body:
vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False
return vals
def _stage_vals_from_body(step, default_seq):
"""Accept either `order` (UI) or `sequence` (ORM). Emit `sequence` only."""
seq = step.get('sequence')
if seq is None:
seq = step.get('order', default_seq)
try:
seq = int(seq)
except (TypeError, ValueError):
seq = default_seq
approver_id = step.get('approver_id')
try:
approver_id = int(approver_id) if approver_id else 0
except (TypeError, ValueError):
approver_id = 0
vals = {
'sequence': seq,
'approver_id': approver_id or False,
'max_days': int(step.get('max_days') or 3),
'auto_escalate': bool(step.get('auto_escalate')),
'notification_email': step.get('notification_email') or False,
}
return vals
# ── Controller ──────────────────────────────────────────────────────────────
class ApprovalWorkflowController(http.Controller): class ApprovalWorkflowController(http.Controller):
# ── Workflows ────────────────────────────────────────────────
@http.route('/api/approval-workflows', type='http', auth='none', @http.route('/api/approval-workflows', type='http', auth='none',
methods=['GET'], csrf=False) methods=['GET'], csrf=False)
@jwt_required @jwt_required
def list_workflows(self, **kw): def list_workflows(self, **kw):
try: try:
cr = _cr() M = request.env['encoach.approval.workflow'].sudo()
if not _table_exists(cr, 'encoach_approval_workflow'): domain = []
return _json_response({'items': [], 'total': 0}) if kw.get('status'):
cr.execute( domain.append(('status', '=', kw['status']))
"SELECT id, name, type, allow_bypass, entity_id, create_date " if kw.get('entity_id'):
"FROM encoach_approval_workflow ORDER BY create_date DESC") domain.append(('entity_id', '=', int(kw['entity_id'])))
workflows = cr.fetchall() recs = M.search(domain, order='create_date desc')
items = [_ser_workflow(r) for r in recs]
items = [] # Envelope supports both {items,total} (legacy) and {results,total}
for wf_id, name, wtype, bypass, entity_id, created in workflows: # (PaginatedResponse shape used by the approvals frontend service).
cr.execute( return _json_response({
"SELECT id, sequence, approver_id, status, comment, " 'items': items,
"auto_escalate, max_days, acted_at " 'results': items,
"FROM encoach_approval_stage WHERE workflow_id = %s " 'total': len(items),
"ORDER BY sequence ASC", (wf_id,))
steps = []
for sid, seq, approver_id, status, comment, escalate, max_d, acted in cr.fetchall():
steps.append({
'id': sid,
'sequence': seq,
'approver_id': approver_id,
'approver_name': _user_name(cr, approver_id),
'status': status or 'pending',
'comment': comment or '',
'auto_escalate': bool(escalate),
'max_days': max_d or 0,
'acted_at': acted.isoformat() if acted else None,
}) })
entity_name = None
if entity_id:
cr.execute("SELECT name FROM encoach_entity WHERE id=%s", (entity_id,))
row = cr.fetchone()
entity_name = row[0] if row else None
items.append({
'id': wf_id,
'name': name,
'type': wtype or '',
'entity_id': entity_id,
'entity_name': entity_name,
'allow_bypass': bool(bypass),
'steps': steps,
'created': created.strftime('%Y-%m-%d') if created else '',
})
return _json_response({'items': items, 'total': len(items)})
except Exception as e: except Exception as e:
_logger.exception('list workflows failed') _logger.exception('list approval workflows failed')
return _json_response({'error': str(e)}, 500) return _error_response(str(e), 500)
@http.route('/api/approval-workflows', type='http', auth='none', @http.route('/api/approval-workflows', type='http', auth='none',
methods=['POST'], csrf=False) methods=['POST'], csrf=False)
@@ -98,199 +151,191 @@ class ApprovalWorkflowController(http.Controller):
def create_workflow(self, **kw): def create_workflow(self, **kw):
try: try:
body = _get_json_body() body = _get_json_body()
name = body.get('name', '').strip() name = (body.get('name') or '').strip()
if not name: if not name:
return _error_response('name is required', 400) return _error_response('name is required', 400)
cr = _cr() vals = {'name': name, 'type': body.get('type', 'custom')}
now = datetime.now() _apply_workflow_writes(body, vals)
uid = request.env.uid
cr.execute( steps = body.get('steps') or []
"INSERT INTO encoach_approval_workflow " stage_cmds = []
"(name, type, allow_bypass, create_uid, write_uid, create_date, write_date) " for i, step in enumerate(steps):
"VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id", sv = _stage_vals_from_body(step, (i + 1) * 10)
(name, body.get('type', 'custom'), body.get('allow_bypass', False), if sv.get('approver_id'):
uid, uid, now, now)) stage_cmds.append((0, 0, sv))
wf_id = cr.fetchone()[0] if stage_cmds:
vals['stage_ids'] = stage_cmds
for i, step in enumerate(body.get('steps', [])): rec = request.env['encoach.approval.workflow'].sudo().create(vals)
approver_id = step.get('approver_id') return _json_response(_ser_workflow(rec), 201)
if approver_id:
cr.execute(
"INSERT INTO encoach_approval_stage "
"(workflow_id, sequence, approver_id, status, auto_escalate, max_days, "
"create_uid, write_uid, create_date, write_date) "
"VALUES (%s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s)",
(wf_id, (i + 1) * 10, int(approver_id),
step.get('auto_escalate', True), step.get('max_days', 3),
uid, uid, now, now))
cr.execute("SELECT id, name, type FROM encoach_approval_workflow WHERE id=%s", (wf_id,))
row = cr.fetchone()
return _json_response({'id': row[0], 'name': row[1], 'type': row[2]}, 201)
except Exception as e: except Exception as e:
_logger.exception('create workflow failed') _logger.exception('create approval workflow failed')
return _json_response({'error': str(e)}, 500) return _error_response(str(e), 500)
@http.route('/api/approval-workflows/<int:wf_id>', type='http', auth='none', @http.route('/api/approval-workflows/<int:wf_id>', type='http',
methods=['DELETE'], csrf=False) auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_workflow(self, wf_id, **kw):
try:
rec = request.env['encoach.approval.workflow'].sudo().browse(wf_id)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_workflow(rec))
except Exception as e:
_logger.exception('get approval workflow failed')
return _error_response(str(e), 500)
@http.route('/api/approval-workflows/<int:wf_id>', type='http',
auth='none', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_workflow(self, wf_id, **kw):
try:
rec = request.env['encoach.approval.workflow'].sudo().browse(wf_id)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
_apply_workflow_writes(body, vals)
# Replace stages only when steps key is present in the body so
# a status-only toggle doesn't wipe stages.
if 'steps' in body and isinstance(body['steps'], list):
cmds = [(5, 0, 0)]
for i, step in enumerate(body['steps']):
sv = _stage_vals_from_body(step, (i + 1) * 10)
if sv.get('approver_id'):
cmds.append((0, 0, sv))
vals['stage_ids'] = cmds
if vals:
rec.write(vals)
return _json_response(_ser_workflow(rec))
except Exception as e:
_logger.exception('update approval workflow failed')
return _error_response(str(e), 500)
@http.route('/api/approval-workflows/<int:wf_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
def delete_workflow(self, wf_id, **kw): def delete_workflow(self, wf_id, **kw):
try: try:
cr = _cr() rec = request.env['encoach.approval.workflow'].sudo().browse(wf_id)
cr.execute("DELETE FROM encoach_approval_request WHERE workflow_id=%s", (wf_id,)) if rec.exists():
cr.execute("DELETE FROM encoach_approval_stage WHERE workflow_id=%s", (wf_id,)) rec.unlink()
cr.execute("DELETE FROM encoach_approval_workflow WHERE id=%s", (wf_id,))
return _json_response({'success': True}) return _json_response({'success': True})
except Exception as e: except Exception as e:
_logger.exception('delete workflow failed') _logger.exception('delete approval workflow failed')
return _json_response({'error': str(e)}, 500) return _error_response(str(e), 500)
# ── Approval Requests ── # ── Approval Requests ────────────────────────────────────────
@http.route('/api/approval-requests', type='http', auth='none', @http.route('/api/approval-requests', type='http', auth='none',
methods=['GET'], csrf=False) methods=['GET'], csrf=False)
@jwt_required @jwt_required
def list_requests(self, **kw): def list_requests(self, **kw):
try: try:
cr = _cr() M = request.env['encoach.approval.request'].sudo()
if not _table_exists(cr, 'encoach_approval_request'): domain = []
return _json_response({'items': [], 'total': 0}) if kw.get('state'):
state_filter = kw.get('state') domain.append(('state', '=', kw['state']))
where = "" if kw.get('workflow_id'):
params = [] domain.append(('workflow_id', '=', int(kw['workflow_id'])))
if state_filter: recs = M.search(domain, order='created_at desc, id desc')
where = "WHERE ar.state = %s" items = [_ser_request(r) for r in recs]
params.append(state_filter) return _json_response({
'items': items,
cr.execute(f""" 'results': items,
SELECT ar.id, ar.workflow_id, aw.name as wf_name, 'total': len(items),
ar.res_model, ar.res_id, ar.state,
ar.requester_id, ar.current_stage_id,
ar.bypass_reason, ar.created_at
FROM encoach_approval_request ar
LEFT JOIN encoach_approval_workflow aw ON aw.id = ar.workflow_id
{where}
ORDER BY ar.created_at DESC NULLS LAST
""", params)
items = []
for row in cr.fetchall():
rid, wf_id, wf_name, model, res_id, state, req_id, stage_id, bypass, created = row
stage_seq = None
if stage_id:
cr.execute("SELECT sequence FROM encoach_approval_stage WHERE id=%s", (stage_id,))
s = cr.fetchone()
stage_seq = s[0] if s else None
items.append({
'id': rid,
'workflow_id': wf_id,
'workflow_name': wf_name or '',
'res_model': model or '',
'res_id': res_id or 0,
'state': state or 'draft',
'requester_id': req_id,
'requester_name': _user_name(cr, req_id),
'current_stage_id': stage_id,
'current_stage_sequence': stage_seq,
'bypass_reason': bypass or '',
'created_at': created.isoformat() if created else None,
}) })
return _json_response({'items': items, 'total': len(items)})
except Exception as e: except Exception as e:
_logger.exception('list requests failed') _logger.exception('list approval requests failed')
return _json_response({'error': str(e)}, 500) return _error_response(str(e), 500)
@http.route('/api/approval-requests/<int:req_id>/approve', type='http', @http.route('/api/approval-requests/<int:req_id>/approve', type='http',
auth='none', methods=['POST'], csrf=False) auth='none', methods=['POST'], csrf=False)
@jwt_required @jwt_required
def approve_request(self, req_id, **kw): def approve_request(self, req_id, **kw):
try: try:
cr = _cr()
body = _get_json_body() body = _get_json_body()
now = datetime.now() req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists():
cr.execute(
"SELECT workflow_id, current_stage_id FROM encoach_approval_request WHERE id=%s",
(req_id,))
row = cr.fetchone()
if not row:
return _error_response('Request not found', 404) return _error_response('Request not found', 404)
wf_id, stage_id = row
if stage_id: stage = req_rec.current_stage_id
cr.execute( if stage:
"UPDATE encoach_approval_stage SET status='approved', comment=%s, acted_at=%s " stage.write({
"WHERE id=%s", (body.get('comment', ''), now, stage_id)) 'status': 'approved',
'comment': body.get('comment', ''),
cr.execute( 'acted_at': datetime.now(),
"SELECT id FROM encoach_approval_stage WHERE workflow_id=%s ORDER BY sequence ASC", })
(wf_id,))
all_stages = [r[0] for r in cr.fetchall()]
# Move to next stage or finalize.
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
stage_ids = [s.id for s in stages]
try: try:
idx = all_stages.index(stage_id) idx = stage_ids.index(stage.id) if stage else -1
except ValueError: except ValueError:
idx = -1 idx = -1
if idx + 1 < len(all_stages): if 0 <= idx and idx + 1 < len(stage_ids):
cr.execute( next_stage = request.env['encoach.approval.stage'].sudo().browse(stage_ids[idx + 1])
"UPDATE encoach_approval_request SET current_stage_id=%s, state='in_progress' " req_rec.write({
"WHERE id=%s", (all_stages[idx + 1], req_id)) 'current_stage_id': next_stage.id,
'state': 'in_progress',
})
else: else:
cr.execute( req_rec.write({'state': 'approved'})
"UPDATE encoach_approval_request SET state='approved' WHERE id=%s", (req_id,))
return _json_response({'success': True, 'id': req_id}) return _json_response({'success': True, 'id': req_id,
'state': req_rec.state})
except Exception as e: except Exception as e:
_logger.exception('approve request failed') _logger.exception('approve request failed')
return _json_response({'error': str(e)}, 500) return _error_response(str(e), 500)
@http.route('/api/approval-requests/<int:req_id>/reject', type='http', @http.route('/api/approval-requests/<int:req_id>/reject', type='http',
auth='none', methods=['POST'], csrf=False) auth='none', methods=['POST'], csrf=False)
@jwt_required @jwt_required
def reject_request(self, req_id, **kw): def reject_request(self, req_id, **kw):
try: try:
cr = _cr()
body = _get_json_body() body = _get_json_body()
now = datetime.now() req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists():
cr.execute("SELECT current_stage_id FROM encoach_approval_request WHERE id=%s", (req_id,))
row = cr.fetchone()
if not row:
return _error_response('Request not found', 404) return _error_response('Request not found', 404)
stage = req_rec.current_stage_id
stage_id = row[0] if stage:
if stage_id: stage.write({
cr.execute( 'status': 'rejected',
"UPDATE encoach_approval_stage SET status='rejected', comment=%s, acted_at=%s " 'comment': body.get('comment', ''),
"WHERE id=%s", (body.get('comment', ''), now, stage_id)) 'acted_at': datetime.now(),
})
cr.execute( req_rec.write({'state': 'rejected'})
"UPDATE encoach_approval_request SET state='rejected' WHERE id=%s", (req_id,)) return _json_response({'success': True, 'id': req_id,
return _json_response({'success': True, 'id': req_id}) 'state': req_rec.state})
except Exception as e: except Exception as e:
_logger.exception('reject request failed') _logger.exception('reject request failed')
return _json_response({'error': str(e)}, 500) return _error_response(str(e), 500)
# ── Users for assignee selection ── # ── Approvers picker ─────────────────────────────────────────
@http.route('/api/approval-users', type='http', auth='none', @http.route('/api/approval-users', type='http', auth='none',
methods=['GET'], csrf=False) methods=['GET'], csrf=False)
@jwt_required @jwt_required
def list_users(self, **kw): def list_users(self, **kw):
try: try:
cr = _cr() Users = request.env['res.users'].sudo()
cr.execute( domain = [('active', '=', True), ('id', '>', 1)]
"SELECT ru.id, rp.name, ru.login " search = (kw.get('search') or '').strip()
"FROM res_users ru JOIN res_partner rp ON rp.id = ru.partner_id " if search:
"WHERE ru.active = true AND ru.id > 1 " domain += ['|', ('name', 'ilike', search), ('login', 'ilike', search)]
"ORDER BY rp.name LIMIT 100") recs = Users.search(domain, order='name', limit=100)
items = [{'id': r[0], 'name': r[1], 'login': r[2]} for r in cr.fetchall()] items = [{
return _json_response({'items': items}) 'id': u.id,
'name': u.name or u.login,
'login': u.login or '',
'email': u.email or '',
} for u in recs]
return _json_response({'items': items, 'results': items, 'total': len(items)})
except Exception as e: except Exception as e:
_logger.exception('list users failed') _logger.exception('list approval users failed')
return _json_response({'error': str(e)}, 500) return _error_response(str(e), 500)

View File

@@ -12,3 +12,4 @@ from . import exam_assignment
from . import exam_schedule from . import exam_schedule
from . import exam_structure from . import exam_structure
from . import student_attempt from . import student_attempt
from . import approval

View File

@@ -0,0 +1,95 @@
"""Approval workflow models.
Powers the admin "Approval Config" page (`/admin/approval-config`) and the
per-record approval requests exposed by `ApprovalWorkflowController`.
Prior to this module the controller hand-rolled raw SQL against tables that
were never created. These ORM models now own the schema so Odoo can create
the tables on install/update and the controller can use a standard API.
"""
from odoo import api, fields, models
STATUS_SELECTION = [
('draft', 'Draft'),
('active', 'Active'),
('archived', 'Archived'),
]
REQUEST_STATE = [
('draft', 'Draft'),
('in_progress', 'In Progress'),
('approved', 'Approved'),
('rejected', 'Rejected'),
('bypassed', 'Bypassed'),
]
STAGE_STATUS = [
('pending', 'Pending'),
('approved', 'Approved'),
('rejected', 'Rejected'),
('skipped', 'Skipped'),
]
class EncoachApprovalWorkflow(models.Model):
_name = 'encoach.approval.workflow'
_description = 'Approval Workflow'
_order = 'create_date desc'
name = fields.Char(required=True)
type = fields.Char(default='custom',
help='Categorical tag e.g. leave, purchase, content, custom.')
status = fields.Selection(STATUS_SELECTION, default='draft', required=True,
help='Draft = not yet routable. Active = available to new requests.')
allow_bypass = fields.Boolean(
default=False,
help='If true, a bypass reason allows the workflow to be skipped.')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
stage_ids = fields.One2many('encoach.approval.stage', 'workflow_id',
copy=True)
request_ids = fields.One2many('encoach.approval.request', 'workflow_id')
stage_count = fields.Integer(compute='_compute_counts')
request_count = fields.Integer(compute='_compute_counts')
@api.depends('stage_ids', 'request_ids')
def _compute_counts(self):
for rec in self:
rec.stage_count = len(rec.stage_ids)
rec.request_count = len(rec.request_ids)
class EncoachApprovalStage(models.Model):
_name = 'encoach.approval.stage'
_description = 'Approval Workflow Stage'
_order = 'sequence, id'
workflow_id = fields.Many2one('encoach.approval.workflow',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(default=10, required=True,
help='Stored order; the admin UI speaks `order`.')
approver_id = fields.Many2one('res.users', required=True, ondelete='restrict')
max_days = fields.Integer(default=3,
help='SLA before auto-escalation can fire.')
auto_escalate = fields.Boolean(default=False)
notification_email = fields.Char(
help='Optional CC address; falls back to approver_id.email otherwise.')
status = fields.Selection(STAGE_STATUS, default='pending')
comment = fields.Text()
acted_at = fields.Datetime()
class EncoachApprovalRequest(models.Model):
_name = 'encoach.approval.request'
_description = 'Approval Request'
_order = 'create_date desc'
workflow_id = fields.Many2one('encoach.approval.workflow',
required=True, ondelete='cascade', index=True)
res_model = fields.Char(help='Optional target model (e.g. op.student.leave).')
res_id = fields.Integer(help='Optional target record id inside res_model.')
state = fields.Selection(REQUEST_STATE, default='draft', required=True)
requester_id = fields.Many2one('res.users', ondelete='set null')
current_stage_id = fields.Many2one('encoach.approval.stage', ondelete='set null')
bypass_reason = fields.Text()
created_at = fields.Datetime(default=fields.Datetime.now)

View File

@@ -15,3 +15,6 @@ access_encoach_exam_structure_user,encoach.exam.structure.user,model_encoach_exa
access_encoach_student_attempt_user,encoach.student.attempt.user,model_encoach_student_attempt,base.group_user,1,1,1,1 access_encoach_student_attempt_user,encoach.student.attempt.user,model_encoach_student_attempt,base.group_user,1,1,1,1
access_encoach_student_answer_user,encoach.student.answer.user,model_encoach_student_answer,base.group_user,1,1,1,1 access_encoach_student_answer_user,encoach.student.answer.user,model_encoach_student_answer,base.group_user,1,1,1,1
access_encoach_student_score_user,encoach.student.score.user,model_encoach_student_score,base.group_user,1,1,1,1 access_encoach_student_score_user,encoach.student.score.user,model_encoach_student_score,base.group_user,1,1,1,1
access_encoach_approval_workflow_user,encoach.approval.workflow.user,model_encoach_approval_workflow,base.group_user,1,1,1,1
access_encoach_approval_stage_user,encoach.approval.stage.user,model_encoach_approval_stage,base.group_user,1,1,1,1
access_encoach_approval_request_user,encoach.approval.request.user,model_encoach_approval_request,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
15 access_encoach_student_attempt_user encoach.student.attempt.user model_encoach_student_attempt base.group_user 1 1 1 1
16 access_encoach_student_answer_user encoach.student.answer.user model_encoach_student_answer base.group_user 1 1 1 1
17 access_encoach_student_score_user encoach.student.score.user model_encoach_student_score base.group_user 1 1 1 1
18 access_encoach_approval_workflow_user encoach.approval.workflow.user model_encoach_approval_workflow base.group_user 1 1 1 1
19 access_encoach_approval_stage_user encoach.approval.stage.user model_encoach_approval_stage base.group_user 1 1 1 1
20 access_encoach_approval_request_user encoach.approval.request.user model_encoach_approval_request base.group_user 1 1 1 1

View File

@@ -29,6 +29,7 @@ def _ser_item(i):
'sequence': i.sequence, 'sequence': i.sequence,
'audience': i.audience or 'all', 'audience': i.audience or 'all',
'is_published': i.is_published, 'is_published': i.is_published,
'video_url': i.video_url or '',
} }
@@ -128,6 +129,8 @@ class FaqController(http.Controller):
vals['sequence'] = int(body['sequence']) vals['sequence'] = int(body['sequence'])
if body.get('audience'): if body.get('audience'):
vals['audience'] = body['audience'] vals['audience'] = body['audience']
if body.get('video_url') is not None:
vals['video_url'] = body['video_url'] or False
rec = request.env['encoach.faq.item'].sudo().create(vals) rec = request.env['encoach.faq.item'].sudo().create(vals)
return _json_response(_ser_item(rec)) return _json_response(_ser_item(rec))
except Exception as e: except Exception as e:
@@ -151,6 +154,8 @@ class FaqController(http.Controller):
vals['sequence'] = int(body['sequence']) vals['sequence'] = int(body['sequence'])
if 'is_published' in body: if 'is_published' in body:
vals['is_published'] = bool(body['is_published']) vals['is_published'] = bool(body['is_published'])
if 'video_url' in body:
vals['video_url'] = body['video_url'] or False
if vals: if vals:
rec.write(vals) rec.write(vals)
return _json_response(_ser_item(rec)) return _json_response(_ser_item(rec))

View File

@@ -28,12 +28,43 @@ def _ser_rule(r):
'name': r.name or '', 'name': r.name or '',
'event_type': r.event_type or '', 'event_type': r.event_type or '',
'template': r.template or '', 'template': r.template or '',
'is_active': r.is_active, # Admin UI uses `active`; legacy callers still read `is_active`.
'active': bool(r.is_active),
'is_active': bool(r.is_active),
'recipients': r.recipients or 'all', 'recipients': r.recipients or 'all',
'channels': r.channels or '', 'channels': r.channels or '',
'days_before': r.days_before or 0,
'frequency': r.frequency or 'once',
'channel': r.channel or 'in_app',
'entity_id': r.entity_id.id or None,
'entity_name': r.entity_id.name if r.entity_id else None,
} }
def _apply_rule_writes(body, vals):
"""Translate the incoming JSON body into ORM write values.
Accepts both the new (`active`, `channel`, `days_before`, `frequency`)
and legacy (`is_active`, `channels`) contracts.
"""
for k in ('name', 'event_type', 'template', 'recipients', 'frequency', 'channel'):
if k in body and body[k] is not None:
vals[k] = body[k]
if 'channels' in body and body['channels'] is not None:
vals['channels'] = body['channels']
if 'days_before' in body and body['days_before'] is not None:
try:
vals['days_before'] = int(body['days_before'])
except (TypeError, ValueError):
pass
if 'entity_id' in body:
vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False
if 'active' in body:
vals['is_active'] = bool(body['active'])
elif 'is_active' in body:
vals['is_active'] = bool(body['is_active'])
return vals
class NotificationsController(http.Controller): class NotificationsController(http.Controller):
@http.route('/api/notifications', type='http', auth='public', methods=['GET'], csrf=False) @http.route('/api/notifications', type='http', auth='public', methods=['GET'], csrf=False)
@@ -109,21 +140,19 @@ class NotificationsController(http.Controller):
def create_rule(self, **kw): def create_rule(self, **kw):
try: try:
body = _get_json_body() body = _get_json_body()
if not body.get('name'):
return _error_response('name is required', 400)
if not body.get('event_type'):
return _error_response('event_type is required', 400)
vals = { vals = {
'name': body.get('name', ''), 'name': body.get('name', ''),
'event_type': body.get('event_type', ''), 'event_type': body.get('event_type', ''),
} }
if body.get('template'): _apply_rule_writes(body, vals)
vals['template'] = body['template']
if body.get('recipients'):
vals['recipients'] = body['recipients']
if body.get('channels'):
vals['channels'] = body['channels']
if 'is_active' in body:
vals['is_active'] = bool(body['is_active'])
rec = request.env['encoach.notification.rule'].sudo().create(vals) rec = request.env['encoach.notification.rule'].sudo().create(vals)
return _json_response(_ser_rule(rec)) return _json_response(_ser_rule(rec))
except Exception as e: except Exception as e:
_logger.exception('create notification rule failed')
return _error_response(str(e), 500) return _error_response(str(e), 500)
@http.route('/api/notification-rules/<int:rid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) @http.route('/api/notification-rules/<int:rid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@@ -135,15 +164,12 @@ class NotificationsController(http.Controller):
return _error_response('Not found', 404) return _error_response('Not found', 404)
body = _get_json_body() body = _get_json_body()
vals = {} vals = {}
for k in ('name', 'event_type', 'template', 'recipients', 'channels'): _apply_rule_writes(body, vals)
if k in body:
vals[k] = body[k]
if 'is_active' in body:
vals['is_active'] = bool(body['is_active'])
if vals: if vals:
rec.write(vals) rec.write(vals)
return _json_response(_ser_rule(rec)) return _json_response(_ser_rule(rec))
except Exception as e: except Exception as e:
_logger.exception('update notification rule failed')
return _error_response(str(e), 500) return _error_response(str(e), 500)
@http.route('/api/notification-rules/<int:rid>', type='http', auth='public', methods=['DELETE'], csrf=False) @http.route('/api/notification-rules/<int:rid>', type='http', auth='public', methods=['DELETE'], csrf=False)

View File

@@ -1,4 +1,14 @@
from odoo import models, fields from odoo import api, models, fields
FAQ_AUDIENCE = [
('all', 'All'),
('both', 'Both (Student + Entity)'),
('student', 'Students'),
('teacher', 'Teachers'),
('admin', 'Admins'),
('entity', 'Entity Users'),
]
class EncoachFaqCategory(models.Model): class EncoachFaqCategory(models.Model):
@@ -8,16 +18,12 @@ class EncoachFaqCategory(models.Model):
name = fields.Char(required=True) name = fields.Char(required=True)
sequence = fields.Integer(default=10) sequence = fields.Integer(default=10)
audience = fields.Selection([ audience = fields.Selection(FAQ_AUDIENCE, default='all')
('all', 'All'),
('student', 'Students'),
('teacher', 'Teachers'),
('admin', 'Admins'),
], default='all')
is_published = fields.Boolean(default=True) is_published = fields.Boolean(default=True)
item_ids = fields.One2many('encoach.faq.item', 'category_id') item_ids = fields.One2many('encoach.faq.item', 'category_id')
item_count = fields.Integer(compute='_compute_item_count', store=True) item_count = fields.Integer(compute='_compute_item_count', store=True)
@api.depends('item_ids')
def _compute_item_count(self): def _compute_item_count(self):
for rec in self: for rec in self:
rec.item_count = len(rec.item_ids) rec.item_count = len(rec.item_ids)
@@ -32,10 +38,6 @@ class EncoachFaqItem(models.Model):
question = fields.Char(required=True) question = fields.Char(required=True)
answer = fields.Text(required=True) answer = fields.Text(required=True)
sequence = fields.Integer(default=10) sequence = fields.Integer(default=10)
audience = fields.Selection([ audience = fields.Selection(FAQ_AUDIENCE, default='all')
('all', 'All'),
('student', 'Students'),
('teacher', 'Teachers'),
('admin', 'Admins'),
], default='all')
is_published = fields.Boolean(default=True) is_published = fields.Boolean(default=True)
video_url = fields.Char(help='Optional walkthrough video URL.')

View File

@@ -26,7 +26,8 @@ class EncoachNotificationRule(models.Model):
_description = 'Notification Rule' _description = 'Notification Rule'
name = fields.Char(required=True) name = fields.Char(required=True)
event_type = fields.Char(required=True) event_type = fields.Char(required=True, help='e.g. deadline, chapter_unlock, '
'result_release, assignment, exam')
template = fields.Text() template = fields.Text()
is_active = fields.Boolean(default=True) is_active = fields.Boolean(default=True)
recipients = fields.Selection([ recipients = fields.Selection([
@@ -35,7 +36,23 @@ class EncoachNotificationRule(models.Model):
('teachers', 'Teachers Only'), ('teachers', 'Teachers Only'),
('admins', 'Admins Only'), ('admins', 'Admins Only'),
], default='all') ], default='all')
channels = fields.Char(help='Comma-separated: email,push,in_app') channels = fields.Char(help='Legacy: comma-separated email,push,in_app. '
'New code should use `channel` instead.')
# New fields aligned with the admin Configuration UI (NotificationRules page)
days_before = fields.Integer(default=1,
help='Lead time in days before the event fires.')
frequency = fields.Selection([
('once', 'Once'),
('daily', 'Daily'),
('custom', 'Custom schedule'),
], default='once')
channel = fields.Selection([
('in_app', 'In-App'),
('email', 'Email'),
('both', 'Both'),
], default='in_app')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
class EncoachNotificationPreference(models.Model): class EncoachNotificationPreference(models.Model):