| import os |
| import json |
| import urllib.parse |
| from jinja2 import Environment, FileSystemLoader |
|
|
| |
| try: |
| import pdfkit |
| HAS_PDFKIT = True |
| except ImportError: |
| HAS_PDFKIT = False |
|
|
| |
| try: |
| from weasyprint import HTML |
| HAS_WEASYPRINT = True |
| except Exception as e: |
| print(f"Weasyprint not available: {e}") |
| HAS_WEASYPRINT = False |
|
|
| import sendgrid |
| from sendgrid.helpers.mail import Mail, Attachment, FileContent, FileName, FileType, Disposition |
| import base64 |
|
|
| from firebase_manager import firebase_manager |
|
|
| class ReportGenerator: |
| """ |
| Generates weekly Parent Reports in HTML and PDF formats natively. |
| """ |
| def __init__(self): |
| self.templates_dir = os.path.join(os.path.dirname(__file__), "templates") |
| |
| os.makedirs(self.templates_dir, exist_ok=True) |
| self.jinja_env = Environment(loader=FileSystemLoader(self.templates_dir)) |
|
|
| def _get_db(self): |
| return firebase_manager.get_db() |
|
|
| def generate_quickchart_url(self, labels, data, title="ืืืืื ืืืืช"): |
| """ |
| Builds a URL for QuickChart.io to generate a static Radar Chart image. |
| """ |
| chart_config = { |
| "type": "radar", |
| "data": { |
| "labels": labels, |
| "datasets": [{ |
| "label": title, |
| "data": data, |
| "backgroundColor": "rgba(59, 130, 246, 0.2)", |
| "borderColor": "rgba(59, 130, 246, 1)", |
| "pointBackgroundColor": "rgba(59, 130, 246, 1)", |
| "pointBorderColor": "#fff", |
| "pointHoverBackgroundColor": "#fff", |
| "pointHoverBorderColor": "rgba(59, 130, 246, 1)" |
| }] |
| }, |
| "options": { |
| "scale": { |
| "ticks": { "beginAtZero": True, "max": 100, "stepSize": 20 }, |
| "pointLabels": { "fontSize": 14, "fontFamily": "sans-serif" } |
| }, |
| "legend": { "display": False } |
| } |
| } |
| encoded_config = urllib.parse.quote(json.dumps(chart_config)) |
| |
| return f"https://quickchart.io/chart?w=500&h=500&c={encoded_config}" |
|
|
| def produce_weekly_report(self, uid: str, week_id: str, student_name: str = "ืืชืืืื"): |
| """ |
| Fetches data from Firestore, renders the HTML template, and returns the HTML string. |
| """ |
| db = self._get_db() |
| if not db: |
| raise Exception("Firestore DB not initialized.") |
|
|
| doc_ref = db.collection("users").document(uid).collection("analytics").document(week_id) |
| doc = doc_ref.get() |
|
|
| if not doc.exists: |
| raise Exception(f"No analytics data found for {uid} in {week_id}") |
|
|
| data = doc.to_dict() |
| |
| |
| skills_raw = data.get("skills_data", {}) |
| skills_processed = [] |
| chart_labels = [] |
| chart_data = [] |
| total_score_sum = 0 |
| total_skills_count = 0 |
|
|
| |
| taxonomy = ["ืืืืืจื", "ืืืืืืืจืื", "ืืจืืืื ืืืืจืื", "ืืกืชืืจืืช", "ืืฉืืื ืืืคืจื ืฆืืืื"] |
| |
| for skill_name in taxonomy: |
| chart_labels.append(skill_name) |
| if skill_name in skills_raw: |
| skill_info = skills_raw[skill_name] |
| s_sum = skill_info.get("sum_scores", 0) |
| s_count = skill_info.get("count", 0) |
| s_avg = int(s_sum / s_count) if s_count > 0 else 0 |
| |
| chart_data.append(s_avg) |
| |
| total_score_sum += s_avg |
| total_skills_count += 1 |
| |
| skills_processed.append({ |
| "name": skill_name, |
| "score": s_avg, |
| "sub_skills": skill_info.get("sub_skills", []) |
| }) |
| else: |
| chart_data.append(0) |
|
|
| overall_mastery = int(total_score_sum / total_skills_count) if total_skills_count > 0 else 0 |
| total_exercises = data.get("total_exercises", 0) |
| parent_notes = data.get("parent_notes", []) |
| |
| |
| parent_notes = list(set(parent_notes))[:5] |
| |
| |
| focus_for_next_week = "" |
| if skills_processed: |
| |
| sorted_skills = sorted(skills_processed, key=lambda x: x["score"]) |
| lowest_skill = sorted_skills[0] |
| if lowest_skill["score"] < 80: |
| focus_target = ", ".join(lowest_skill['sub_skills']) if lowest_skill['sub_skills'] else lowest_skill['name'] |
| focus_for_next_week = f"ืืืจื ืขื {focus_target} ืืืืืืง ืืฉืืืื ืื ืืฉื." |
| |
| if not focus_for_next_week and parent_notes: |
| focus_for_next_week = parent_notes[0] |
|
|
| |
| chart_url = self.generate_quickchart_url(chart_labels, chart_data) |
|
|
| |
| template = self.jinja_env.get_template("report_template.html") |
| html_content = template.render( |
| week_label=week_id.replace("week_", "").replace("_", "/"), |
| student_name=student_name, |
| total_exercises=total_exercises, |
| overall_mastery=overall_mastery, |
| skills=skills_processed, |
| chart_url=chart_url, |
| parent_notes=parent_notes, |
| focus_for_next_week=focus_for_next_week |
| ) |
|
|
| return html_content |
|
|
| def export_to_pdf(self, html_content: str, output_path: str): |
| """ |
| Converts the rendered HTML to a PDF file. |
| Prefer WeasyPrint if available (better CSS/font support), fallback to pdfkit. |
| """ |
| if HAS_WEASYPRINT: |
| HTML(string=html_content).write_pdf(output_path) |
| return True |
| elif HAS_PDFKIT: |
| options = { |
| 'page-size': 'A4', |
| 'margin-top': '0.75in', |
| 'margin-right': '0.75in', |
| 'margin-bottom': '0.75in', |
| 'margin-left': '0.75in', |
| 'encoding': "UTF-8", |
| 'no-outline': None |
| } |
| pdfkit.from_string(html_content, output_path, options=options) |
| return True |
| else: |
| raise Exception("No PDF rendering library found. Please install 'weasyprint' or 'pdfkit'.") |
| |
| def send_report_email(self, parent_email: str, student_name: str, pdf_path: str): |
| """ |
| Sends the generated PDF report via SendGrid. |
| """ |
| sg_api_key = os.environ.get("SENDGRID_API_KEY") |
| if not sg_api_key: |
| print("โ ๏ธ [EMAIL] SENDGRID_API_KEY not found in environment. Skipping email.") |
| return False |
| |
| try: |
| sg = sendgrid.SendGridAPIClient(api_key=sg_api_key) |
| |
| |
| from_email = os.environ.get("SENDGRID_FROM_EMAIL", "reports@buddymath.co.il") |
| subject = f"ืืื ืขืืจ ืืฉืืืข ืฉื {student_name} ืืืชืืืืงื? ๐ ืืืื ืืฉืืืขื ื-ืืืืจื ืืืชืืืืงื ืืคื ืื." |
| |
| |
| content = "ืืื! ืืฆืืจืฃ ืืื ืืชืงืืืืช ืฉืืืขื ืืืคืจื ืืช ืืืืืื ืืืืช ืฉืชืืจืืื ืืืืื ืืฉืืืข. ื ืืฉืื ืืชืจืื ืืืืฉืชืคืจ!\n\nืฆืืืช ืืืืจื ืืืชืืืืงื ๐จโ๐ซ" |
| |
| message = Mail( |
| from_email=from_email, |
| to_emails=parent_email, |
| subject=subject, |
| plain_text_content=content |
| ) |
| |
| |
| with open(pdf_path, 'rb') as f: |
| data = f.read() |
| encoded_file = base64.b64encode(data).decode() |
| |
| attachment = Attachment( |
| FileContent(encoded_file), |
| FileName(f"Math_Teacher_Weekly_Report_{student_name}.pdf"), |
| FileType('application/pdf'), |
| Disposition('attachment') |
| ) |
| message.attachment = attachment |
| |
| response = sg.send(message) |
| print(f"๐ง [EMAIL] Report sent to {parent_email}. Status code: {response.status_code}") |
| return response.status_code in [200, 202] |
| |
| except Exception as e: |
| print(f"โ [EMAIL] SendGrid failed: {e}") |
| return False |
|
|
| |
| report_generator = ReportGenerator() |
|
|
| if __name__ == "__main__": |
| |
| print("Running Report Generator Test...") |
| import asyncio |
| |
| gen = ReportGenerator() |
| labels = ["ืืืืืจื", "ืืืืืืืจืื", "ืืจืืืื ืืืืจืื", "ืืกืชืืจืืช", "ืืฉืืื ืืืคืจื ืฆืืืื"] |
| data = [85, 90, 60, 100, 40] |
| url = gen.generate_quickchart_url(labels, data) |
| print("QuickChart URL:", url) |
| |
| mock_html = gen.jinja_env.get_template("report_template.html").render( |
| week_label="11/2026", |
| student_name="ืืืชื ืืจืืฉ", |
| total_exercises=14, |
| overall_mastery=75, |
| skills=[ |
| {"name": "ืืืืืจื", "score": 85, "sub_skills": ["ืืฉืืืืืช ืืืขืื ืจืืฉืื ื", "ืืืงื ืืืงืืช"]}, |
| {"name": "ืืืืืืืจืื", "score": 90, "sub_skills": ["ืืคืืคืช ืืฉืืืฉืื"]}, |
| ], |
| chart_url=url, |
| parent_notes=["ืืชืืืื ืืืื ืืื ื ืืืื ืืืืืื ืืฉืชื ืื", "ืฉืืืื ืืขืืื ืืืืงื ืืืงืืช!"], |
| focus_for_next_week="ืืืจื ืขื ื ืืกืืืืช ืืืคื ืืืงืืฆืจ ืืฉืืคืืจ ืืืืืง ืืืืืืจื." |
| ) |
| |
| with open("sample_report.html", "w", encoding="utf-8") as f: |
| f.write(mock_html) |
| print("Generated sample_report.html directly to root dir for browser inspection.") |
|
|