From d5f05941c9b95d19debd4d6c5504a2fe769900d9 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Sat, 25 Oct 2025 13:31:03 +0000 Subject: [PATCH] Email notification --- backend/app/api/endpoints/pprs.py | 127 +++++ backend/app/core/email.py | 48 ++ backend/app/crud/crud_ppr.py | 7 +- backend/app/models/ppr.py | 1 + backend/app/templates/ppr_cancelled.html | 19 + backend/app/templates/ppr_submitted.html | 21 + backend/requirements.txt | 4 +- init_db.sql | 4 +- web/edit.html | 652 +++++++++++++++++++++++ 9 files changed, 880 insertions(+), 3 deletions(-) create mode 100644 backend/app/core/email.py create mode 100644 backend/app/templates/ppr_cancelled.html create mode 100644 backend/app/templates/ppr_submitted.html create mode 100644 web/edit.html diff --git a/backend/app/api/endpoints/pprs.py b/backend/app/api/endpoints/pprs.py index 57eed55..596ef92 100644 --- a/backend/app/api/endpoints/pprs.py +++ b/backend/app/api/endpoints/pprs.py @@ -8,6 +8,8 @@ from app.crud.crud_journal import journal as crud_journal from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate, Journal from app.models.ppr import User from app.core.utils import get_client_ip +from app.core.email import email_service +from app.core.config import settings router = APIRouter() @@ -78,6 +80,23 @@ async def create_public_ppr( } }) + # Send email if email provided + if ppr_in.email: + await email_service.send_email( + to_email=ppr_in.email, + subject="PPR Submitted Successfully", + template_name="ppr_submitted.html", + template_vars={ + "name": ppr_in.captain, + "aircraft": ppr_in.ac_reg, + "arrival_time": ppr_in.eta.strftime("%Y-%m-%d %H:%M"), + "departure_time": ppr_in.etd.strftime("%Y-%m-%d %H:%M") if ppr_in.etd else "N/A", + "purpose": ppr_in.notes or "N/A", + "public_token": ppr.public_token, + "base_url": settings.base_url + } + ) + return ppr @@ -199,6 +218,20 @@ async def update_ppr_status( } }) + # Send email if cancelled and email provided + if status_update.status == PPRStatus.CANCELED and ppr.email: + await email_service.send_email( + to_email=ppr.email, + subject="PPR Cancelled", + template_name="ppr_cancelled.html", + template_vars={ + "name": ppr.captain, + "aircraft": ppr.ac_reg, + "arrival_time": ppr.eta.strftime("%Y-%m-%d %H:%M"), + "departure_time": ppr.etd.strftime("%Y-%m-%d %H:%M") if ppr.etd else "N/A" + } + ) + return ppr @@ -231,6 +264,100 @@ async def delete_ppr( return ppr +@router.get("/public/edit/{token}", response_model=PPR) +async def get_ppr_for_edit( + token: str, + db: Session = Depends(get_db) +): + """Get PPR details for public editing using token""" + ppr = crud_ppr.get_by_public_token(db, token) + if not ppr: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invalid or expired token" + ) + # Only allow editing if not already processed + if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="PPR cannot be edited at this stage" + ) + return ppr + + +@router.patch("/public/edit/{token}", response_model=PPR) +async def update_ppr_public( + token: str, + ppr_in: PPRUpdate, + request: Request, + db: Session = Depends(get_db) +): + """Update PPR publicly using token""" + ppr = crud_ppr.get_by_public_token(db, token) + if not ppr: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invalid or expired token" + ) + # Only allow editing if not already processed + if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="PPR cannot be edited at this stage" + ) + + client_ip = get_client_ip(request) + updated_ppr = crud_ppr.update(db, db_obj=ppr, obj_in=ppr_in, user="public", user_ip=client_ip) + return updated_ppr + + +@router.delete("/public/cancel/{token}", response_model=PPR) +async def cancel_ppr_public( + token: str, + request: Request, + db: Session = Depends(get_db) +): + """Cancel PPR publicly using token""" + ppr = crud_ppr.get_by_public_token(db, token) + if not ppr: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invalid or expired token" + ) + # Only allow canceling if not already processed + if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="PPR cannot be cancelled at this stage" + ) + + client_ip = get_client_ip(request) + # Cancel by setting status to CANCELED + cancelled_ppr = crud_ppr.update_status( + db, + ppr_id=ppr.id, + status=PPRStatus.CANCELED, + user="public", + user_ip=client_ip + ) + + # Send cancellation email if email provided + if cancelled_ppr.email: + await email_service.send_email( + to_email=cancelled_ppr.email, + subject="PPR Cancelled", + template_name="ppr_cancelled.html", + template_vars={ + "name": cancelled_ppr.captain, + "aircraft": cancelled_ppr.ac_reg, + "arrival_time": cancelled_ppr.eta.strftime("%Y-%m-%d %H:%M"), + "departure_time": cancelled_ppr.etd.strftime("%Y-%m-%d %H:%M") if cancelled_ppr.etd else "N/A" + } + ) + + return cancelled_ppr + + @router.get("/{ppr_id}/journal", response_model=List[Journal]) async def get_ppr_journal( ppr_id: int, diff --git a/backend/app/core/email.py b/backend/app/core/email.py new file mode 100644 index 0000000..5192ec0 --- /dev/null +++ b/backend/app/core/email.py @@ -0,0 +1,48 @@ +import aiosmtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from jinja2 import Environment, FileSystemLoader +import os +from app.core.config import settings + + +class EmailService: + def __init__(self): + self.smtp_host = settings.mail_host + self.smtp_port = settings.mail_port + self.smtp_user = settings.mail_username + self.smtp_password = settings.mail_password + self.from_email = settings.mail_from + self.from_name = settings.mail_from_name + + # Set up Jinja2 environment for templates + template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates') + self.jinja_env = Environment(loader=FileSystemLoader(template_dir)) + + async def send_email(self, to_email: str, subject: str, template_name: str, template_vars: dict): + # Render the template + template = self.jinja_env.get_template(template_name) + html_content = template.render(**template_vars) + + # Create message + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = f"{self.from_name} <{self.from_email}>" + msg['To'] = to_email + + # Attach HTML content + html_part = MIMEText(html_content, 'html') + msg.attach(html_part) + + # Send email + try: + async with aiosmtplib.SMTP(hostname=self.smtp_host, port=self.smtp_port, use_tls=True) as smtp: + await smtp.login(self.smtp_user, self.smtp_password) + await smtp.send_message(msg) + except Exception as e: + # Log error, but for now, print + print(f"Failed to send email: {e}") + # In production, use logging + + +email_service = EmailService() \ No newline at end of file diff --git a/backend/app/crud/crud_ppr.py b/backend/app/crud/crud_ppr.py index a06c80e..c44e90b 100644 --- a/backend/app/crud/crud_ppr.py +++ b/backend/app/crud/crud_ppr.py @@ -2,6 +2,7 @@ from typing import List, Optional from sqlalchemy.orm import Session from sqlalchemy import and_, or_, func, desc from datetime import date, datetime +import secrets from app.models.ppr import PPRRecord, PPRStatus from app.schemas.ppr import PPRCreate, PPRUpdate from app.crud.crud_journal import journal as crud_journal @@ -11,6 +12,9 @@ class CRUDPPR: def get(self, db: Session, ppr_id: int) -> Optional[PPRRecord]: return db.query(PPRRecord).filter(PPRRecord.id == ppr_id).first() + def get_by_public_token(self, db: Session, token: str) -> Optional[PPRRecord]: + return db.query(PPRRecord).filter(PPRRecord.public_token == token).first() + def get_multi( self, db: Session, @@ -67,7 +71,8 @@ class CRUDPPR: db_obj = PPRRecord( **obj_in.dict(), created_by=created_by, - status=PPRStatus.NEW + status=PPRStatus.NEW, + public_token=secrets.token_urlsafe(64) ) db.add(db_obj) db.commit() diff --git a/backend/app/models/ppr.py b/backend/app/models/ppr.py index 9b3cd16..dc9bc86 100644 --- a/backend/app/models/ppr.py +++ b/backend/app/models/ppr.py @@ -42,6 +42,7 @@ class PPRRecord(Base): departed_dt = Column(DateTime, nullable=True) created_by = Column(String(16), nullable=True) submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp()) + public_token = Column(String(128), nullable=True, unique=True) class User(Base): diff --git a/backend/app/templates/ppr_cancelled.html b/backend/app/templates/ppr_cancelled.html new file mode 100644 index 0000000..1737ee1 --- /dev/null +++ b/backend/app/templates/ppr_cancelled.html @@ -0,0 +1,19 @@ + + + + PPR Cancelled + + +

