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:
1
custom_addons/encoach_ai_course/controllers/__init__.py
Normal file
1
custom_addons/encoach_ai_course/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import ai_course
|
||||
396
custom_addons/encoach_ai_course/controllers/ai_course.py
Normal file
396
custom_addons/encoach_ai_course/controllers/ai_course.py
Normal 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)
|
||||
Reference in New Issue
Block a user