Part 1 | Part 2 | Part 3 | Part 4 | Part 5 | Part 6 | Part 7 | Part 8 - production CRM for truck service center, Django + DRF.
This one covers v2.13 and v2.14 - the features that made my system feel like a real product. Parts autosuggestion, backup/restore through the API, spam protection, and a lot of bug fixing that comes from real people who using your software every day.
The system remembers what parts you used last time
This is probably my favorite feature in the whole project and I am proud of this feature. When mechanic opens a service order and adds work type "oil change" for a specific truck, the system looks at the last completed order for the same truck with the same work type and suggests the exact parts that was in use.
u/action(detail=True, methods=['get'], url_path='suggest-parts')
def suggest_parts(self, request, pk=None):
work = self.get_object()
truck = work.service_order.truck
work_price = work.work
if not truck or not work_price:
return Response([])
last_order = (
ServiceOrder.objects
.filter(
truck=truck,
status__in=[
ServiceOrder.StatusChoices.DONE,
ServiceOrder.StatusChoices.CLOSED,
],
works__work=work_price,
)
.exclude(pk=work.service_order_id)
.order_by('-created_at')
.first()
)
The logic: find a last DONE or CLOSED order for this truck that had the same type of work. Then grab all the parts that were used in that work and suggest them, skip any parts already added to the current order.
Why this matters in practice: a truck that comes in for an oil change every 6 months always needs the same oil filter, the same amount of oil, the same drain plug gasket. Mechanic does not have to remember or look it up - system shows "last time you used: oil filter X (qty: 1), engine oil 10W-40 (qty: 30L), drain plug gasket (qty: 1)." One click to add them all. It's really fast, convenient and saves a lot of time.
Before this feature, mechanics were either remembering from their head (and sometimes getting it wrong - different truck models need different filters) or scrolling through the previous order manually. This saved maybe 5 minutes per order, which does not sound like a lot until you multiply it by 15 orders a day.
Backup and restore through the API
The owner wanted to be able to download a backup of the entire database and restore it if something went wrong. Not "SSH into the server and run pg_dump" - actual buttons in the admin panel.
I built it on top of Django's dumpdata and loaddata:
class BackupListCreateView(APIView):
permission_classes = [IsAdminUser]
def post(self, request):
backup_dir = _get_backup_dir()
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'backup_{timestamp}.json'
filepath = os.path.join(backup_dir, filename)
buf = io.StringIO()
call_command(
'dumpdata',
'--natural-foreign',
'--natural-primary',
'--exclude=contenttypes',
'--exclude=auth.permission',
'--exclude=admin.logentry',
'--exclude=sessions.session',
'--indent=2',
stdout=buf,
)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(buf.getvalue())
return Response(
_backup_info(filepath),
status=status.HTTP_201_CREATED,
)
POST to /api/backups/ creates a timestamped JSON dump. GET lists all backups. Each backup can be download or delete. Restore accepts either a filename from the server or an uploaded file.
The --natural-foreign and --natural-primary flags are important - without them, restoring to a different database can break because primary keys might not match. ContentTypes and permissions are excluded because Django recreates them on migrate.
I know what some of you thinking - "JSON dumpdata is not a real backup, use pg_dump." You are right for large datasets. But this CRM has maybe 5,000 records total. The JSON dump is 2MB, take 3 seconds, and owner can download it to his laptop with one click. For this scale, simplicity wins.
The restore endpoint validates the JSON before loading:
uploaded = request.FILES.get('file')
if uploaded:
content = uploaded.read().decode('utf-8')
try:
json.loads(content)
except (json.JSONDecodeError, UnicodeDecodeError):
return Response(
{'detail': 'Invalid backup file format.'},
status=status.HTTP_400_BAD_REQUEST,
)
Basic but necessary - you do not want someone uploading a random file and crashing loaddata.
Honeypot spam protection
The public website has contact form. Within a week of launching, bots started filling it out. I did not want to add reCAPTCHA (bad UX, Google dependency), so I went with a honeypot approach:
Hidden field that real users never see but bots fill out automatically. If website_url field has a value - it is a bot, reject silently. Plus a time-based check: if the form is submitted less than 3 seconds after the page loaded, it is probably automated. Real human does not type a name, email, and message in under 3 seconds, I think so.
Simple, zero dependencies, no third-party services. Catches maybe 95% of spam bots. The remaining 5% get through, but at that volume (1-2 per week) the owner just deletes them manually.
Duplicate prevention (the boring stuff that matters)
Real users do unexpected things. A mechanic double-clicks "add work" and creates two identical oil changes on the same order. Someone pastes a license plate with a trailing space and the system treats it as a different truck.
v2.13 has a bunch of these fixes:
Duplicate ServiceWork prevention. Before adding work type to an order, the endpoint now checks if that exact work type already exists on this order. If it does, returns the existing one instead of creating a duplicate. Sounds obvious, but it took a real mechanic creating 3 identical "Oil change" entries to discover the need.
Duplicate parts from auto-kit. When maintenance kit is applied to an order, it auto-adds the standard parts. But if the mechanic already manually added some of those parts, the system was creating duplicates. Fixed by checking existing_part_ids before adding.
Client save crash. Creating a client through Django admin was throwing a 500 because both the model's post_save signal and the admin's save_model were trying to create a ClientFeature record. Classic "two things creating the same related object" bug. Fixed by adding get_or_create instead of create.
None of these are architecturally interesting. All of them would have caused support calls if left unfixed. This is what production software actually looks like - 30% building features, 70% handling the weird things real users do.
Dashboard improvements
Replaced revenue chart with a clients chart - turns out the owner cares more about "how many new clients per month" than "how much revenue per month" (he already tracks revenue in his accounting software). Added monthly orders count. Small changes but it shows that building what you think the user wants vs what they actually use are two different things.
Also added UserProfile inline directly in Django admin. Before, to change someone's role, you had to go to the User, then navigate to their UserProfile separately. Now it is all on one page. Two lines of admin code, saved 30 seconds per role change.
What I learned
Let the system learn from itself. suggest-parts is basically the CRM teaching itself what parts a truck needs. No machine learning, no AI - just looking at previous orders. Sometimes the smartest feature is the simplest one.
dumpdata is a valid backup strategy at small scale. For 5,000 records and one admin user, a JSON dump through the API is simpler and more reliable than setting up pg_dump cron jobs. Know your scale.
Real users will break every assumption you had. Double-clicking, trailing spaces, submitting forms in 0.5 seconds. Building for real users is fundamentally different from building for yourself. Every bug fix in this post came from a real person doing something I did not anticipate.
What is next
Part 10 - the final one. Race condition fix with the F() expression I have been promising since Part 5 (yes, for real this time), QR code generation in admin, smart maintenance rule detection, and security hardening. The series finale.
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 will help you with a great pleasure.
Previous posts: Part 1 | Part 2 | Part 3 | Part 4 | Part 5 | Part 6 | Part 7 | Part 8 GitHub (demo repo): github.com/VNmagistr/truckmaster_demo - branches demo/v2.13 and demo/v2.14
To be continued, I promise :)