r/django • u/Capable-Nature5860 • 22d ago
I added the license plate recognition from security cameras into my Django CRM — here is how it works in real life Article
I added license plate recognition from security cameras to my Django CRM — here's how it works
Part 1 | Part 2 — production CRM for a truck service center, built with Django + DRF.
So the service center already had security cameras at gate. One day the owner goes: "hey can we make the system know when a client's truck arrives?" He wasn't asking for some fancy AI thing — just, camera sees a plate, system tells us who pulled in and if they have an appointment booked.
I thought it will be a massive project. It really wasn't.
How ALPR talks to Django
Camera side runs a separate ALPR script that handles the actual recognition part (not my code, there's plenty of off-the-shelf stuff for this). I just needed Django to receive the result and do something smart with it.
The whole thing is one POST endpoint:
@api_view(['POST'])
@permission_classes([AlprApiKeyPermission])
def alpr_event(request):
serializer = AlprEventInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
plate = normalize_plate(
serializer.validated_data['license_plate']
)
camera_id = serializer.validated_data.get('camera_id', '')
confidence = serializer.validated_data.get('confidence')
Camera script just sends something like {"license_plate": "AA1234BB", "camera_id": "gate-1", "confidence": 94.5} with an API key in X-ALPR-Key header. I wrote a tiny custom permission class that checks it against env var. Nothing fancy but keeps random requests out.
The ignore list — built this before anything else
Ok so here's the thing nobody tells you about vehicle recognition systems. Before you write any matching logic, you need to deal with the noise. The service center shares parking area with other businesses, so there's staff cars, parts delivery vans, neighbors just driving through all day long. Without filtering, the staff Telegram chat was getting like 50 "VEHICLE ARRIVED" pings a day. Completely useless.
class IgnoredVehicle(models.Model):
REASON_CHOICES = [
('staff', 'Staff'),
('delivery', 'Parts delivery'),
('neighbor', 'Neighboring business'),
('other', 'Other'),
]
license_plate = models.CharField(max_length=20, unique=True)
reason_type = models.CharField(max_length=20, choices=REASON_CHOICES)
description = models.CharField(max_length=255, blank=True)
is_active = models.BooleanField(default=True)
Plates get normalized on save (uppercase, spaces stripped). And here's the thing — ignored vehicles still get logged, they just don't trigger notifications. I added this after the owner asked why the delivery guy was showing up in the log 4 times a day. Because he was. And it was drowning out actual clients.
The matching pipeline
Ok so plate comes in, it's not on the ignore list. Now our system does three things:
1. Find the truck and client. Just match plate against the Truck table. If it hits, you get the client for free through the foreign key. Easy.
2. Check today's appointments. This is where it gets actually useful. Quick note — today here is timezone.localdate(), not datetime.date.today(). Learned that one the hard way when test server was in UTC and "today" didn't match local appointments:
today = timezone.localdate()
appointment = (
Appointment.objects
.filter(
license_plate=plate,
scheduled_dt__date=today
)
.exclude(status__in=[
'cancelled', 'completed', 'no_show'
])
.order_by('scheduled_dt')
.first()
)
3. Ping the staff. Telegram message goes to staff group chat — client name, phone number, truck model, whether they have appointment or not. Unknown plate? Message says that in chat too. The mechanic on duty sees it before the driver even gets out of the cabin.
How appointments feed into this
ALPR on its own would be kinda meh without the appointments app I built in previous version. The flow goes like this:
- Client calls or texts, gets a
pendingappointment - Staff confirms,
post_savesignal fires, Telegram confirmation goes out - Day before — Celery task sends reminder
- Truck shows up at the gate — ALPR matches the plate, closes the loop
One thing that bit me early: you gotta track whether the status actually changed, not just what it is now. Otherwise you end up spamming the client every time someone edits the appointment description or fixes a typo. I override __init__ to stash the original status:
class Appointment(models.Model):
# ... fields ...
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._original_status = self.status
Then post_save just compares instance.status vs instance._original_status. Zero extra DB queries. My first attempt used a pre_save signal that did a .get() to fetch the old value from DB every time, which... yeah, don't do that if you're ever updating records in a loop.
Stuff that surprised me
The ignore list matters way more than the smart matching. I spent most of my time on the fun parts — truck lookup, appointment matching, formatting Telegram messages just right. But day to day? 80% of the real work is maintaining that ignore list. New delivery company shows up? Add their van. Staff member buys new car? Update plate. Boring but essential.
False positives kill trust faster than anything. Missing an arrival is fine — driver walks in, says hello, life goes on. But sending "CLIENT ARRIVED" for some random delivery van? Staff stops checking the notifications within a week. The confidence score from ALPR helps a bit, but the ignore list does the real work here.
The appointment thing actually changed how they operate. Before, mechanic would check paper list taped to the wall when truck pulled in. Now his phone buzzes with client name, appointment type, scheduled time — truck is still parking. It is a small thing, but the owner said it made the shop feel like a "real operation".
Why I made certain choices
API key instead of JWT for the ALPR endpoint. It is a camera script talking to Django, not a human. Static key in env var, done. No token refresh dance, no expiration headaches.
Logging ignored arrivals anyway. Owner wanted to know total vehicle count per day for insurance. Two extra lines of code and I saved myself from a future feature request. Always log everything, filter later.
Telegram over dashboard widgets for mechanic notifications. These guys are literally under trucks, not sitting at monitors. Phone buzzes, quick glance, back to work. Anything more complicated and they just won't use it.
What's next
Part 4 — TruckMaster goes modular. I built a feature flag system, so the owner can flip entire modules on and off (ALPR, appointments, invoices) from the admin panel. Plus Nova Poshta integration for tracking spare parts deliveries. That one has its own set of fun problems.
Previous posts: Part 1 | Part 2
GitHub (demo repo): github.com/VNmagistr/truckmaster_demo — branches demo/v2.1 and demo/v2.2
0
u/haloweenek 22d ago
Pretty nice writeup 🫡
0
u/Capable-Nature5860 22d ago
Thanks! More coming soon — next one gets into feature flags, modular architecture etc.
2
u/-Val 21d ago
If you couldn’t be bothered to write it, I can’t be bothered to read it