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_pdf_report/services/__init__.py
Normal file
1
custom_addons/encoach_pdf_report/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .pdf_generator import PdfGenerator
|
||||
149
custom_addons/encoach_pdf_report/services/pdf_generator.py
Normal file
149
custom_addons/encoach_pdf_report/services/pdf_generator.py
Normal 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()
|
||||
Reference in New Issue
Block a user