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
Subscribe to my newsletter
Read articles from Mogboluwaga Onayade directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
