feat: initial backend codebase — EnCoach v3

Complete Odoo 19 backend with 25 custom addons:
- encoach_core: user/entity/role management
- encoach_api: REST API + JWT auth
- encoach_ai: OpenAI integration, AI settings, generation
- encoach_ai_course: AI-powered English & IELTS course generation
- encoach_exam_template/session: exam creation, structures, sessions
- encoach_scoring: AI auto-grading + manual approval
- encoach_vector: pgvector RAG integration
- encoach_adaptive: adaptive learning engine
- encoach_placement: placement testing
- encoach_taxonomy/resources: content taxonomy & resource management
- Plus 14 more modules for courses, branding, portal, etc.

Includes docs: user guide, generation report, developer workflow.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-11 15:44:20 +04:00
commit 982d4bca30
371 changed files with 35211 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
from . import services
from . import controllers

View File

@@ -0,0 +1,18 @@
{
'name': 'EnCoach PDF Report',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'PDF exam report generation with QR code verification',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_scoring'],
'data': [
'security/ir.model.access.csv',
],
'external_dependencies': {
'python': ['reportlab', 'qrcode'],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,55 @@
import json
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, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
class EncoachReportsController(http.Controller):
# ------------------------------------------------------------------
# GET /api/reports/exam/<int:attempt_id>/pdf
# ------------------------------------------------------------------
@http.route('/api/reports/exam/<int:attempt_id>/pdf', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def download_exam_pdf(self, attempt_id, **kw):
try:
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.browse(attempt_id)
if not attempt.exists():
return _error_response('Attempt not found', 404)
user = request.env.user.sudo()
is_owner = attempt.student_id.id == user.id
is_admin = (
user.user_type in ('admin', 'mastercorporate')
or user.has_group('base.group_system')
)
is_teacher = user.user_type == 'teacher'
if not (is_owner or is_admin or is_teacher):
return _error_response('You do not have access to this report', 403)
from odoo.addons.encoach_pdf_report.services.pdf_generator import PdfGenerator
pdf_bytes = PdfGenerator.generate_exam_report(request.env, attempt_id)
student_name = (attempt.student_id.name or 'student').replace(' ', '_')
filename = f"exam_report_{student_name}_{attempt_id}.pdf"
return request.make_response(
pdf_bytes,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', f'attachment; filename="{filename}"'),
('Content-Length', str(len(pdf_bytes))),
],
)
except Exception as e:
_logger.exception('download_exam_pdf failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink

View File

@@ -0,0 +1 @@
from .pdf_generator import PdfGenerator

View File

@@ -0,0 +1,149 @@
import hashlib
import hmac
import io
import logging
import qrcode
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm, mm
from reportlab.platypus import (
Image,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
_logger = logging.getLogger(__name__)
class PdfGenerator:
@staticmethod
def generate_exam_report(env, attempt_id):
"""Generate a PDF exam report with skill-by-skill scores and a QR verification code."""
attempt = env['encoach.student.attempt'].sudo().browse(attempt_id)
if not attempt.exists():
raise ValueError(f"Attempt {attempt_id} not found")
buf = io.BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4,
leftMargin=2 * cm, rightMargin=2 * cm,
topMargin=2 * cm, bottomMargin=2 * cm)
styles = getSampleStyleSheet()
title_style = ParagraphStyle('ReportTitle', parent=styles['Title'],
fontSize=20, spaceAfter=12)
heading_style = ParagraphStyle('SectionHeading', parent=styles['Heading2'],
fontSize=14, spaceAfter=8)
elements = []
elements.append(Paragraph("EnCoach Exam Report", title_style))
elements.append(Spacer(1, 6 * mm))
student_name = getattr(attempt, 'student_id', False) and attempt.student_id.name or 'N/A'
exam_title = getattr(attempt, 'exam_id', False) and attempt.exam_id.name or 'N/A'
attempt_date = getattr(attempt, 'create_date', '') or ''
info_data = [
['Student', student_name],
['Exam', exam_title],
['Date', str(attempt_date)[:19]],
]
info_table = Table(info_data, colWidths=[4 * cm, 12 * cm])
info_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 11),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
]))
elements.append(info_table)
elements.append(Spacer(1, 8 * mm))
elements.append(Paragraph("Skill Scores", heading_style))
scores = env['encoach.score'].sudo().search([
('attempt_id', '=', attempt_id),
])
score_header = ['Skill', 'Band Score']
score_rows = [score_header]
for score in scores:
skill_name = getattr(score, 'skill_name', '') or getattr(score, 'name', 'N/A')
band = getattr(score, 'band_score', '') or getattr(score, 'score', 'N/A')
score_rows.append([str(skill_name), str(band)])
if len(score_rows) > 1:
score_table = Table(score_rows, colWidths=[8 * cm, 8 * cm])
score_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2C3E50')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.whitesmoke, colors.white]),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 6),
]))
elements.append(score_table)
elements.append(Spacer(1, 6 * mm))
overall_band = getattr(attempt, 'overall_band', 'N/A')
cefr_level = getattr(attempt, 'cefr_level', 'N/A')
elements.append(Paragraph(f"Overall Band: <b>{overall_band}</b>", styles['Normal']))
elements.append(Paragraph(f"CEFR Level: <b>{cefr_level}</b>", styles['Normal']))
elements.append(Spacer(1, 4 * mm))
feedback = getattr(attempt, 'feedback_summary', '') or ''
if feedback:
elements.append(Paragraph("Feedback", heading_style))
elements.append(Paragraph(str(feedback), styles['Normal']))
elements.append(Spacer(1, 6 * mm))
verification_hash = PdfGenerator._generate_verification_hash(env, attempt_id)
base_url = env['ir.config_parameter'].sudo().get_param('web.base.url', '')
verify_url = f"{base_url}/api/verify/{verification_hash}"
qr_img = PdfGenerator._build_qr_code(verify_url)
qr_buf = io.BytesIO()
qr_img.save(qr_buf, format='PNG')
qr_buf.seek(0)
elements.append(Paragraph("Verification", heading_style))
elements.append(Paragraph("Scan to verify this report:", styles['Normal']))
elements.append(Spacer(1, 2 * mm))
elements.append(Image(qr_buf, width=4 * cm, height=4 * cm))
doc.build(elements)
return buf.getvalue()
@staticmethod
def _generate_verification_hash(env, attempt_id):
"""Create HMAC-SHA256 hash for attempt verification."""
secret = env['ir.config_parameter'].sudo().get_param(
'encoach.verification_secret', 'default-secret-change-me'
)
attempt = env['encoach.student.attempt'].sudo().browse(attempt_id)
student_name = getattr(attempt, 'student_id', False) and attempt.student_id.name or ''
exam_name = getattr(attempt, 'exam_id', False) and attempt.exam_id.name or ''
message = f"{attempt_id}:{student_name}:{exam_name}"
return hmac.new(
secret.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha256,
).hexdigest()
@staticmethod
def _build_qr_code(url):
"""Generate a QR code PIL Image from a URL."""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=4,
)
qr.add_data(url)
qr.make(fit=True)
return qr.make_image(fill_color="black", back_color="white").get_image()