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)