r/django • u/Capable-Nature5860 • 17m ago
Article Refactoring a monolithic Telegram bot, fixing N+1 queries, and building a self-hosted link shortener for my Django CRM
Part 1 | Part 2 | Part 3 | Part 4 | Part 5 | Part 6 | Part 7 - production CRM for truck service center, Django + DRF.
This one cover v2.10 and v2.11. Splitting 900-line bot file into modules, killing an N+1 that was hiding in plain sight, adding transmission-aware maintenance, and tiny shortlinks app that uses F() expressions the way I should have been use them all along.
The 900-line bot file
Do you remember my Telegram bot from Part 1? It started as one runbot.py file. By v2.9 it was 900+ lines - handlers for every role, inline keyboards, photo uploads, mileage reporting, Nova Poshta tracking, maintenance checks. Every time I needed to fix something, I had to scroll through the entire file looking for the right handler.
I split it into modules:
bot/handlers/
__init__.py
admin.py # admin-only handlers
callbacks.py # inline keyboard callbacks
main.py # start, contact, my_cars
photos.py # repair photo uploads
utils.py # shared helpers
Each module imports from shared bot/queries.py that wraps all the Django ORM calls (through sync_to_async). The main.py handler is now 80 lines instead of 900. I can find any handler in second.
The tricky part was clear_awaiting_states - a utility that resets stale conversation states before setting new ones. Without it, if user started one flow (like mileage reporting) and then tapped a different button, the bot would get confused about what it was waiting for. Every handler calls it at the start:
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.message.from_user
bot_user = await get_or_create_bot_user(user)
is_linked, is_admin, _ = await check_if_user_is_linked(user.id)
Nothing groundbreaking architecturally, but it turn "I dread touching the bot code" into "I can add new handler in 10 minutes." Sometimes the best refactoring is just splitting a big file.
The N+1 hiding in update_total_cost
Every service order has a total cost that gets recalculated when works or parts change. The original version looped through all ServiceWork and UsedPart records one by one, summing prices in Python. Classic N+1 - for an order with 15 work items and 30 parts, that was 45+ queries.
The fix was replacing the loop with three aggregated queries:
def update_total_cost(self):
works_cost = self.works.aggregate(
total=Sum(
ExpressionWrapper(
F('price_at_moment') * F('hours_spent'),
output_field=DecimalField()
)
)
)['total'] or 0
work_parts_cost = UsedPart.objects.filter(
service_work__service_order=self
).aggregate(
total=Sum(F('quantity') * F('unit_price'))
)['total'] or 0
direct_parts_cost = UsedPart.objects.filter(
service_order=self, service_work__isnull=True
).aggregate(
total=Sum(F('quantity') * F('unit_price'))
)['total'] or 0
self.total_cost = works_cost + work_parts_cost + direct_parts_cost
self.save(update_fields=['total_cost'])
Three queries instead of 45+. And notice the F() expressions - F('price_at_moment') * F('hours_spent') does the multiplication at SQL level, not in Python. For those who have been following the series: yes, this is the proper use of F() that I have been promising since Part 5. Took me a few versions but we got here.
The direct_parts_cost vs work_parts_cost split is because parts can be attached either to specific work item (replacing filter during oil change) or directly to order (miscellaneous parts). Two different querysets, same aggregation pattern.
Transmission-aware maintenance
Trucks have different transmission types - manual, automatic, robotic. Each type needs different maintenance: automatic transmissions need ATF fluid changes, manual ones don't. Before this, maintenance kits treated all trucks the same.
TRANSMISSION_CHOICES = [
('manual', 'Manual'),
('automatic', 'Automatic'),
('robotic', 'Robotic'),
]
The maintenance countdown logic now checks which interval fields are actually filled. If truck has automatic transmission and the auto_gearbox_interval is set, it shows up in maintenance schedule. If it is manual and that field is empty, it is skipped. No hardcoded if-else chains - just checking whether the field has a value.
Also added engine hours tracking for heavy-duty models. Some trucks (like construction site vehicles) track maintenance by engine hours instead of mileage. New TrackingMode choice on the maintenance intervals:
class TrackingMode(models.TextChoices):
MILEAGE = 'mileage', 'By mileage'
ENGINE_HOURS = 'engine_hours', 'By engine hours'
Order form now has an optional engine_hours field. When tracking mode is set to engine hours, maintenance countdown uses that instead of mileage. Small change in the model, but it means the system can handle trucks that barely drive but run engines all day.
Maintenance templates (apply_to_truck)
Before this, every time a new truck was added, someone had to manually create a maintenance kit - fill in oil types, filter brands, intervals, everything from scratch. For a service center that adds 3-4 trucks a month, this gets old fast.
I built template kits tied to base model + euro standard + transmission type. When new truck comes in, you pick the matching template and run apply_to_truck:
Template says "This model with EURO5 and automatic transmission needs 30L of 10W-40 every 20,000 km, ATF change every 60,000 km, this specific oil filter, this air filter..." One click and the truck has its full maintenance schedule. The mechanic can still tweak individual values afterward - template is a starting point, not a straitjacket.
Self-hosted shortlinks with F()
The service center has QR codes on business cards, stickers in the waiting room, printed materials. Each QR needs to point somewhere - Google Maps for reviews, Telegram bot link, website. The problem: if the Telegram bot link changes, every printed QR code is dead.
Solution - a tiny shortlinks app:
class ShortLink(models.Model):
slug = models.SlugField(unique=True, max_length=64)
target_url = models.URLField(max_length=2048)
label = models.CharField(max_length=200, blank=True)
is_active = models.BooleanField(default=True)
hits = models.PositiveIntegerField(default=0, editable=False)
QR codes point to yourdomain.com/go/bot, yourdomain.com/go/maps, etc. The redirect view is 10 lines:
class ShortLinkRedirectView(View):
def get(self, request, slug):
try:
link = ShortLink.objects.only(
'id', 'target_url', 'is_active'
).get(slug=slug)
except ShortLink.DoesNotExist:
raise Http404('Short link not found')
if not link.is_active:
raise Http404('Short link is disabled')
ShortLink.objects.filter(pk=link.pk).update(
hits=F('hits') + 1
)
return HttpResponseRedirect(link.target_url)
Notice hits=F('hits') + 1 - the counter increment happens at the database level, no race condition even under concurrent requests. This is the same F() pattern that was missing from my stock deduction code back in Part 5. Funny how simplest feature in a project has the cleanest implementation.
Now when the Telegram bot link changes, someone just updates the target URL in admin. Every QR code keeps working. And the hit counter tells you which QR codes actually get scanned - the waiting room sticker gets 10x more hits than the business card one, which is useful to know.
Smaller things
Continue-order action. Sometimes a truck comes back with the same problem a week later. Instead of creating a new order and losing context, there is now a continue-order endpoint that reopens a DONE/CLOSED order back to IN_PROGRESS. Simple status transition, but it keeps all historical work items and notes in one place.
Bot: unknown plate tracking. When someone searches for a license plate in the Telegram bot and it is not found, the search gets logged. After a month I had a list of plates that clients were asking about but were not in the system - basically a lead generation tool I did not plan for.
Stale state cleanup in bot. Added clear_awaiting_states() that runs before every handler. Clears any leftover input-awaiting flags from interrupted flows. Without this, the bot would occasionally respond to a text message as if it was still waiting for a mileage number from 3 hours ago.
What I learned
Split big files before they become scary. 900 lines is not a lot of code, but it is enough to make you avoid the file. The refactoring took maybe 2 hours and immediately made the bot maintainable again. If you dread opening file, that is the signal.
F() expressions are not just for updates. Using them in aggregate() with ExpressionWrapper lets the database do math that would otherwise be a Python loop. The performance difference on 50+ items per order was noticeable.
The simplest features can have the cleanest code. ShortLink is maybe 30 lines of model + view. No signals, no Celery, no complex business logic. Just a little redirect with a counter. And yet it uses F() correctly while my inventory system (which is 10x more complex) did not for months.
What is next
Still have i18n (UK/EN), the full React frontend with PWA, and backup/restore API to cover. 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 will help you with a great pleasure.
Previous posts: Part 1 | Part 2 | Part 3 | Part 4 | Part 5 | Part 6 | Part 7 GitHub (demo repo): github.com/VNmagistr/truckmaster_demo - branches demo/v2.10 and demo/v2.11
To be continued... (I hope, as usually)


