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 @@ + + +
+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
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
Update your Prior Permission Required (PPR) request details below.
+