Email notification

This commit is contained in:
James Pattinson
2025-10-25 13:31:03 +00:00
parent 91e820b9a8
commit d5f05941c9
9 changed files with 880 additions and 3 deletions

View File

@@ -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,

48
backend/app/core/email.py Normal file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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):

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>PPR Cancelled</title>
</head>
<body>
<h1>PPR Cancelled</h1>
<p>Dear {{ name }},</p>
<p>Your Prior Permission Request (PPR) has been cancelled.</p>
<p><strong>PPR Details:</strong></p>
<ul>
<li>Aircraft: {{ aircraft }}</li>
<li>Original Arrival: {{ arrival_time }}</li>
<li>Original Departure: {{ departure_time }}</li>
</ul>
<p>If this was not intended, please contact us.</p>
<p>Best regards,<br>Swansea Airport Team</p>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title>PPR Submitted</title>
</head>
<body>
<h1>PPR Submitted Successfully</h1>
<p>Dear {{ name }},</p>
<p>Your Prior Permission Request (PPR) has been submitted.</p>
<p><strong>PPR Details:</strong></p>
<ul>
<li>Aircraft: {{ aircraft }}</li>
<li>Arrival: {{ arrival_time }}</li>
<li>Departure: {{ departure_time }}</li>
<li>Purpose: {{ purpose }}</li>
</ul>
<p>You can <a href="{{ base_url }}/edit?token={{ public_token }}">edit or cancel</a> your PPR using this secure link.</p>
<p>You will receive further updates via email.</p>
<p>Best regards,<br>Swansea Airport Team</p>
</body>
</html>

View File

@@ -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
redis==5.0.1
aiosmtplib==3.0.1
jinja2==3.1.2