dotandru commited on
Commit
0ff5e3d
ยท
1 Parent(s): f7cf4db

feat: Parent Report Engine (HTML to PDF) and SendGrid Delivery

Browse files
main.py CHANGED
@@ -335,6 +335,87 @@ async def ask_question(request: AskQuestionRequest):
335
  data = request.dict()
336
  res = await orchestrator.ask_question(data.get("context_data"), data.get("question"), data.get("student_name"))
337
  return JSONResponse(content=res)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
 
339
  if __name__ == "__main__":
340
  import uvicorn
 
335
  data = request.dict()
336
  res = await orchestrator.ask_question(data.get("context_data"), data.get("question"), data.get("student_name"))
337
  return JSONResponse(content=res)
338
+
339
+ class MigrationRequest(BaseModel):
340
+ batch_size: Optional[int] = 50
341
+
342
+ @app.post("/admin/migrate_to_v2")
343
+ async def migrate_to_v2(req: MigrationRequest, x_api_key: str = Header(None)):
344
+ """
345
+ V280.2: Admin endpoint to migrate users to V2 Quota system.
346
+ """
347
+ from config import FIREBASE_CREDENTIALS_JSON
348
+ import os
349
+
350
+ # Just a basic safety check (in production, use a real secret)
351
+ admin_secret = os.environ.get("ADMIN_SECRET_KEY", "buddy_admin_123")
352
+ if x_api_key != admin_secret:
353
+ raise HTTPException(status_code=401, detail="Unauthorized")
354
+
355
+ try:
356
+ from scripts.migrate_users_to_cloud import migrate_users
357
+ import asyncio
358
+
359
+ # Run migration in background so we don't block
360
+ asyncio.create_task(asyncio.to_thread(migrate_users))
361
+
362
+ return {"status": "success", "message": "Migration started in background."}
363
+ except Exception as e:
364
+ logger.error(f"Migration error: {e}")
365
+ raise HTTPException(status_code=500, detail=str(e))
366
+
367
+ class ReportRequest(BaseModel):
368
+ uid: str
369
+ student_name: str
370
+ parent_email: str
371
+ week_id: str
372
+
373
+ @app.post("/v2/send_weekly_report")
374
+ async def send_weekly_report(req: ReportRequest):
375
+ """
376
+ Generates and emails the weekly AI Assessment report to the parent.
377
+ """
378
+ try:
379
+ import os
380
+ from report_generator import report_generator
381
+
382
+ # 1. Produce HTML
383
+ html_content = report_generator.produce_weekly_report(
384
+ uid=req.uid,
385
+ week_id=req.week_id,
386
+ student_name=req.student_name
387
+ )
388
+
389
+ # 2. Generate PDF
390
+ pdf_path = f"/tmp/report_{req.uid}_{req.week_id}.pdf"
391
+ # Create tmp dir if it doesn't exist (local dev)
392
+ os.makedirs(os.path.dirname(pdf_path), exist_ok=True)
393
+ report_generator.export_to_pdf(html_content, pdf_path)
394
+
395
+ # 3. Email PDF
396
+ success = report_generator.send_report_email(
397
+ parent_email=req.parent_email,
398
+ student_name=req.student_name,
399
+ pdf_path=pdf_path
400
+ )
401
+
402
+ # Cleanup
403
+ try:
404
+ if os.path.exists(pdf_path):
405
+ os.remove(pdf_path)
406
+ except Exception as e:
407
+ print(f"Cleanup failed for {pdf_path}: {e}")
408
+
409
+ if success:
410
+ return {"status": "success", "message": f"Report sent to {req.parent_email}"}
411
+
412
+ return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to send email via SendGrid."})
413
+
414
+ except Exception as e:
415
+ logger.error(f"Failed to generate report: {e}")
416
+ import traceback
417
+ traceback.print_exc()
418
+ return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
419
 
420
  if __name__ == "__main__":
421
  import uvicorn
