r/django 7d ago

Article Moving from signals to a service layer, adding RBAC, and importing 500 clients from Excel into my Django CRM

Part 1 | Part 2 | Part 3 | Part 4 | Part 5 | Part 6 - production CRM for truck-service center, Django + DRF.

This covers v2.7 and v2.9. The big theme here is cleaning up architecture decisions I made early on that started to hurt. Signals for stock management, no proper role-based access, manual client onboarding. All fixed now.

Why I killed my signals (StockService refactor)

In earlier versions, stock deductions happened through Django signals. When a UsedPart was created, post_save signal would fire and reduce warehouse stock. When deleted - post_delete would restore it. Sounds clean in theory. But..

In practice - it was nightmare. The signals were invisible - new developer (me, three months later) would look at view code and have no idea that saving a UsedPart triggers stock changes. Debugging was painful because the traceback starts in the signal handler, not where you actually called .save(). And testing was awful - every test that touches UsedPart was also triggering stock logic, whether I want it or not.

So I replaced everything with a StockService class:

class StockService:
    u/staticmethod
    def deduct(used_part):
        warehouse = _get_warehouse(used_part)
        if not warehouse:
            return
        stock_item, _ = StockItem.objects.get_or_create(
            warehouse=warehouse,
            product=used_part.part,
            defaults={'quantity': 0},
        )
        stock_item.quantity -= used_part.quantity
        stock_item.save()

        service_order = _get_service_order(used_part)
        StockMovement.objects.create(
            movement_type='out',
            product=used_part.part,
            quantity=used_part.quantity,
            warehouse_from=warehouse,
            service_order=service_order,
        )

    u/staticmethod
    def restore(used_part):
        # ... opposite of deduct ...

    u/staticmethod
    def adjust(used_part, old_quantity):
        delta = old_quantity - used_part.quantity
        if delta == 0:
            return
        # ... adjust stock by delta ...

Three methods: deduct, restore, adjust. Called explicitly from views and serializers. No magic, no hidden side effects. When I read view code now, I can see exactly where stock changes happen because there is line that says StockService.deduct(used_part).

The adjust method was the thing signals could never handle cleanly. When mechanic changes the quantity on an existing UsedPart (used 3 filters instead of 2), you need to calculate the delta and adjust. With signals you would need to stash old value in pre_save, compare in post_save... same mess I had with appointment status tracking in Part 3. Service layer just takes old_quantity as parameter.

Importing 500 clients from an Excel spreadsheet

The service center had been running for years before TruckMaster. All their client data lived in one massive Excel file - names, phone numbers, VIN's, license plates, truck models. About 500 rows. Entering them by hand through the admin panel was not an option.

I wrote a management command:

python manage.py import_clients_xlsx --file /path/to/clients.xlsx
python manage.py import_clients_xlsx --file /path/to/clients.xlsx --dry-run

The --dry-run flag was the most important feature. It run the whole import logic but does not write to the database. Just prints what would happen: "would create client X", "would update truck Y", "phone +380... already taken by client Z". The Owner ran dry-run first, fix duplicates in the Excel, then ran the real import. Zero surprises.

Tricky part was deduplication. The Excel had inconsistent naming - same client could be "Тра***", "ТРА***". I normalized names with ' '.join(str(s).strip().split()).lower() and matched on that. Not bulletproof but caught 90% of case.

Another thing - ownership tracking during import. If a license plate already exists in system under a different client, the import creates an OwnershipHistory record (same as the Truck.save() logic from Part 1) before reassigning. So historical service records stay with old owner.

Redis debounce for ALPR

Remember the ALPR system from Part 3? Security camera sends a plate recognition event to Django every time it sees a plate. Problem: camera sometimes sends the same plate 10 times in 30 seconds (truck passing slowly, multiple frames). Without debounce, staff Telegram chat would get spammed with duplicate "VEHICLE ARRIVED" notifications.

The fix was simple - Redis cache with a 5-minute TTL:

ALPR_DEBOUNCE_TTL = 300

debounce_key = f'alpr:debounce:{plate}'
if cache.get(debounce_key):
    return Response({'status': 'debounced', 'license_plate': plate})
cache.set(debounce_key, True, ALPR_DEBOUNCE_TTL)

First time a plate is seen - process it normally and set the cache key. Next time the same plate shows up within 5 minutes - return early with debounced status. Redis TTL handles expiry automatically, no cleanup need.

I should have built this from day one. The camera was sending around 50 duplicate events per day and I only noticed because the Telegram notification log was full of identical messages one second apart.

Role-based access control

Up to this point, authentication was JWT-based (Part 1) but authorization was basically "logged in = can do everything." The owner, the mechanic, and the storekeeper all had the same API access. Not ideal.

I added role-based permission classes:

class IsAdminRole(BasePermission):
    def has_permission(self, request, view):
        return (request.user.is_superuser
                or _role(request.user) == 'admin')

