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