跳到主要内容

r/django



Advertisement: Move in without going offline. T-Mobile 5G Home Internet. Delivered same-day. Powered by DoorDash.
media poster


Play Doom in your Django Admin
Play Doom in your Django Admin
  • r/django - Play Doom in your Django Admin
  • r/django - Play Doom in your Django Admin

Building a truck service CRM with Django — Part 2: dashboard, PDF exports, and the small stuff nobody warns you about
Building a truck service CRM with Django — Part 2: dashboard, PDF exports, and the small stuff nobody warns you about

Part 1 here if you missed it — I covered the initial architecture, data models, and Telegram bot for a production CRM I built for a truck service center.

This post covers versions 1.1 through 2.0. Honestly, some of these changes are embarrassingly small, but I think there's value in showing that real projects aren't always big dramatic rewrites. Sometimes it's just "oh crap, demo passwords don't work" at 11pm.

The dashboard nobody asked for (v1.1)

The client didn't ask for analytics. I built them anyway because I wanted to see if the data model could support it, and also because a dashboard with a revenue chart looks great in a demo.

The stats endpoint pulls order counts by status plus revenue aggregations. Nothing fancy — just Sum('total_cost') on closed orders filtered by date ranges. The 12-month revenue chart was the trickiest part, and honestly it's a bit hacky:

revenue_chart = []
for i in range(11, -1, -1):
    month_date = (today.replace(day=1) 
                  - datetime.timedelta(days=i * 28)).replace(day=1)
    if month_date.month == 12:
        next_month = month_date.replace(
            year=month_date.year + 1, month=1
        )
    else:
        next_month = month_date.replace(
            month=month_date.month + 1
        )
    revenue = closed_qs.filter(
        created_at__date__gte=month_date,
        created_at__date__lt=next_month,
    ).aggregate(total=Sum('total_cost'))['total'] or 0

Yeah, that timedelta(days=i * 28) thing to walk backwards through months is... not my proudest moment. It works, but dateutil.relativedelta would've been cleaner. Leaving it here as a reminder that shipped code beats perfect code.

One thing I did right though — I excluded soft-deleted orders from stats from the start. If you don't do this on day one, you'll spend a fun afternoon debugging why your revenue numbers don't match reality.

The demo password incident (v1.2)

This one's short and painful. I had a seed_demo_data management command that used bulk_create() for demo client accounts. Looked great, ran fast, all records created. Except nobody could log in.

Turns out bulk_create() doesn't call save(), which means set_password() never hashes the password. So all demo accounts had raw plaintext in the password field, and Django's auth backend (rightfully) rejected every login attempt.

The fix was dumb simple — loop through and call set_password() before the bulk create. Lost maybe two hours to this, but I'll never forget it. If you're seeding users with bulk_create, just... don't, unless you hash passwords first.

Two lines that saved 10 minutes per call (v1.3)

The service center has trucks with different Euro emission standards (EURO3, EURO5, EURO6) and different base models. The mechanics kept scrolling through lists of 200+ trucks looking for the right one.

The fix was adding euro_standard and base_model to filterset_fields on TruckViewSet. Three lines of actual code change. The mechanics loved it more than any feature I've built before or since.

Sometimes the highest-value work is the most boring work.

Photo counts without the N+1 (v1.4)

Every service order can have multiple repair photos. The list view needed to show how many photos each order has. The naive approach — order.photos.count() in the serializer — is a classic N+1 trap.

I went with prefetch_related('photos') on the queryset and a photos_count field on the list serializer that reads from the prefetched cache. Not groundbreaking, but on a page showing 50 orders, that's 50 fewer queries.

PDF exports, or: why Cyrillic fonts are pain (v2.0)

The client wanted to print service orders as PDFs — the kind you hand to the customer with a signature line at the bottom. ReportLab was the obvious choice since it works server-side and doesn't need a headless browser.

The fun started when I realized all our data is in Ukrainian (Cyrillic), and ReportLab's built-in fonts don't support it. The solution is to register a TrueType font that covers Cyrillic glyphs:

font_paths = [
    '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
    '/usr/share/fonts/TTF/DejaVuSans.ttf',
    'C:/Windows/Fonts/arial.ttf',
]
font_name = 'Helvetica'  # fallback
for fp in font_paths:
    if os.path.exists(fp):
        try:
            pdfmetrics.registerFont(TTFont('CustomFont', fp))
            font_name = 'CustomFont'
        except Exception:
            pass
        break

Not pretty, but it handles dev (Windows), staging (Ubuntu), and production (Ubuntu) with one code path. DejaVu Sans is available on most Linux systems and covers Cyrillic, Latin, and Greek.

The PDF itself is a standard ReportLab SimpleDocTemplate with tables for order details, performed works with pricing, and a total row at the bottom. I added zebra striping on table rows and a yellow accent color for headers to match the brand.

The part I'm actually proud of: the signature block at the bottom. Two columns — "Client signature" and "Mechanic signature" — with a horizontal rule above. It's a tiny detail, but the service center owner said it made the PDFs look "real", as opposed to the Excel printouts they were using before.

What I'd do differently

The dashboard stats endpoint makes 14 database queries. One for each month in the chart, plus counts by status. It should be a single query with TruncMonth and annotate. It works fine now because there's only a few thousand orders, but it won't scale. I'll fix it. Eventually. Probably.

The font registration code is fragile. If tomorrow I deploy to Alpine Linux, the font paths will be wrong. Should've used django.conf.settings.REPORTLAB_FONT_PATH or bundled the font in the project.

What's next

Part 3 will cover v2.1–v2.2, where things get actually interesting — appointment booking, automatic license plate recognition from security camera feeds, and invoice generation. That's where the project stopped being "just a CRM" and started becoming something bigger.

Previous: Part 1 — Initial architecture and data models GitHub (demo repo): github.com/VNmagistr/truckmaster_demo — branches demo/v1.1 through demo/v2.0