class CanManageStock(BasePermission):
    def has_permission(self, request, view):
        return (request.user.is_superuser
                or _role(request.user) in (
                    'admin', 'manager', 'storekeeper'
                ))

class CanAccessInvoices(BasePermission):
    def has_permission(self, request, view):
        return (request.user.is_superuser
                or _role(request.user) in (
                    'admin', 'manager', 'accountant'
                ))

Roles are stored on UserProfile and checked via a simple _role() helper. Nothing fancy - no django-guardian, no object-level permissions. Just "this role can access this viewset." For team of 5 people this is more than enough.

The important thing was that I could add these to existing viewsets without changing any view logic - just add permission_classes = [IsAuthenticated, CanManageStock] and it works. DRF's permission system is really well designed for this.

Also reduced JWT access token lifetime from 12 hours to 15 minutes. 12 hours was lazy and insecure. If someone's token leaks, 15 minutes limits damage.

Smaller things

Bulk repair photo upload. Before, mechanics uploaded photos one by one. Now there is a bulk_upload endpoint that accepts multiple files, saves them all, and sends one Telegram notification instead of ten. Also added MAX_REPAIR_PHOTOS_PER_ORDER constant - 20 photos per order, because without limit someone will upload their entire camera roll.

Barcode lookup. Added a barcode field to Product and a query parameter on the inventory API. Scan a barcode with a phone, hit the API, get the product. Took maybe 30 minutes to implement but the storekeeper acts like I gave him a superpower.

Bot maintenance history. Truck owners can now check maintenance history for their vehicles directly in Telegram. Shows last 3 service orders per truck with dates and work descriptions. Moved it under a "My vehicles" submenu to keep the bot keyboard clean.

What I learned

Signals are for cross-cutting concerns, not business logic. Audit logging, cache invalidation, sending notifications - signals are great for these. Stock management, payment processing, status transitions - these belong in explicit service calls. The moment you catch yourself writing pre_save + post_save combos to track field changes, you have outgrown signals.

Always add --dry-run to import commands. The cost of implementing it is maybe 20 minutes. The cost of a botched import that creates 200 duplicate clients is a weekend of cleanup and an angry owner.

Redis debounce is a pattern you will use everywhere. ALPR events, webhook handlers, rate limiting, notification dedup - same pattern, different keys and TTLs. Once you build it for one thing, you start seeing opportunities everywhere.

What is next

More versions to cover - i18n (UK/EN), maintenance templates, QR/shortlinks, and eventually the full React frontend with PWA. If there is interest I will keep going.

Also I take on freelance Django projects when something interesting comes along. If you are building something in this space, feel free to dm, I'll help you with a great pleasure

Previous posts: Part 1 | Part 2 | Part 3 | Part 4 | Part 5 | Part 6 GitHub (demo repo): github.com/VNmagistr/truckmaster_demo — branches demo/v2.7 and demo/v2.9

To be continued... (I hope, as usually)

19 Upvotes

16 comments sorted by

5

u/Capable-Nature5860 7d ago

Quick heads-up before the DB purists arrive with pitchforks:

Yeap, I am fully aware that the StockService.deduct() method in this version still uses in-memory math (stock_item.quantity -= used_part.quantity) instead of F() expressions or select_for_update().

As I mentioned in previous posts, I'm documenting this project strictly chronologically. This specific version (v2.7) was purely about surviving the Django signals nightmare and moving logic to a service layer. The actual atomic database-level fix for the race condition was implemented a bit later, and I'll show that exact SQL refactoring in the next post.

One architectural crisis at a time!

3

u/TheZikoss 6d ago

Thanks for sharing. I love to see more posts like this about tackling challenges after deployment.

2

u/Capable-Nature5860 6d ago

Thanks! Greenfield development is fun, but the real engineering always starts the day after deployment, when real users start using the system in ways you never anticipated. Glad it resonates!

2

u/Smooth-Zucchini4923 7d ago

Also reduced JWT access token lifetime from 12 hours to 15 minutes. 12 hours was lazy and insecure. If someone's token leaks, 15 minutes limits damage.

Not sure this matters. Presumably you have refresh tokens too, right? If so, and the refresh token is kept in the browser, stored in the same location as the access token, then it's irrelevant what the access token lifetime is unless you also reduce refresh token lifetime. Tokens are vastly more likely to be stolen from storage than from an SSL protected connection. The only thing that matters is max(access_token_lifetime, refresh_token_lifetime).

Remember the ALPR system from Part 3?

Neat. This has me thinking about if ALPRs could be used with any of my sites. Only a few of them deal with data about a specific location, though.

1

u/Capable-Nature5860 7d ago

Good point on JWT - you are right, if both tokens sit in the same storage then shorter access lifetime does not buy much on its own. Fair criticism, I should look into refresh token rotation and proper token storage separation. Thanks for pointing that out. Re ALPR - honestly any business, that has a physical location with a gate or parking could use it. The Django side is trivial (one POST endpoint + ignore list), hard part is the camera/recognition setup. But there are cheap off-the-shelf ALPR solutions now that just send webhooks so the barrier is mostly hardware, not code.

