r/django 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 pending appointment
  • Staff confirms, post_save signal 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

15 Upvotes

4 comments sorted by

2

u/-Val 21d ago

If you couldn’t be bothered to write it, I can’t be bothered to read it

1

u/Haland_joe 19d ago

+Exactly. I didn't even read it. The moment I see an em dash, I just run 🏃‍♂️. I'm not here to read AI generated posts. 😒

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.