Website contact form endpoint
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements, drone_requests
|
||||
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements, drone_requests, contact_requests
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -13,6 +13,7 @@ api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"]
|
||||
api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
|
||||
api_router.include_router(movements.router, prefix="/movements", tags=["movements"])
|
||||
api_router.include_router(drone_requests.router, prefix="/drone-requests", tags=["drone_requests"])
|
||||
api_router.include_router(contact_requests.router, prefix="/contact-requests", tags=["contact_requests"])
|
||||
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
||||
api_router.include_router(public_book.router, prefix="/public-book", tags=["public_booking"])
|
||||
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.core.email import email_service
|
||||
from app.core.utils import get_client_ip
|
||||
from app.schemas.contact_request import ContactRequestCreate, ContactRequestReceipt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CONTACT_REQUEST_RECIPIENT = "tower@swansea-airport.wales"
|
||||
|
||||
|
||||
@router.post("/public", response_model=ContactRequestReceipt)
|
||||
async def create_public_contact_request(
|
||||
contact_request: ContactRequestCreate,
|
||||
request: Request,
|
||||
):
|
||||
submitted_at = datetime.now(timezone.utc)
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
print(
|
||||
"Public contact request received "
|
||||
f"at={submitted_at.isoformat()} "
|
||||
f"type={contact_request.enquiry_type.value} "
|
||||
f"name={contact_request.name!r} "
|
||||
f"email={contact_request.email} "
|
||||
f"source={contact_request.source_page or '-'} "
|
||||
f"ip={client_ip}"
|
||||
)
|
||||
|
||||
await email_service.send_email(
|
||||
to_email=CONTACT_REQUEST_RECIPIENT,
|
||||
subject=f"Website contact: {contact_request.subject}",
|
||||
template_name="contact_request.html",
|
||||
reply_to=f"{contact_request.name} <{contact_request.email}>",
|
||||
template_vars={
|
||||
"submitted_at": submitted_at.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
"client_ip": client_ip,
|
||||
"name": contact_request.name,
|
||||
"email": contact_request.email,
|
||||
"phone": contact_request.phone,
|
||||
"enquiry_type": contact_request.enquiry_type.value,
|
||||
"subject": contact_request.subject,
|
||||
"message": contact_request.message,
|
||||
"source_page": contact_request.source_page,
|
||||
},
|
||||
)
|
||||
|
||||
return ContactRequestReceipt()
|
||||
@@ -19,7 +19,14 @@ class EmailService:
|
||||
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):
|
||||
async def send_email(
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
template_name: str,
|
||||
template_vars: dict,
|
||||
reply_to: str | None = None,
|
||||
):
|
||||
# Render the template
|
||||
template = self.jinja_env.get_template(template_name)
|
||||
html_content = template.render(**template_vars)
|
||||
@@ -29,6 +36,8 @@ class EmailService:
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = f"{self.from_name} <{self.from_email}>"
|
||||
msg['To'] = to_email
|
||||
if reply_to:
|
||||
msg['Reply-To'] = reply_to
|
||||
|
||||
# Attach HTML content
|
||||
html_part = MIMEText(html_content, 'html')
|
||||
@@ -45,4 +54,4 @@ class EmailService:
|
||||
# In production, use logging
|
||||
|
||||
|
||||
email_service = EmailService()
|
||||
email_service = EmailService()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, validator
|
||||
|
||||
|
||||
class ContactEnquiryType(str, Enum):
|
||||
GENERAL = "general"
|
||||
AVIATION_BUSINESS = "aviation_business"
|
||||
PILOT = "pilot"
|
||||
EVENTS = "events"
|
||||
COMMUNITY = "community"
|
||||
|
||||
|
||||
class ContactRequestCreate(BaseModel):
|
||||
name: str = Field(..., max_length=128)
|
||||
email: EmailStr
|
||||
phone: Optional[str] = Field(None, max_length=32)
|
||||
enquiry_type: ContactEnquiryType
|
||||
subject: str = Field(..., max_length=160)
|
||||
message: str = Field(..., min_length=1, max_length=4000)
|
||||
source_page: Optional[str] = Field(None, max_length=256)
|
||||
|
||||
@validator("name", "subject", "message")
|
||||
def validate_required_text(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("Field is required")
|
||||
return value
|
||||
|
||||
@validator("phone", "source_page")
|
||||
def strip_optional_text(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class ContactRequestReceipt(BaseModel):
|
||||
status: str = "received"
|
||||
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Website Contact Request</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f6f8; color: #24313d; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 1.55;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f6f8; padding: 24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-collapse: collapse; border-radius: 8px; overflow: hidden; border: 1px solid #dfe5eb;">
|
||||
<tr>
|
||||
<td style="background: #263645; color: #ffffff; padding: 24px 28px;">
|
||||
<h1 style="margin: 0; font-size: 24px; line-height: 1.25;">Website Contact Request</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 17px;">{{ enquiry_type | e }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 28px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; margin: 0 0 22px;">
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc; width: 38%;"><strong>Submitted</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ submitted_at | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Name</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ name | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Email</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ email | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Phone</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ phone | default("-", true) | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Category</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ enquiry_type | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Subject</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ subject | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Source page</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ source_page | default("-", true) | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Client IP</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ client_ip | e }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 style="font-size: 19px; margin: 0 0 10px;">Message</h2>
|
||||
<div style="border: 1px solid #dfe5eb; background: #f8fafc; padding: 14px 16px; white-space: pre-wrap;">{{ message | e }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
def contact_payload(**overrides):
|
||||
payload = {
|
||||
"name": "Jane Smith",
|
||||
"email": "jane@example.com",
|
||||
"phone": "07123 456789",
|
||||
"enquiry_type": "aviation_business",
|
||||
"subject": "Basing a maintenance business at Swansea",
|
||||
"message": "We would like to explore operating from Swansea Airport.",
|
||||
"source_page": "/contact/",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_public_contact_request_emails_tower_and_logs(client, monkeypatch, capsys):
|
||||
sent_emails = []
|
||||
|
||||
async def fake_send_email(**kwargs):
|
||||
sent_emails.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.contact_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/contact-requests/public",
|
||||
json=contact_payload(),
|
||||
headers={"X-Forwarded-For": "203.0.113.10, 10.0.0.1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "received"}
|
||||
assert len(sent_emails) == 1
|
||||
|
||||
email = sent_emails[0]
|
||||
assert email["to_email"] == "tower@swansea-airport.wales"
|
||||
assert email["reply_to"] == "Jane Smith <jane@example.com>"
|
||||
assert email["subject"] == "Website contact: Basing a maintenance business at Swansea"
|
||||
assert email["template_name"] == "contact_request.html"
|
||||
assert email["template_vars"]["name"] == "Jane Smith"
|
||||
assert email["template_vars"]["enquiry_type"] == "aviation_business"
|
||||
assert email["template_vars"]["client_ip"] == "203.0.113.10"
|
||||
|
||||
log_output = capsys.readouterr().out
|
||||
assert "Public contact request received" in log_output
|
||||
assert "aviation_business" in log_output
|
||||
assert "jane@example.com" in log_output
|
||||
|
||||
|
||||
def test_public_contact_request_validation(client, monkeypatch):
|
||||
async def fake_send_email(**kwargs):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.contact_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
invalid_category = client.post(
|
||||
"/api/v1/contact-requests/public",
|
||||
json=contact_payload(enquiry_type="sales"),
|
||||
)
|
||||
blank_required = client.post(
|
||||
"/api/v1/contact-requests/public",
|
||||
json=contact_payload(name=" ", subject="", message=" "),
|
||||
)
|
||||
|
||||
assert invalid_category.status_code == 422
|
||||
assert blank_required.status_code == 422
|
||||
Reference in New Issue
Block a user