packages.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ pango1.0-tools
2
+ libpango1.0-dev
3
+ libcairo2-dev
4
+ libffi-dev
report_generator.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import urllib.parse
4
+ from jinja2 import Environment, FileSystemLoader
5
+
6
+ # Try to import pdfkit if available
7
+ try:
8
+ import pdfkit
9
+ HAS_PDFKIT = True
10
+ except ImportError:
11
+ HAS_PDFKIT = False
12
+
13
+ # Fallback to weasyprint if preferred/available
14
+ try:
15
+ from weasyprint import HTML
16
+ HAS_WEASYPRINT = True
17
+ except Exception as e:
18
+ print(f"Weasyprint not available: {e}")
19
+ HAS_WEASYPRINT = False
20
+
21
+ import sendgrid
22
+ from sendgrid.helpers.mail import Mail, Attachment, FileContent, FileName, FileType, Disposition
23
+ import base64
24
+
25
+ from firebase_manager import firebase_manager
26
+
27
+ class ReportGenerator:
28
+ """
29
+ Generates weekly Parent Reports in HTML and PDF formats natively.
30
+ """
31
+ def __init__(self):
32
+ self.templates_dir = os.path.join(os.path.dirname(__file__), "templates")
33
+ # Ensure templates directory exists
34
+ os.makedirs(self.templates_dir, exist_ok=True)
35
+ self.jinja_env = Environment(loader=FileSystemLoader(self.templates_dir))
36
+
37
+ def _get_db(self):
38
+ return firebase_manager.get_db()
39
+
40
+ def generate_quickchart_url(self, labels, data, title="ืžื™ื•ืžื ื•ื™ื•ืช"):
41
+ """
42
+ Builds a URL for QuickChart.io to generate a static Radar Chart image.
43
+ """
44
+ chart_config = {
45
+ "type": "radar",
46
+ "data": {
47
+ "labels": labels,
48
+ "datasets": [{
49
+ "label": title,
50
+ "data": data,
51
+ "backgroundColor": "rgba(59, 130, 246, 0.2)",
52
+ "borderColor": "rgba(59, 130, 246, 1)",
53
+ "pointBackgroundColor": "rgba(59, 130, 246, 1)",
54
+ "pointBorderColor": "#fff",
55
+ "pointHoverBackgroundColor": "#fff",
56
+ "pointHoverBorderColor": "rgba(59, 130, 246, 1)"
57
+ }]
58
+ },
59
+ "options": {
60
+ "scale": {
61
+ "ticks": { "beginAtZero": True, "max": 100, "stepSize": 20 },
62
+ "pointLabels": { "fontSize": 14, "fontFamily": "sans-serif" }
63
+ },
64
+ "legend": { "display": False }
65
+ }
66
+ }
67
+ encoded_config = urllib.parse.quote(json.dumps(chart_config))
68
+ # Use high resolution for PDF printing
69
+ return f"https://quickchart.io/chart?w=500&h=500&c={encoded_config}"
70
+
71
+ def produce_weekly_report(self, uid: str, week_id: str, student_name: str = "ื”ืชืœืžื™ื“"):
72
+ """
73
+ Fetches data from Firestore, renders the HTML template, and returns the HTML string.
74
+ """
75
+ db = self._get_db()
76
+ if not db:
77
+ raise Exception("Firestore DB not initialized.")
78
+
79
+ doc_ref = db.collection("users").document(uid).collection("analytics").document(week_id)
80
+ doc = doc_ref.get()
81
+
82
+ if not doc.exists:
83
+ raise Exception(f"No analytics data found for {uid} in {week_id}")
84
+
85
+ data = doc.to_dict()
86
+
87
+ # Process Skills metrics
88
+ skills_raw = data.get("skills_data", {})
89
+ skills_processed = []
90
+ chart_labels = []
91
+ chart_data = []
92
+ total_score_sum = 0
93
+ total_skills_count = 0
94
+
95
+ # Known taxonomy to ensure a consistent radar outline (even if 0)
96
+ taxonomy = ["ืืœื’ื‘ืจื”", "ื’ื™ืื•ืžื˜ืจื™ื”", "ื˜ืจื™ื’ื•ื ื•ืžื˜ืจื™ื”", "ื”ืกืชื‘ืจื•ืช", "ื—ืฉื‘ื•ืŸ ื“ื™ืคืจื ืฆื™ืืœื™"]
97
+
98
+ for skill_name in taxonomy:
99
+ chart_labels.append(skill_name)
100
+ if skill_name in skills_raw:
101
+ skill_info = skills_raw[skill_name]
102
+ s_sum = skill_info.get("sum_scores", 0)
103
+ s_count = skill_info.get("count", 0)
104
+ s_avg = int(s_sum / s_count) if s_count > 0 else 0
105
+
106
+ chart_data.append(s_avg)
107
+
108
+ total_score_sum += s_avg
109
+ total_skills_count += 1
110
+
111
+ skills_processed.append({
112
+ "name": skill_name,
113
+ "score": s_avg,
114
+ "sub_skills": skill_info.get("sub_skills", [])
115
+ })
116
+ else:
117
+ chart_data.append(0)
118
+
119
+ overall_mastery = int(total_score_sum / total_skills_count) if total_skills_count > 0 else 0
120
+ total_exercises = data.get("total_exercises", 0)
121
+ parent_notes = data.get("parent_notes", [])
122
+
123
+ # Deduplicate parent notes and limit to top 5
124
+ parent_notes = list(set(parent_notes))[:5]
125
+
126
+ # Identify "Focus for next week" (Lowest mastery score that is > 0 or first parent note)
127
+ focus_for_next_week = ""
128
+ if skills_processed:
129
+ # Sort skills by score ascending
130
+ sorted_skills = sorted(skills_processed, key=lambda x: x["score"])
131
+ lowest_skill = sorted_skills[0]
132
+ if lowest_skill["score"] < 80:
133
+ focus_target = ", ".join(lowest_skill['sub_skills']) if lowest_skill['sub_skills'] else lowest_skill['name']
134
+ focus_for_next_week = f"ื—ื–ืจื” ืขืœ {focus_target} ืœื—ื™ื–ื•ืง ื”ืฉืœื™ื˜ื” ื‘ื ื•ืฉื."
135
+
136
+ if not focus_for_next_week and parent_notes:
137
+ focus_for_next_week = parent_notes[0]
138
+
139
+ # Generate Chart
140
+ chart_url = self.generate_quickchart_url(chart_labels, chart_data)
141
+
142
+ # Template Rendering
143
+ template = self.jinja_env.get_template("report_template.html")
144
+ html_content = template.render(
145
+ week_label=week_id.replace("week_", "").replace("_", "/"),
146
+ student_name=student_name,
147
+ total_exercises=total_exercises,
148
+ overall_mastery=overall_mastery,
149
+ skills=skills_processed,
150
+ chart_url=chart_url,
151
+ parent_notes=parent_notes,
152
+ focus_for_next_week=focus_for_next_week
153
+ )
154
+
155
+ return html_content
156
+
157
+ def export_to_pdf(self, html_content: str, output_path: str):
158
+ """
159
+ Converts the rendered HTML to a PDF file.
160
+ Prefer WeasyPrint if available (better CSS/font support), fallback to pdfkit.
161
+ """
162
+ if HAS_WEASYPRINT:
163
+ HTML(string=html_content).write_pdf(output_path)
164
+ return True
165
+ elif HAS_PDFKIT:
166
+ options = {
167
+ 'page-size': 'A4',
168
+ 'margin-top': '0.75in',
169
+ 'margin-right': '0.75in',
170
+ 'margin-bottom': '0.75in',
171
+ 'margin-left': '0.75in',
172
+ 'encoding': "UTF-8",
173
+ 'no-outline': None
174
+ }
175
+ pdfkit.from_string(html_content, output_path, options=options)
176
+ return True
177
+ else:
178
+ raise Exception("No PDF rendering library found. Please install 'weasyprint' or 'pdfkit'.")
179
+
180
+ def send_report_email(self, parent_email: str, student_name: str, pdf_path: str):
181
+ """
182
+ Sends the generated PDF report via SendGrid.
183
+ """
184
+ sg_api_key = os.environ.get("SENDGRID_API_KEY")
185
+ if not sg_api_key:
186
+ print("โš ๏ธ [EMAIL] SENDGRID_API_KEY not found in environment. Skipping email.")
187
+ return False
188
+
189
+ try:
190
+ sg = sendgrid.SendGridAPIClient(api_key=sg_api_key)
191
+
192
+ # The sender email must be verified in SendGrid
193
+ from_email = os.environ.get("SENDGRID_FROM_EMAIL", "reports@buddymath.co.il")
194
+ subject = f"ืื™ืš ืขื‘ืจ ื”ืฉื‘ื•ืข ืฉืœ {student_name} ื‘ืžืชืžื˜ื™ืงื”? ๐Ÿ“ˆ ื”ื“ื•ื— ื”ืฉื‘ื•ืขื™ ืž-BuddyMath ื‘ืคื ื™ื."
195
+
196
+ # Simple content
197
+ content = "ื”ื™ื™! ืžืฆื•ืจืฃ ื“ื•ื— ื”ืชืงื“ืžื•ืช ืฉื‘ื•ืขื™ ื”ืžืคืจื˜ ืืช ื”ืžื™ื•ืžื ื•ื™ื•ืช ืฉืชื•ืจื’ืœื• ื‘ืžื”ืœืš ื”ืฉื‘ื•ืข. ื ืžืฉื™ืš ืœืชืจื’ืœ ื•ืœื”ืฉืชืคืจ!\n\nืฆื•ื•ืช BuddyMath ๐Ÿค–"
198
+
199
+ message = Mail(
200
+ from_email=from_email,
201
+ to_emails=parent_email,
202
+ subject=subject,
203
+ plain_text_content=content
204
+ )
205
+
206
+ # Attach PDF
207
+ with open(pdf_path, 'rb') as f:
208
+ data = f.read()
209
+ encoded_file = base64.b64encode(data).decode()
210
+
211
+ attachment = Attachment(
212
+ FileContent(encoded_file),
213
+ FileName(f"BuddyMath_Weekly_Report_{student_name}.pdf"),
214
+ FileType('application/pdf'),
215
+ Disposition('attachment')
216
+ )
217
+ message.attachment = attachment
218
+
219
+ response = sg.send(message)
220
+ print(f"๐Ÿ“ง [EMAIL] Report sent to {parent_email}. Status code: {response.status_code}")
221
+ return response.status_code in [200, 202]
222
+
223
+ except Exception as e:
224
+ print(f"โŒ [EMAIL] SendGrid failed: {e}")
225
+ return False
226
+
227
+ # Global Instance
228
+ report_generator = ReportGenerator()
229
+
230
+ if __name__ == "__main__":
231
+ # Test script locally with mockup data if running directly
232
+ print("Running Report Generator Test...")
233
+ import asyncio
234
+
235
+ gen = ReportGenerator()
236
+ labels = ["ืืœื’ื‘ืจื”", "ื’ื™ืื•ืžื˜ืจื™ื”", "ื˜ืจื™ื’ื•ื ื•ืžื˜ืจื™ื”", "ื”ืกืชื‘ืจื•ืช", "ื—ืฉื‘ื•ืŸ ื“ื™ืคืจื ืฆื™ืืœื™"]
237
+ data = [85, 90, 60, 100, 40]
238
+ url = gen.generate_quickchart_url(labels, data)
239
+ print("QuickChart URL:", url)
240
+
241
+ mock_html = gen.jinja_env.get_template("report_template.html").render(
242
+ week_label="11/2026",
243
+ student_name="ื“ื•ืชืŸ ื”ืจื•ืฉ",
244
+ total_exercises=14,
245
+ overall_mastery=75,
246
+ skills=[
247
+ {"name": "ืืœื’ื‘ืจื”", "score": 85, "sub_skills": ["ืžืฉื•ื•ืื•ืช ืžืžืขืœื” ืจืืฉื•ื ื”", "ื—ื•ืงื™ ื—ื–ืงื•ืช"]},
248
+ {"name": "ื’ื™ืื•ืžื˜ืจื™ื”", "score": 90, "sub_skills": ["ื—ืคื™ืคืช ืžืฉื•ืœืฉื™ื"]},
249
+ ],
250
+ chart_url=url,
251
+ parent_notes=["ื”ืชืœืžื™ื“ ื’ื™ืœื” ื”ื‘ื ื” ื˜ื•ื‘ื” ื‘ื‘ื™ื“ื•ื“ ืžืฉืชื ื™ื", "ืฉืœื™ื˜ื” ืžืขื•ืœื” ื‘ื—ื•ืงื™ ื—ื–ืงื•ืช!"],
252
+ focus_for_next_week="ื—ื–ืจื” ืขืœ ื ื•ืกื—ืื•ืช ื”ื›ืคืœ ื”ืžืงื•ืฆืจ ืœืฉื™ืคื•ืจ ื”ื“ื™ื•ืง ื”ืืœื’ื‘ืจื™."
253
+ )
254
+
255
+ with open("sample_report.html", "w", encoding="utf-8") as f:
256
+ f.write(mock_html)
257
+ print("Generated sample_report.html directly to root dir for browser inspection.")
requirements.txt CHANGED
@@ -11,4 +11,8 @@ sympy
11
  edge-tts>=7.0.0
