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,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()