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:
2
custom_addons/encoach_pdf_report/__init__.py
Normal file
2
custom_addons/encoach_pdf_report/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import services
|
||||
from . import controllers
|
||||
18
custom_addons/encoach_pdf_report/__manifest__.py
Normal file
18
custom_addons/encoach_pdf_report/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
custom_addons/encoach_pdf_report/controllers/__init__.py
Normal file
1
custom_addons/encoach_pdf_report/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import reports
|
||||
55
custom_addons/encoach_pdf_report/controllers/reports.py
Normal file
55
custom_addons/encoach_pdf_report/controllers/reports.py
Normal 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)
|
||||
@@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
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