Personalized Confirmation Tickets with FastAPI and Pillow

Photo by MD Duran on Unsplash

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 ticket

  • Returns 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 image

  • registration_color: RGB color tuple for text

  • font_family: Path to the font file

  • offset: (x, y) coordinates for text placement

  • max_font_size: Maximum initial font size

  • max_text_size: Maximum allowed text width

  • multi_line and allow_multi_line: Control text wrapping behavior

ServiceConfig Schema

Allows defining specific configurations for different services:

  • id: Unique identifier for the service

  • name: Human-readable service name

  • ticket_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