PPR Cancelled

+

Dear {{ name }},

+

Your Prior Permission Request (PPR) has been cancelled.

+

PPR Details:

+ +

If this was not intended, please contact us.

+

Best regards,
Swansea Airport Team

+ + \ No newline at end of file diff --git a/backend/app/templates/ppr_submitted.html b/backend/app/templates/ppr_submitted.html new file mode 100644 index 0000000..946ec84 --- /dev/null +++ b/backend/app/templates/ppr_submitted.html @@ -0,0 +1,21 @@ + + + + PPR Submitted + + +

PPR Submitted Successfully

+

Dear {{ name }},

+

Your Prior Permission Request (PPR) has been submitted.

+

PPR Details:

+ +

You can edit or cancel your PPR using this secure link.

+

You will receive further updates via email.

+

Best regards,
Swansea Airport Team

+ + \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 45e9a75..614571e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,4 +14,6 @@ pydantic-settings==2.0.3 pytest==7.4.3 pytest-asyncio==0.21.1 httpx==0.25.2 -redis==5.0.1 \ No newline at end of file +redis==5.0.1 +aiosmtplib==3.0.1 +jinja2==3.1.2 \ No newline at end of file diff --git a/init_db.sql b/init_db.sql index 510635b..dd0d12a 100644 --- a/init_db.sql +++ b/init_db.sql @@ -41,6 +41,7 @@ CREATE TABLE submitted ( departed_dt DATETIME DEFAULT NULL, created_by VARCHAR(16) DEFAULT NULL, submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + public_token VARCHAR(128) DEFAULT NULL UNIQUE, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- Indexes for better performance @@ -49,7 +50,8 @@ CREATE TABLE submitted ( INDEX idx_etd (etd), INDEX idx_ac_reg (ac_reg), INDEX idx_submitted_dt (submitted_dt), - INDEX idx_created_by (created_by) + INDEX idx_created_by (created_by), + INDEX idx_public_token (public_token) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Activity journal table with foreign key diff --git a/web/edit.html b/web/edit.html new file mode 100644 index 0000000..d45a9b2 --- /dev/null +++ b/web/edit.html @@ -0,0 +1,652 @@ + + + + + + Edit Swansea PPR Request + + + +
+
+

✏️ Edit Swansea PPR Request

+

Update your Prior Permission Required (PPR) request details below.

+
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+
+ Processing your request... +
+ +
+

✅ PPR Request Updated Successfully!

+

Your changes have been saved.

+
+ +
+

❌ PPR Request Cancelled

+

Your PPR request has been cancelled.

+
+
+ + +
+ + + + \ No newline at end of file