Personalized Confirmation Tickets with FastAPI and Pillow
While working as a backend developer for AIESEC in Nigeria, I encountered a challenge during conference registrations: creating personalized tickets for attendees. Traditional methods, like writing extensive MJML templates, felt cumbersome and time-consuming. So, I decided to treat the tickets as images and used Python libraries: Pillow (PIL) for image manipulation and FastAPI to create a web server to host the images.
Installation
I started by setting up a virtual environment in the directory I plan to have my project. You can get help setting up a virtual environment here. Then, I installed the packages I’d be using:
pip install fastapi uvicorn pydantic pillow
The FastAPI Server
Inside a main.py
file, I used to write my main code, I imported the following packages: FastAPI for creating the web server, CORSMiddleware for cross-origin resource sharing, and StreamingResponse for sending image files. I made a FastAPI application instance, configured CORS to allow requests from any origin, and used the last lines to ensure the FastAPI application ran directly as a script.
from fastapi import FastAPI, Path
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
app = FastAPI()
origins = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
if __name__ == "__main__":
app.run()
Ticket Generation Endpoint
You'll use this endpoint to get the ticket for a particular conference and a specific attendee. Simply provide the service—the service is the conference name—in the URL and pass the first name and last name as query parameters.
So, this endpoint:
Calls
generate_delegate_ticket()
function to create the ticketReturns the ticket as a streaming PNG image
from ticket import generate_delegate_ticket
@app.get("/api/{service}/generate-ticket")
async def generate_ticket(
first_name: str,
last_name: str,
service: str = Path(..., description="The service to generate ticket for")
):
doc_io, file_name = generate_delegate_ticket(service, first_name, last_name)
# setting headers for the response, specifying the filename and content type
headers = {
"Content-Disposition": f"inline; filename={file_name}",
"Content-Type": "image/png"
}
# return the image as a streaming response
return StreamingResponse(
doc_io,
headers=headers,
media_type="image/png"
)
Making the Ticket
Schemas
Inside a schema.py
I added the following Pydantic schemas to define and validate configuration parameters for our ticket generation system.
from pydantic import BaseModel
class TicketConfig(BaseModel):
ticket_template: str
ticket_color: tuple[int, int, int]
font_family: str
offset: tuple[int, int]
max_font_size: int
max_text_size: int
multi_line: bool = False
allow_multi_line: bool = True
class ServiceConfig(BaseModel):
id: str
name: str
ticket_config: TicketConfig = None
TicketConfig Schema
This schema defines the details for ticket customization:
registration_template
: Path to the base ticket imageregistration_color
: RGB color tuple for textfont_family
: Path to the font fileoffset
: (x, y) coordinates for text placementmax_font_size
: Maximum initial font sizemax_text_size
: Maximum allowed text widthmulti_line
andallow_multi_line
: Control text wrapping behavior
ServiceConfig Schema
Allows defining specific configurations for different services:
id
: Unique identifier for the servicename
: Human-readable service nameticket_config
: Optional TicketConfig for service-specific customization
Service Configs
Service configurations are stored in service_configs.py
for two conferences: Youth Speak Forum (ysf) and NEXLDS IFE (nexlds_ife), which I will use as examples.
from schema import ServiceConfig, TicketConfig
ysf_2022 = ServiceConfig(
id= 'ysf-2022',
name= 'YSF 2022',
ticket_config=TicketConfig(
registration_template=r'static/ysf-2022/ticket_template.png',
registration_color=(255, 255, 255),
font_family='static/ysf-2022/Poppins/Poppins-Black.ttf',
offset=(64, 256),
max_font_size=134,
max_text_size=1095,
multi_line=True
)
)
nexlds_ife = ServiceConfig(
id= 'nexlds-ife',
name= 'NEXLDS Ife',
ticket_config= TicketConfig(
registration_template = r'static/nexlds-ife/ticket_template.png',
registration_color = (102, 78, 59),
font_family = 'static/nexlds-ife/Space_Mono/SpaceMono-Bold.ttf',
offset = (190, 351),
max_font_size = 84,
max_text_size = 693
)
)
# maps service identifiers to their corresponding ServiceConfig instances
service_configs: dict[str, ServiceConfig] = {
nexlds_ife.id: nexlds_ife,
ysf_2022.id: ysf_2022,
}
Customizing the ticket
I created a ticket.py
file to write the attendee’s name on the ticket. The multiline_textbbox()
function returns a (left, top, right, bottom)
bounding box, where right
indicates the text width.
import re
from PIL import Image, ImageDraw, ImageFont
def make_ticket(img_file, color, attendee_name, offset, font_family, max_font_size, max_text_size, allow_multi_line=True) -> Image: # noqa: FBT002
#opens the image file and converts image to RGB mode
img = Image.open(img_file, 'r').convert('RGB')
imgdraw = ImageDraw.Draw(img)
font_size = max_font_size
def get_text_size(text, font):
# multiline_text method to get text dimensions
return imgdraw.multiline_textbbox((0, 0), text, font=font)[2]
font = ImageFont.truetype(font_family, font_size)
width = get_text_size(attendee_name, font)
# checks if the name fits in the maximum font size
while width > max_text_size:
# replace spaces with newlines
if font_size == max_font_size and allow_multi_line:
attendee_name = attendee_name.replace(' ', '\n')
else:
font = ImageFont.truetype(font_family, font_size)
width = get_text_size(attendee_name, font)
font_size -= 1
# Use multiline_text for drawing
imgdraw.multiline_text(offset, attendee_name, color, font=font)
return img
To retrieve the attendee's details and render the ticket, I added the following to the ticket.py
file:
import re
from io import BytesIO
from fastapi.exceptions import HTTPException
from service_configs import service_configs
def get_buffer(stream, open_stream):
"""creates an in-memory buffer containing the PNG image data."""
io = None
if stream is not None:
io = BytesIO()
open_stream(stream, io)
io.seek(0)
return io
def generate_delegate_ticket(service: str, first_name: str, last_name: str):
"""generates a delegate ticket for a service and participant names."""
service_config = service_configs.get(service)
if service_config is None or service_config.ticket_config is None:
raise HTTPException(status_code=404, detail="Requested service is unavailable")
first_name = re.sub('([^A-z-]).+', '', first_name.strip()).upper()
last_name = re.sub('([^A-z-]).+', '', last_name.strip()).upper()
ticket_config = service_config.ticket_config
single_line_name = f"{first_name} {last_name}"
name = '{}{}{}'.format(first_name, '\n' if ticket_config.multi_line else ' ', last_name)
img = make_ticket(
ticket_config.registration_template,
ticket_config.registration_color,
name,
ticket_config.offset,
ticket_config.font_family,
ticket_config.max_font_size,
ticket_config.max_text_size,
ticket_config.allow_multi_line
)
# return the in-memory image buffer and a formatted filename for the ticket
return (
get_buffer(img, lambda img, io: img.save(io, 'PNG')),
f'{single_line_name} Confirmation Ticket.png'
)
Running the Application
That covers the code explanation, but what about the actual API response? Run the following command in your terminal from the current working directory:
uvicorn main:app --reload
Open your browser and enter http://127.0.0.1:8000/api/{service_name}/generate-ticket?first_name=first_name&last_name=last_name
in the URL bar. You will see the image being served directly from the server.
Conclusion
I hope you enjoyed working on this as much as I did. You can find the full implementation here.
Potential Improvements
Add text alignment options (center, left, right)
Implement more sophisticated text wrapping