1

u/Witless-One 5d ago

Refresh tokens can be set to one time use, can be revoked, can only be used to generate an access token, and are harder to leak. There’s a reason their lifespan is longer than access tokens

2

u/StudyEasyOrg 5d ago

Really solid writeup, especially the signals-to-service-layer point, that's a lesson a lot of people learn the expensive way. One thing I'd push on gently: the RBAC layer. Checking role in ('admin', 'manager', 'storekeeper') inside each permission class works fine at 5 users, but it tends to get painful the moment you need a 6th role, or someone needs "storekeeper access but only for warehouse B." Django's built-in Group/Permission system (or django-guardian if you actually need object-level checks) handles that by letting you assign permissions to groups instead of hardcoding role-name tuples in Python, so adding a role is a DB/admin change instead of a code change and redeploy.

Not saying you made the wrong call, for a 5-person internal tool the simple version is honestly the right tradeoff and probably faster to reason about. Just curious whether you looked at Groups first and deliberately skipped it, or if the string-based roles predated the RBAC need and you built the smallest thing that worked.

1

u/Capable-Nature5860 5d ago

Honestly, it was second one - string-based roles came first as a quick UserProfile field, and when actual RBAC need appeared I just built permission classes on top of what was already there. I was aware of Groups but at that point migrating existing role assignments felt like more work than just writing 4 permission classes. You are right that it will get painful with a 6th role or object-level needs like 'storekeeper but only warehouse B'. If project grows to that point, Groups + django-guardian are probably where I will end up. For now the hardcoded tuples are easy to grep and understand, which matters when I am only one maintaining it. Good push though, appreciate the nuance - 'not the wrong call, but know what you are trading off' is exactly the right framing.

2

u/RandomPantsAppear 5d ago

The signals were invisible - new developer (me, three months later) would look at view code and have no idea that saving a UsedPart triggers stock changes. 

But an experienced Django developer would immediately look to the signals to see what happens when a UsedPart is created or deleted. What you are describing is just something you get used to as you learn the platform. 

What you are pursuing is an anti-pattern. 

  • Model behavior stays in models and signals tied to those models. 

  • Validation is handled in forms and serializers. 

  • Output is controlled by views. 

If you stay consistent, it is immediately evident where any specific behavior lives. 

Django has little room for classes outside models that modify model behavior. Those are the things no one knows to look for. Those are the things that future developers will struggle with. 

With Django, everything has its predictable place. That is what makes the magic work. 

1

u/Capable-Nature5860 4d ago

Fair point and I get the philosophy - if the team follows Django conventions consistently, signals are predictable place to look. No argument there. Where it fell apart for me was when the stock operation touches 3 models at once: StockItem (reduce quantity), StockMovement (create log), Product (recalculate total). That is not really 'model behavior' anymore - it is a business operation that coordinates across models. Putting that in a post_save signal on UsedPart meant one model was secretly orchestrating changes in three others. The service class is not hidden - it is imported and called explicitly in the view. A new developer reads the view and sees StockService.deduct(part) right there. With signals they see part.save() and have to know that saving triggers stock changes somewhere else. But I think this is genuinely one of those cases where both approaches are defensible and the right answer depends on the project size. For a small app with simple model-level side effects, signals are cleaner. For multi-model business operations, I prefer explicit calls. Agree to disagree maybe?

2

u/RandomPantsAppear 4d ago

No I think that is more of a fair use of the pattern. It’s a judgement call. Not how I would do it for sure, but valid. 

If multiple models are modified, the behavior could be correct. 

I would probably have CompanyStockModel as a model itself (a shell with m2m and fk) and have .deduct(part) live there instead of using signals (obviously it would have its own signals). Signals within the models could  trigger the 2nd level changes. 

But also I am backseat driving by this point. You probably know better than me with the full context. 

1

u/Capable-Nature5860 4d ago

That is actually a neat middle ground - keeping the logic on model but a dedicated one that owns the operation, not the UsedPart model itself. I like that. Thanks for the perspective!

2

u/RandomPantsAppear 4d ago

No problem! 

With that you can also easy gain a new endpoint that can dump basically the entire account. Useful for things like dashboards. 

2

u/berrypy 4d ago

I must say, you are progressing bit by bit. the signal part is a must. This is why I don't often recommend signals. It's a nightmare. Worse is if you import fixtures from Django load data management command.

This is where you will hate signals because you won't even notice the issues. your approach to the service layer is better. keep the features rolling..nice

1

u/Capable-Nature5860 4d ago

Thanks, berrypy! Yeah the loaddata + signals combo is horror story on its own. Glad I moved away from that before I had to deal with fixtures. Appreciate you following along the series!

1

u/Fartstream 6d ago

Less m dashes please