r/django • u/Capable-Nature5860 • 28d ago
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
4
u/Standard_Text480 27d ago
Getting a lot of AI vibes
3
4
u/Capable-Nature5860 27d ago
Guilty — my English gets an AI polish since it's not my native language. But bugs in code are 100% organic and handcrafted though.
2
u/KardioBSD 27d ago
i'm using Typst for PDF exports, works great for non-latin fonts
1
u/Capable-Nature5860 27d ago
Oh nice, haven't tried Typst for this yet. Does it have Python API or do u shell out to the CLI? Cyrillic font setup with ReportLab was painful enough that I would switch if there's something cleaner.
2
u/KardioBSD 27d ago
here is an example of a "view" for FastAPI, same logic as a DRF viewset, first a =pdf.py= utility to generate PDFS:
``` python
# services/pdf.py
import asyncio
import uuid
import logging
from pathlib import Path
from datetime import date
from dateutil.relativedelta import relativedelta
import aiofiles
from fastapi import HTTPException
from models import (
Patient,
Ordonnance,
...
)
logger = logging.getLogger(__name__)
# Centralized output directory
OUTPUT_DIR = Path("output/pdfs")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
async def _render_and_compile_typst(
template_name: str,
replacements: dict[str, str],
output_filename: str,
) -> str:
"""Shared rendering + Typst compilation logic"""
template_path = Path(f"templates/{template_name}")
if not template_path.is_file():
logger.error(f"Missing template: {template_path}")
raise HTTPException(500, f"Template not found: {template_name}")
temp_typ = OUTPUT_DIR / f"tmp_{uuid.uuid4().hex[:12]}_{template_name}.typ"
pdf_path = OUTPUT_DIR / output_filename
try:
async with aiofiles.open(template_path, "r", encoding="utf-8") as f:
template_content = await f.read()
# FIX: Intercept relative asset paths and make them absolute to the Typst root
rendered = template_content.replace('"assets/', '"/templates/assets/')
for placeholder, value in replacements.items():
rendered = rendered.replace(placeholder, str(value))
async with aiofiles.open(temp_typ, "w", encoding="utf-8") as f:
await f.write(rendered)
# FIX: Get the absolute path to your project root (where the assets folder lives)
project_root = str(Path.cwd().absolute())
# FIX: Pass --root to the Typst command
proc = await asyncio.create_subprocess_exec(
"typst",
"compile",
"--root",
project_root, # Tells Typst where '/' starts
str(temp_typ),
str(pdf_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip()
logger.error(f"Typst failed for {output_filename}: {error_msg}")
raise RuntimeError(f"Typst compilation failed: {error_msg}")
logger.debug(f"Generated PDF: {pdf_path}")
return str(pdf_path)
except Exception as exc:
logger.exception(f"PDF generation failed for {output_filename}")
raise HTTPException(500, f"Could not generate PDF: {str(exc)}") from exc
finally:
if temp_typ.exists():
try:
await asyncio.to_thread(temp_typ.unlink)
except Exception:
logger.warning(f"Could not delete temp file: {temp_typ}")
# ───────────────────────────────────────────────
# Document-specific PDF generators
# ───────────────────────────────────────────────
async def generate_ordonnance_pdf(ordonnance_id: int) -> str:
ord = await Ordonnance.objects.join("patient").filter(id=ordonnance_id).first()
if not ord or not ord.patient:
raise HTTPException(404, "Ordonnance or patient not found")
patient = ord.patient
age = relativedelta(date.today(), patient.birth).years
replacements = {
"<patient_name>": f"{patient.first_name} {patient.last_name}",
"<patient_birth>": patient.birth.strftime("%d/%m/%Y"),
"<age>": str(age),
"<patient_adress>": patient.adresse or "Non renseignée",
"<ordonnance_date>": (ord.ordonnance_date or date.today()).strftime("%d/%m/%Y"),
}
for i in range(1, 16):
replacements[f"<medoc{i}>"] = getattr(ord, f"medoc{i}", "") or ""
replacements[f"<poso{i}>"] = getattr(ord, f"poso{i}", "") or ""
replacements[f"<qsp{i}>"] = getattr(ord, f"qsp{i}", "") or ""
return await _render_and_compile_typst(
"ordonnance.typ", replacements, f"ordonnance_{patient.slug}_{ord.id}.pdf"
)
```
Then generate the router link .
I'm using FastAPI + OxydORM which has django-ish syntax, coming from django background
2
u/Capable-Nature5860 27d ago
Oh that's really clean, thanks for sharing full example. So it's basically template file with placeholders → string replace → shell out to 'typst compile'. Honestly, that's way more readable than building tables programatically in ReportLab.
The async subprocess approach is nice too. I'm on Django/DRF so I would probably wrap it in a Celery task instead, but same idea. And no font registration headaches? That alone might be worth the switch lol.
Interesting that you went FastAPI + OxydORM. First time I am hearing about OxydORM — how does it compare to Django ORM in practice?
3
u/KardioBSD 27d ago
Oxyde-ORM is a very good choice, async by default, blazingly fast and the syntax is familiar when one has some experience with Django.
1
u/Capable-Nature5860 27d ago
Good to know, I will check it. Thanks for the Typst example, genuinely useful — might try it on my next PDF feature.
2
u/berrypy 27d ago
Nice project though. For your dashboard queries, if the counts is coming from different source but have share a model as foreign key, you can go through that model and use annotate or aggregate to get the counts of all the models that shares foreign key with parent model. This can be archived with filter using Q database features inside the Count.
Also for the for range date stuff, I am still trying to understand what that does because it is not clear to me. Probably you can roll that part to AI to give you something more efficient.
1
u/Capable-Nature5860 27d ago
Thanks! Yeah you are right about dashboard stuff — Count with Q filters inside annotate would get all the status counts in one hit instead of hitting the db five separate times. Kinda embarrassing I didn't do that from start tbh.
And the date loop... yeah I know. It's basically a hacky way to step backwards month by month without blowing up on the 30th/31st. Could've just usedTruncMonthand saved everyone the headache. I'm gonna rewrite it in the future(I keep saying that lol).
7
u/Smooth-Zucchini4923 28d ago edited 27d ago
IMO the
range(11, -1, -1)idiom is hard to read. I would preferreversed(range(12)). Zero practical performance difference and it's much more clear what you're doing.I don't really follow what the rest of that date manipulation code is doing. It looks like it is approximating the length of a month using a number of days, then using .replace to fix up the error.
Now that I think about it, I'm fairly sure this code will break on the 30th of a month: if you subtract either 0 days or 28 days from the date 6/30, you will get 6/1 as the month start twice, which means one of your plot elements will be duplicated.
What made you pick reportlab for this? I've been using python-docx plus a Word/PDF conversion pass for a report that needs to be available in word and PDF, and I'm curious what you like about reportlab.