feat(v3): restructure project + add complete frontend

- Restructure: move backend from new_project/ to backend/
- Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks)
- Add docs/ with SRS specs, user stories, and workflow documentation
- Update .gitignore for new directory layout

Workflows implemented:
  WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration,
  WF4 General English Exam, WF5 Course Generation,
  WF6 Entity Student Onboarding, AI Course Generation,
  Adaptive Learning Engine UI, White-Label Branding, Score Release

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
commit 907a5c0e92
331 changed files with 23511 additions and 0 deletions

View File

@@ -0,0 +1 @@
from . import ai_course

View File

@@ -0,0 +1,396 @@
import json
import logging
from odoo import http, fields
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__)
class EncoachAiCourseController(http.Controller):
# ------------------------------------------------------------------
# POST /api/ai-course/english/create
# ------------------------------------------------------------------
@http.route('/api/ai-course/english/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_english(self, **kw):
try:
body = _get_json_body()
cefr_level = body.get('cefr_level')
if not cefr_level:
return _error_response('cefr_level is required', 400)
user = request.env.user.sudo()
gap_profile_id = body.get('gap_profile_id')
GapProfile = request.env['encoach.gap.profile'].sudo()
if gap_profile_id:
gap_profile = GapProfile.browse(int(gap_profile_id))
if not gap_profile.exists():
return _error_response('Gap profile not found', 404)
else:
gap_profile = GapProfile.search(
[('student_id', '=', user.id)],
order='created_at desc', limit=1,
)
if not gap_profile:
gap_profile = GapProfile.create({
'student_id': user.id,
'source_type': 'exam',
'skill_gaps': json.dumps([]),
'topic_weaknesses': json.dumps([]),
})
brief = {
'skill_gaps': json.loads(gap_profile.skill_gaps or '[]'),
'topic_weaknesses': json.loads(gap_profile.topic_weaknesses or '[]'),
'cefr_level': cefr_level,
}
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.create({
'student_id': user.id,
'course_type': 'general_english',
'status': 'pending_review',
'brief': json.dumps(brief),
'attempts': 1,
})
return _json_response({
'log_id': log.id,
'status': log.status,
'brief': brief,
}, 201)
except Exception as e:
_logger.exception('create_english failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/ielts/create
# ------------------------------------------------------------------
@http.route('/api/ai-course/ielts/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_ielts(self, **kw):
try:
body = _get_json_body()
skill = body.get('skill')
target_band = body.get('target_band')
if not skill:
return _error_response('skill is required', 400)
if not target_band:
return _error_response('target_band is required', 400)
valid_skills = ('listening', 'reading', 'writing', 'speaking')
if skill not in valid_skills:
return _error_response(
f'skill must be one of: {", ".join(valid_skills)}', 400,
)
brief = {
'skill': skill,
'target_band': float(target_band),
'custom_instructions': body.get('brief', ''),
}
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
log = Log.create({
'skill': skill,
'status': 'format_check',
'brief': json.dumps(brief),
'attempts': 1,
})
return _json_response({
'log_id': log.id,
'status': log.status,
'skill': log.skill,
}, 201)
except Exception as e:
_logger.exception('create_ielts failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/<int:course_id>/quality
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/quality', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def quality(self, course_id, **kw):
try:
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
return _json_response({
'status': log.status,
'readability_score': 0.0,
'cefr_alignment': log.status in ('quality_check', 'approved'),
'grammar_issues': [],
'attempts': log.attempts,
})
except Exception as e:
_logger.exception('quality check failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/<int:course_id>/approve
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/approve', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def approve(self, course_id, **kw):
try:
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
if log.status == 'approved':
return _error_response('Already approved', 400)
log.write({
'status': 'approved',
'approved_by': request.env.user.id,
})
return _json_response({'approved': True})
except Exception as e:
_logger.exception('approve failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/<int:course_id>/reject
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/reject', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def reject(self, course_id, **kw):
try:
body = _get_json_body()
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
reason = body.get('reason', '')
can_retry = log.attempts < 3
if can_retry:
log.write({
'status': 'generating',
'attempts': log.attempts + 1,
'error_log': reason or log.error_log,
})
else:
log.write({
'status': 'rejected',
'error_log': reason or log.error_log,
})
return _json_response({
'rejected': True,
'can_retry': can_retry,
})
except Exception as e:
_logger.exception('reject failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/<int:course_id>/validation
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/validation', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def validation(self, course_id, **kw):
try:
IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo()
ielts_log = IeltsLog.browse(course_id)
if ielts_log.exists():
fmt_result = {}
band_result = {}
try:
fmt_result = json.loads(ielts_log.format_check_result or '{}')
except (json.JSONDecodeError, TypeError):
pass
try:
band_result = json.loads(ielts_log.band_check_result or '{}')
except (json.JSONDecodeError, TypeError):
pass
overall_passed = bool(
ielts_log.format_check_result and ielts_log.band_check_result
)
return _json_response({
'type': 'ielts',
'validation_results': {
'format_check_result': fmt_result,
'band_check_result': band_result,
},
'overall_passed': overall_passed,
})
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
validation_results = {
'status': log.status,
'quality_passed': log.status in ('approved', 'pending_review'),
'attempts': log.attempts,
}
return _json_response({
'type': 'english',
'validation_results': validation_results,
'overall_passed': log.status == 'approved',
})
except Exception as e:
_logger.exception('validation check failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/review-queue
# ------------------------------------------------------------------
@http.route('/api/ai-course/review-queue', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def review_queue(self, **kw):
try:
body = _get_json_body() if request.httprequest.content_length else {}
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Resource = request.env['encoach.resource'].sudo()
domain = [('ai_generated', '=', True), ('review_status', '=', 'pending')]
total = Resource.search_count(domain)
resources = Resource.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
return _json_response({
'total': total,
'page': page,
'size': size,
'items': [r.to_api_dict() for r in resources],
})
except Exception as e:
_logger.exception('review_queue failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/review/<int:resource_id>
# ------------------------------------------------------------------
@http.route('/api/ai-course/review/<int:resource_id>', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def review_resource(self, resource_id, **kw):
try:
body = _get_json_body()
action = body.get('action')
notes = body.get('notes', '')
if action not in ('approve', 'reject'):
return _error_response('action must be approve or reject', 400)
Resource = request.env['encoach.resource'].sudo()
resource = Resource.browse(resource_id)
if not resource.exists():
return _error_response('Resource not found', 404)
vals = {
'review_status': 'approved' if action == 'approve' else 'rejected',
'approved': action == 'approve',
}
resource.write(vals)
return _json_response({'status': vals['review_status'], 'resource_id': resource_id})
except Exception as e:
_logger.exception('review_resource failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/ielts-review-queue
# ------------------------------------------------------------------
@http.route('/api/ai-course/ielts-review-queue', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def ielts_review_queue(self, **kw):
try:
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
domain = [('review_status', '=', 'pending_review')]
total = Log.search_count(domain)
logs = Log.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
items = []
for log in logs:
items.append({
'id': log.id,
'skill': log.skill or '',
'band_target': log.band_target if hasattr(log, 'band_target') else 0,
'review_status': log.review_status,
'created_at': log.create_date.isoformat() if log.create_date else '',
})
return _json_response({
'total': total,
'page': page,
'size': size,
'items': items,
})
except Exception as e:
_logger.exception('ielts_review_queue failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/ielts-review/<int:log_id>
# ------------------------------------------------------------------
@http.route('/api/ai-course/ielts-review/<int:log_id>', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def ielts_review(self, log_id, **kw):
try:
body = _get_json_body()
action = body.get('action')
examiner_notes = body.get('examiner_notes', '')
if action not in ('approve', 'reject', 'revise'):
return _error_response('action must be approve, reject, or revise', 400)
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
log = Log.browse(log_id)
if not log.exists():
return _error_response('Log not found', 404)
status_map = {
'approve': 'approved',
'reject': 'rejected',
'revise': 'revision_needed',
}
log.write({
'review_status': status_map[action],
'examiner_id': request.env.user.id,
'examiner_notes': examiner_notes,
'reviewed_at': fields.Datetime.now(),
})
return _json_response({'status': status_map[action], 'log_id': log_id})
except Exception as e:
_logger.exception('ielts_review failed')
return _error_response(str(e), 500)