12
  firebase-admin==6.4.0
13
  opencv-python-headless
14
- sse-starlette
 
 
 
 
 
11
  edge-tts>=7.0.0
12
  firebase-admin==6.4.0
13
  opencv-python-headless
14
+ sse-starlette
15
+ jinja2
16
+ pdfkit
17
+ weasyprint
18
+ sendgrid
sample_report.html ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="he" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>ื“ื•ื— ื”ืชืงื“ืžื•ืช ืฉื‘ื•ืขื™ - BuddyMath</title>
6
+ <style>
7
+ /* CSS Inline for PDF Compatibility */
8
+ @import url('https://fonts.googleapis.com/css2?family=Assistant:wght@400;600;800&display=swap');
9
+
10
+ body {
11
+ font-family: 'Assistant', sans-serif;
12
+ background-color: #f8fafc;
13
+ color: #1e293b;
14
+ margin: 0;
15
+ padding: 40px;
16
+ direction: rtl;
17
+ }
18
+
19
+ .container {
20
+ max-width: 800px;
21
+ margin: 0 auto;
22
+ background: white;
23
+ border-radius: 24px;
24
+ padding: 40px;
25
+ box-shadow: 0 10px 25px rgba(0,0,0,0.05);
26
+ }
27
+
28
+ .header {
29
+ text-align: center;
30
+ margin-bottom: 40px;
31
+ border-bottom: 2px solid #e2e8f0;
32
+ padding-bottom: 20px;
33
+ }
34
+
35
+ .header h1 {
36
+ color: #4f46e5;
37
+ font-size: 32px;
38
+ margin: 0 0 10px 0;
39
+ font-weight: 800;
40
+ }
41
+
42
+ .header p {
43
+ color: #64748b;
44
+ font-size: 18px;
45
+ margin: 0;
46
+ }
47
+
48
+ .stats-grid {
49
+ display: flex;
50
+ justify-content: space-between;
51
+ margin-bottom: 40px;
52
+ gap: 20px;
53
+ }
54
+
55
+ .stat-card {
56
+ flex: 1;
57
+ background: #eff6ff;
58
+ border-radius: 16px;
59
+ padding: 24px;
60
+ text-align: center;
61
+ border: 1px solid #bfdbfe;
62
+ }
63
+
64
+ .stat-card h3 {
65
+ margin: 0 0 10px 0;
66
+ color: #3b82f6;
67
+ font-size: 16px;
68
+ }
69
+
70
+ .stat-card .value {
71
+ font-size: 36px;
72
+ font-weight: 800;
73
+ color: #1d4ed8;
74
+ margin: 0;
75
+ }
76
+
77
+ .content-flex {
78
+ display: flex;
79
+ gap: 40px;
80
+ margin-bottom: 40px;
81
+ }
82
+
83
+ .chart-section {
84
+ flex: 1;
85
+ text-align: center;
86
+ background: #ffffff;
87
+ border-radius: 20px;
88
+ padding: 20px;
89
+ border: 1px solid #e2e8f0;
90
+ }
91
+
92
+ .chart-section h2 {
93
+ color: #334155;
94
+ font-size: 20px;
95
+ margin-top: 0;
96
+ }
97
+
98
+ .chart-img {
99
+ max-width: 100%;
100
+ height: auto;
101
+ }
102
+
103
+ .skills-list {
104
+ flex: 1;
105
+ }
106
+
107
+ .skill-item {
108
+ margin-bottom: 20px;
109
+ }
110
+
111
+ .skill-header {
112
+ display: flex;
113
+ justify-content: space-between;
114
+ align-items: center;
115
+ margin-bottom: 8px;
116
+ }
117
+
118
+ .skill-name {
119
+ font-weight: 600;
120
+ color: #334155;
121
+ }
122
+
123
+ .skill-score {
124
+ font-weight: 800;
125
+ color: #4f46e5;
126
+ }
127
+
128
+ .progress-bar {
129
+ height: 12px;
130
+ background: #e2e8f0;
131
+ border-radius: 10px;
132
+ overflow: hidden;
133
+ }
134
+
135
+ .progress-fill {
136
+ height: 100%;
137
+ background: linear-gradient(90deg, #6366f1, #3b82f6);
138
+ border-radius: 10px;
139
+ }
140
+
141
+ .sub-skills {
142
+ margin-top: 8px;
143
+ font-size: 14px;
144
+ color: #64748b;
145
+ }
146
+
147
+ .notes-section {
148
+ background: #fdf2f8;
149
+ border-radius: 20px;
150
+ padding: 30px;
151
+ border: 1px solid #fbcfe8;
152
+ }
153
+
154
+ .notes-section h2 {
155
+ color: #be185d;
156
+ margin-top: 0;
157
+ font-size: 20px;
158
+ }
159
+
160
+ .note-item {
161
+ position: relative;
162
+ padding-right: 24px;
163
+ margin-bottom: 12px;
164
+ color: #831843;
165
+ font-size: 16px;
166
+ line-height: 1.5;
167
+ }
168
+
169
+ .note-item::before {
170
+ content: "โ€ข";
171
+ position: absolute;
172
+ right: 0;
173
+ color: #ec4899;
174
+ font-size: 24px;
175
+ line-height: 1;
176
+ }
177
+
178
+ .footer {
179
+ margin-top: 40px;
180
+ text-align: center;
181
+ color: #94a3b8;
182
+ font-size: 14px;
183
+ }
184
+ </style>
185
+ </head>
186
+ <body>
187
+ <div class="container">
188
+ <div class="header">
189
+ <h1>ื“ื•ื— ื”ืชืงื“ืžื•ืช ืฉื‘ื•ืขื™</h1>
190
+ <p>ืฉื‘ื•ืข 11/2026 | ื“ื•ืชืŸ ื”ืจื•ืฉ</p>
191
+ </div>
192
+
193
+ <div class="stats-grid">
194
+ <div class="stat-card">
195
+ <h3>ืกื”"ื› ืชืจื’ื™ืœื™ื ืฉื ืคืชืจื•</h3>
196
+ <p class="value">14</p>
197
+ </div>
198
+ <div class="stat-card">
199
+ <h3>ืžืžื•ืฆืข ืฉืœื™ื˜ื” ื›ืœืœื™</h3>
200
+ <p class="value">75%</p>
201
+ </div>
202
+ </div>
203
+
204
+ <div class="content-flex">
205
+ <!-- Radar Chart via QuickChart.io -->
206
+ <div class="chart-section">
207
+ <h2>ืžืคืช ืžื™ื•ืžื ื•ื™ื•ืช</h2>
208
+ <img class="chart-img" src="https://quickchart.io/chart?w=500&h=500&c=%7B%22type%22%3A%20%22radar%22%2C%20%22data%22%3A%20%7B%22labels%22%3A%20%5B%22%5Cu05d0%5Cu05dc%5Cu05d2%5Cu05d1%5Cu05e8%5Cu05d4%22%2C%20%22%5Cu05d2%5Cu05d9%5Cu05d0%5Cu05d5%5Cu05de%5Cu05d8%5Cu05e8%5Cu05d9%5Cu05d4%22%2C%20%22%5Cu05d8%5Cu05e8%5Cu05d9%5Cu05d2%5Cu05d5%5Cu05e0%5Cu05d5%5Cu05de%5Cu05d8%5Cu05e8%5Cu05d9%5Cu05d4%22%2C%20%22%5Cu05d4%5Cu05e1%5Cu05ea%5Cu05d1%5Cu05e8%5Cu05d5%5Cu05ea%22%2C%20%22%5Cu05d7%5Cu05e9%5Cu05d1%5Cu05d5%5Cu05df%20%5Cu05d3%5Cu05d9%5Cu05e4%5Cu05e8%5Cu05e0%5Cu05e6%5Cu05d9%5Cu05d0%5Cu05dc%5Cu05d9%22%5D%2C%20%22datasets%22%3A%20%5B%7B%22label%22%3A%20%22%5Cu05de%5Cu05d9%5Cu05d5%5Cu05de%5Cu05e0%5Cu05d5%5Cu05d9%5Cu05d5%5Cu05ea%22%2C%20%22data%22%3A%20%5B85%2C%2090%2C%2060%2C%20100%2C%2040%5D%2C%20%22backgroundColor%22%3A%20%22rgba%2859%2C%20130%2C%20246%2C%200.2%29%22%2C%20%22borderColor%22%3A%20%22rgba%2859%2C%20130%2C%20246%2C%201%29%22%2C%20%22pointBackgroundColor%22%3A%20%22rgba%2859%2C%20130%2C%20246%2C%201%29%22%2C%20%22pointBorderColor%22%3A%20%22%23fff%22%2C%20%22pointHoverBackgroundColor%22%3A%20%22%23fff%22%2C%20%22pointHoverBorderColor%22%3A%20%22rgba%2859%2C%20130%2C%20246%2C%201%29%22%7D%5D%7D%2C%20%22options%22%3A%20%7B%22scale%22%3A%20%7B%22ticks%22%3A%20%7B%22beginAtZero%22%3A%20true%2C%20%22max%22%3A%20100%2C%20%22stepSize%22%3A%2020%7D%2C%20%22pointLabels%22%3A%20%7B%22fontSize%22%3A%2014%2C%20%22fontFamily%22%3A%20%22sans-serif%22%7D%7D%2C%20%22legend%22%3A%20%7B%22display%22%3A%20false%7D%7D%7D" alt="Radar Chart">
209
+ </div>
210
+
211
+ <div class="skills-list">
212
+ <h2>ืคื™ืจื•ื˜ ื ื•ืฉืื™ื</h2>
213
+
214
+ <div class="skill-item">
215
+ <div class="skill-header">
216
+ <span class="skill-name">ืืœื’ื‘ืจื”</span>
217
+ <span class="skill-score">85%</span>
218
+ </div>
219
+ <div class="progress-bar">
220
+ <div class="progress-fill" style="width: 85%;"></div>
221
+ </div>
222
+ <div class="sub-skills">
223
+ <strong>ืชืชื™-ื ื•ืฉืื™ื ืฉื˜ื•ืคืœื•:</strong> ืžืฉื•ื•ืื•ืช ืžืžืขืœื” ืจืืฉื•ื ื”, ื—ื•ืงื™ ื—ื–ืงื•ืช
224
+ </div>
225
+ </div>
226
+
227
+ <div class="skill-item">
228
+ <div class="skill-header">
229
+ <span class="skill-name">ื’ื™ืื•ืžื˜ืจื™ื”</span>
230
+ <span class="skill-score">90%</span>
231
+ </div>
232
+ <div class="progress-bar">
233
+ <div class="progress-fill" style="width: 90%;"></div>
234
+ </div>
235
+ <div class="sub-skills">
236
+ <strong>ืชืชื™-ื ื•ืฉืื™ื ืฉื˜ื•ืคืœื•:</strong> ื—ืคื™ืคืช ืžืฉื•ืœืฉื™ื
237
+ </div>
238
+ </div>
239
+
240
+ </div>
241
+ </div>
242
+
243
+
244
+ <div class="notes-section" style="margin-bottom: 30px;">
245
+ <h2>ื ืงื•ื“ื•ืช ืœืฉื™ืžื•ืจ ื•ืœื—ื™ื–ื•ืง ๐Ÿ’ก</h2>
246
+
247
+ <div class="note-item">ื”ืชืœืžื™ื“ ื’ื™ืœื” ื”ื‘ื ื” ื˜ื•ื‘ื” ื‘ื‘ื™ื“ื•ื“ ืžืฉืชื ื™ื</div>
248
+
249
+ <div class="note-item">ืฉืœื™ื˜ื” ืžืขื•ืœื” ื‘ื—ื•ืงื™ ื—ื–ืงื•ืช!</div>
250
+
251
+ </div>
252
+
253
+
254
+
255
+ <div class="notes-section" style="background: #f0fdfa; border-color: #ccfbf1;">
256
+ <h2 style="color: #0d9488;">ืžื˜ืจื” ืœืฉื‘ื•ืข ื”ื‘ื ๐ŸŽฏ</h2>
257
+ <div class="note-item" style="color: #115e59;">ื—ื–ืจื” ืขืœ ื ื•ืกื—ืื•ืช ื”ื›ืคืœ ื”ืžืงื•ืฆืจ ืœืฉื™ืคื•ืจ ื”ื“ื™ื•ืง ื”ืืœื’ื‘ืจื™.</div>
258
+ </div>
259
+
260
+
261
+ <div class="footer">
262
+ <p>ื”ื•ืคืง ืขืœ ื™ื“ื™ ืžืขืจื›ืช ื”ื‘ื™ื ื” ื”ืžืœืื›ื•ืชื™ืช ืฉืœ ืžื•ืจื” ื‘ืื“ื™ | BuddyMath</p>
263
+ </div>
264
+ </div>
265
+ </body>
266
+ </html>
templates/report_template.html ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="he" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>ื“ื•ื— ื”ืชืงื“ืžื•ืช ืฉื‘ื•ืขื™ - BuddyMath</title>
6
+ <style>
7
+ /* CSS Inline for PDF Compatibility */
8
+ @import url('https://fonts.googleapis.com/css2?family=Assistant:wght@400;600;800&display=swap');
9
+
10
+ body {
11
+ font-family: 'Assistant', sans-serif;
12
+ background-color: #f8fafc;
13
+ color: #1e293b;
14
+ margin: 0;
15
+ padding: 40px;
16
+ direction: rtl;
17
+ }
18
+
19
+ .container {
20
+ max-width: 800px;
21
+ margin: 0 auto;
22
+ background: white;
23
+ border-radius: 24px;
24
+ padding: 40px;
25
+ box-shadow: 0 10px 25px rgba(0,0,0,0.05);
26
+ }
27
+
28
+ .header {
29
+ text-align: center;
30
+ margin-bottom: 40px;
31
+ border-bottom: 2px solid #e2e8f0;
32
+ padding-bottom: 20px;
33
+ }
34
+
35
+ .header h1 {
36
+ color: #4f46e5;
37
+ font-size: 32px;
38
+ margin: 0 0 10px 0;
39
+ font-weight: 800;
40
+ }
41
+
42
+ .header p {
43
+ color: #64748b;
44
+ font-size: 18px;
45
+ margin: 0;
46
+ }
47
+
48
+ .stats-grid {
49
+ display: flex;
50
+ justify-content: space-between;
51
+ margin-bottom: 40px;
52
+ gap: 20px;
53
+ }
54
+
55
+ .stat-card {
56
+ flex: 1;
57
+ background: #eff6ff;
58
+ border-radius: 16px;
59
+ padding: 24px;
60
+ text-align: center;
61
+ border: 1px solid #bfdbfe;
62
+ }
63
+
64
+ .stat-card h3 {
65
+ margin: 0 0 10px 0;
66
+ color: #3b82f6;
67
+ font-size: 16px;
68
+ }
69
+
70
+ .stat-card .value {
71
+ font-size: 36px;
72
+ font-weight: 800;
73
+ color: #1d4ed8;
74
+ margin: 0;
75
+ }
76
+
77
+ .content-flex {
78
+ display: flex;
79
+ gap: 40px;
80
+ margin-bottom: 40px;
81
+ }
82
+
83
+ .chart-section {
84
+ flex: 1;
85
+ text-align: center;
86
+ background: #ffffff;
87
+ border-radius: 20px;
88
+ padding: 20px;
89
+ border: 1px solid #e2e8f0;
90
+ }
91
+
92
+ .chart-section h2 {
93
+ color: #334155;
94
+ font-size: 20px;
95
+ margin-top: 0;
96
+ }
97
+
98
+ .chart-img {
99
+ max-width: 100%;
100
+ height: auto;
101
+ }
102
+
103
+ .skills-list {
104
+ flex: 1;
105
+ }
106
+
107
+ .skill-item {
108
+ margin-bottom: 20px;
109
+ }
110
+
111
+ .skill-header {
112
+ display: flex;
113
+ justify-content: space-between;
114
+ align-items: center;
115
+ margin-bottom: 8px;
116
+ }
117
+
118
+ .skill-name {
119
+ font-weight: 600;
120
+ color: #334155;
121
+ }
122
+
123
+ .skill-score {
124
+ font-weight: 800;
125
+ color: #4f46e5;
126
+ }
127
+
128
+ .progress-bar {
129
+ height: 12px;
130
+ background: #e2e8f0;
131
+ border-radius: 10px;
132
+ overflow: hidden;
133
+ }
134
+
135
+ .progress-fill {
136
+ height: 100%;
137
+ background: linear-gradient(90deg, #6366f1, #3b82f6);
138
+ border-radius: 10px;
139
+ }
140
+
141
+ .sub-skills {
142
+ margin-top: 8px;
143
+ font-size: 14px;
144
+ color: #64748b;
145
+ }
146
+
147
+ .notes-section {
148
+ background: #fdf2f8;
149
+ border-radius: 20px;
150
+ padding: 30px;
151
+ border: 1px solid #fbcfe8;
152
+ }
153
+
154
+ .notes-section h2 {
155
+ color: #be185d;
156
+ margin-top: 0;
157
+ font-size: 20px;
158
+ }
159
+
160
+ .note-item {
161
+ position: relative;
162
+ padding-right: 24px;
163
+ margin-bottom: 12px;
164
+ color: #831843;
165
+ font-size: 16px;
166
+ line-height: 1.5;
167
+ }
168
+
169
+ .note-item::before {
170
+ content: "โ€ข";
171
+ position: absolute;
172
+ right: 0;
173
+ color: #ec4899;
174
+ font-size: 24px;
175
+ line-height: 1;
176
+ }
177
+
178
+ .footer {
179
+ margin-top: 40px;
180
+ text-align: center;
181
+ color: #94a3b8;
182
+ font-size: 14px;
183
+ }
184
+ </style>
185
+ </head>
186
+ <body>
187
+ <div class="container">
188
+ <div class="header">
189
+ <h1>ื“ื•ื— ื”ืชืงื“ืžื•ืช ืฉื‘ื•ืขื™</h1>
190
+ <p>ืฉื‘ื•ืข {{ week_label }} | {{ student_name }}</p>
191
+ </div>
192
+
193
+ <div class="stats-grid">
194
+ <div class="stat-card">
195
+ <h3>ืกื”"ื› ืชืจื’ื™ืœื™ื ืฉื ืคืชืจื•</h3>
196
+ <p class="value">{{ total_exercises }}</p>
197
+ </div>
198
+ <div class="stat-card">
199
+ <h3>ืžืžื•ืฆืข ืฉืœื™ื˜ื” ื›ืœืœื™</h3>
200
+ <p class="value">{{ overall_mastery }}%</p>
201
+ </div>
202
+ </div>
203
+
204
+ <div class="content-flex">
205
+ <!-- Radar Chart via QuickChart.io -->
206
+ <div class="chart-section">
207
+ <h2>ืžืคืช ืžื™ื•ืžื ื•ื™ื•ืช</h2>
208
+ <img class="chart-img" src="{{ chart_url }}" alt="Radar Chart">
209
+ </div>
210
+
211
+ <div class="skills-list">
212
+ <h2>ืคื™ืจื•ื˜ ื ื•ืฉืื™ื</h2>
213
+ {% for skill in skills %}
214
+ <div class="skill-item">
215
+ <div class="skill-header">
216
+ <span class="skill-name">{{ skill.name }}</span>
217
+ <span class="skill-score">{{ skill.score }}%</span>
218
+ </div>
219
+ <div class="progress-bar">
220
+ <div class="progress-fill" style="width: {{ skill.score }}%;"></div>
221
+ </div>
222
+ <div class="sub-skills">
223
+ <strong>ืชืชื™-ื ื•ืฉืื™ื ืฉื˜ื•ืคืœื•:</strong> {{ skill.sub_skills | join(", ") }}
224
+ </div>
225
+ </div>
226
+ {% endfor %}
227
+ </div>
228
+ </div>
229
+
230
+ {% if parent_notes and parent_notes|length > 0 %}
231
+ <div class="notes-section" style="margin-bottom: 30px;">
232
+ <h2>ื ืงื•ื“ื•ืช ืœืฉื™ืžื•ืจ ื•ืœื—ื™ื–ื•ืง ๐Ÿ’ก</h2>
233
+ {% for note in parent_notes %}
234
+ <div class="note-item">{{ note }}</div>
235
+ {% endfor %}
236
+ </div>
237
+ {% endif %}
238
+
239
+ {% if focus_for_next_week %}
240
+ <div class="notes-section" style="background: #f0fdfa; border-color: #ccfbf1;">
241
+ <h2 style="color: #0d9488;">ืžื˜ืจื” ืœืฉื‘ื•ืข ื”ื‘ื ๐ŸŽฏ</h2>
242
+ <div class="note-item" style="color: #115e59;">{{ focus_for_next_week }}</div>
243
+ </div>
244
+ {% endif %}
245
+
246
+ <div class="footer">
247
+ <p>ื”ื•ืคืง ืขืœ ื™ื“ื™ ืžืขืจื›ืช ื”ื‘ื™ื ื” ื”ืžืœืื›ื•ืชื™ืช ืฉืœ ืžื•ืจื” ื‘ืื“ื™ | BuddyMath</p>
248
+ </div>
249
+ </div>
250
+ </body>
251
+ </html>