THE IDEA
I did not want to build another basic to-do app or a blog. I wanted something with real users, real authentication, real user-generated content, moderation, and a database that actually mattered.
That idea became Vote Chai Lahore. A simple concept: help people find the best chai spots in Lahore, through the community itself. Users can discover chai spots, search and filter them, vote for their favorites, leave a rating and a review, submit new spots, and check their own activity on a profile page. Admins review new submissions before they go public.
The stack I chose was Django and Python for the backend, SQLite for the database during development, plain HTML and CSS with Django Templates for the frontend, Cloudflare Turnstile and Django Axes for security, and Gmail SMTP for sending emails.
I kept the frontend simple on purpose. I was not trying to learn React or fancy JavaScript right now. I was trying to actually understand a backend framework properly, so I gave that my full attention.
THINKING BEFORE CODING
Before touching Django at all, I forced myself to think like an engineer instead of jumping straight to django-admin startproject. I asked basic questions first. Who is going to use this app. What can a guest do. What can a logged in user do. What can an admin do. Then I broke the project down into functional requirements, which is simply what the software should do, like register, login, vote, review, add a spot, view a leaderboard. And separately, non functional requirements, which are not features but qualities, like being fast, secure, and reliable. Turnstile, rate limiting, password hashing, and image validation all fall under this second category.
I also thought about the data before writing a single model. A user creates chai spots. A chai spot can have many reviews and many votes. A review belongs to one user and one spot. Once I had this relationship clear in my head, writing the actual models later became easy, because I was just translating a picture I already understood into code.
I also planned the page structure before writing HTML. Home page, chai spots page, spot detail page, leaderboard, login, register, profile, add spot. Then I planned a rough content flow for the homepage itself: header first, then a hero section, then how it works, then chai of the week, then top spots, then a call to action, then footer. Thinking about structure and meaning first, and worrying about spacing and colors later, made a huge difference. HTML describes what something is. CSS decides how it looks. Mixing those two ideas together in your head early just creates confusion.
BUILDING THE STRUCTURE, PIECE BY PIECE
I started with pure HTML and CSS, no Django yet, just to get the page skeleton right. Once that felt solid, I moved to Django step by step: project setup, then urls, then views, then templates, then the header, then each section of the homepage one at a time, then styling, then models, then the admin panel, then CRUD operations, then voting, then reviews, then the leaderboard.
Doing it in small pieces like this actually saved me. When something broke, I usually knew roughly which piece I had just touched, so I did not have to search the entire project to find the bug.
THE FIRST REAL BUG, AND A GOOD LESSON IN DEBUGGING
At one point my spots page loaded, the server responded with a 200 status meaning success, but the page was completely blank. That was confusing, because 200 usually means everything worked.
I debugged it the proper way, one layer at a time. First I swapped render for a plain HttpResponse just to prove the view itself was working. It was. So the problem was not in my view or my database query. Next I replaced the real spots.html with a tiny, almost empty template. That worked too. So the bug had to be somewhere inside the template rendering, specifically related to base.html, since spots.html extended it.
The strange part was that home.html also extended base.html, and home.html worked perfectly fine. After a lot of digging, I found it. A single missing quotation mark inside base.html. Home.html happened to never execute the exact line where that mistake lived, so it never broke. Spots.html did.
The real lesson here was that a working status code does not mean a working page. When something fails silently, the fastest way to find it is to simplify things until they work, then slowly add complexity back until it breaks again. That is how you find the exact point of failure instead of guessing randomly.
A SMALL FIX THAT TAUGHT ME SOMETHING BIGGER
In the Django admin panel, every chai spot showed up as ChaiSpot object 1, ChaiSpot object 2, and so on, instead of the actual name I had typed in. Nothing was wrong with my data. Django simply did not know how I wanted an object to be displayed as text.
The fix was one small method inside the model.
def str(self):
return self.name
That single method tells Django, whenever you need to show this object as plain text, use its name. No migration needed, just save and refresh. It looked like a tiny cosmetic fix, but it taught me something important about how Django objects represent themselves, which helped later when I was reading data in templates and the admin panel.
UNDERSTANDING WHY approved EQUALS FALSE MATTERED
Early on I added a simple boolean field to the chai spot model called approved, set to False by default. A user submits a chai spot, it gets stored with approved as false, an admin reviews it in the admin panel, and only after approval does it show up publicly on the site.
This felt like a tiny detail at the time, but it turned into one of the more important system design decisions in the whole project. I asked myself what happens if this app becomes popular and hundreds of spots get submitted every day. One human cannot manually check that many entries forever. That question led me to think about how big platforms actually handle this at scale, using automatic validation first, duplicate detection, image checks, user reputation over time, and community reporting, with a human only stepping in for the difficult cases. I did not build all of that for this project, but understanding why it would eventually be needed was one of the most valuable things I took away from this build.
ADDING SECURITY, AND LEARNING IT IS NEVER JUST ONE LAYER
I added Cloudflare Turnstile to the registration form to stop bots from creating fake accounts. It displayed correctly in the browser, and at first I thought that was enough.
It was not. A widget showing up in a browser proves nothing by itself. Someone could skip the browser entirely and send a request straight to the register endpoint using a script or a tool like Postman, completely bypassing the widget. The real protection has to happen on the backend, by sending the token Cloudflare gives the browser back to Cloudflare for verification, and only creating the account if that verification succeeds.
def verify_turnstile(token):
response = requests.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={
"secret": settings.TURNSTILE_SECRET_KEY,
"response": token,
}
)
result = response.json()
return result.get("success", False)
I only trusted this once I actually saw success equals true printed out from Cloudflare's own response, not just the widget rendering nicely on screen. That became a rule I now apply everywhere. Never trust anything that only happens on the client side. Frontend checks are for user experience. Backend checks are for actual security.
I also learned that one security feature is never the whole picture. Turnstile stops bots at signup, but a user who already has an account could still automate requests using a script. That is a different problem, and it needs a different layer, which is exactly why I also added Django Axes, rate limiting on sensitive actions, duplicate detection on chai spot submissions, and admin approval as a final safety net. Each layer catches something the others cannot.
THE BUG THAT ONLY HAPPENED ON REGISTRATION, NOT LOGIN
After installing Django Axes for login protection, my settings file ended up with two authentication backends instead of one. Login kept working fine, but registration suddenly broke with an error about multiple authentication backends being configured.
The reason was subtle. When a user logs in normally, Django's authenticate function decides which backend verified them and quietly stores that information on the user object, so the later login call knows what to do. But in my registration view, I was creating the user directly with create_user and then calling login immediately after. That freshly created user had never gone through authenticate, so Django had no idea which backend to use to log them in, and it refused to guess.
The fix was to explicitly authenticate the user right after creating them, before logging them in.
user = User.objects.create_user(
username=username,
email=email,
password=password,
)
user = authenticate(
request,
username=username,
password=password,
)
login(request, user)
This is actually the same pattern used by production systems everywhere, create the user, authenticate the user, then log them in. It also means the code stays compatible later if I ever add something like Google login.
THE MYSTERY REDIRECT TO A PAGE THAT DID NOT EXIST
After a successful login, users were being sent to a URL called accounts profile, which returned a 404 error, even though my actual profile page lived at a completely different address.
I had not written that URL anywhere in my project. It turned out Django's built in login view has a default behavior. If you do not explicitly tell it where to send a user after a successful login, it assumes accounts profile automatically. Since my project never defined that path, it broke.
The fix was adding two lines to settings.
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "profile"
LOGIN_REDIRECT_URL tells Django exactly where to send someone right after they log in successfully. LOGIN_URL tells the login required decorator where to send someone who tries to open a protected page while not logged in, instead of falling back to a default guess. The bigger lesson here was that when something redirects you somewhere you never coded, it is almost always a framework default quietly doing its own thing, not a bug you introduced.
THE EMAIL THAT NEVER ARRIVED
I built out Django's password reset flow, and every page displayed correctly. The final page even said check your email, we have sent you a reset link. Except no email ever showed up, even though the account definitely existed.
The reset logic itself was completely correct. What was missing was an actual configured email backend. Django does not send real emails out of the box, it needs to be told exactly which mail server to use. I configured Django's SMTP backend using Gmail, and used a Gmail app password instead of my normal account password, since Gmail requires that for SMTP access from outside apps.
This taught me something that applies far beyond just Django. A feature can be one hundred percent correct in your code and still completely fail, because it depends on an external service that has not been set up properly yet. Whenever something looks broken, check the plumbing outside your code before assuming your logic is wrong.
A SMALL DEFENSIVE CODING HABIT
At one point I was pulling form data like this.
username = request.POST.get("username").strip()
This works fine as long as the form always sends a username. But a real user going through the actual HTML form is not the only way data can arrive at your server. Someone could send a request directly using a script or a tool, skipping your form entirely, and simply not include a username field at all. In that case, get returns None, and calling strip on None crashes the server with an error.
The safer version provides a default.
username = request.POST.get("username", "").strip()
It is a tiny change, but it reflects a bigger mindset. Never assume incoming data will always look the way your own frontend form would send it. Someone outside your website is never forced to use your form.
WHY DEBUG TRUE OR FALSE IS SUCH AN IMPORTANT LINE
This single setting turned out to be one of the most powerful lines in the entire project, and it connects to a few different bugs I hit.
While DEBUG is set to True, Django shows detailed error pages with tracebacks, which is extremely useful while you are developing, but is dangerous to expose to real users, since it reveals internal details about your code and your server.
I had also built custom 404 and 500 error pages, and for a while I could not understand why they were not showing up. The answer was simple once I found it. Django only uses your custom error templates when DEBUG is set to False. While DEBUG stays True, Django always shows its own internal debug page instead, since that page is meant for developers, not for real visitors. The moment I flipped DEBUG to False as one of my final production steps, my custom error pages finally appeared exactly as intended.
That one setting is really the line between a development mindset and a production mindset. Leaving DEBUG as True in a live application is a common and serious mistake, because it can leak your secret key, your file paths, your installed packages, and other internal details to anyone who triggers an error on purpose.
KEEPING SECRETS OUT OF THE CODE
Early on, my settings file had my secret key, my Gmail app password, and my Turnstile secret key all written directly inside it as plain text. That is fine while a project lives only on your own computer, but it becomes a real problem the moment that code gets pushed to a public place like GitHub, since anyone could read those values directly.
The fix was moving all of that into a separate file called dot env, and reading values from it using a small library called python decouple.
from decouple import config
SECRET_KEY = config("SECRET_KEY")
EMAIL_HOST_PASSWORD = config("EMAIL_HOST_PASSWORD")
TURNSTILE_SECRET_KEY = config("TURNSTILE_SECRET_KEY")
The dot env file itself never gets uploaded to version control. This is the exact same pattern real companies use to separate configuration and secrets from the actual application code, and it was the moment my project started feeling less like a school assignment and more like something built with production in mind.
A SEARCH FEATURE THAT LOOKS SIMPLE BUT HAS REAL LIMITS
My search used Django's icontains lookup across the name, area, and description fields. It works well for exact partial matches, but it does not understand typos. Searching for coffe will not find coffee, because the database is only checking whether one string literally contains another, it is not actually understanding language.
That is the real difference between basic database filtering and an actual search system. At a bigger scale, tools like PostgreSQL full text search, Elasticsearch, or similar systems handle ranking, typo tolerance, and relevance properly. For a project of this size, icontains was a completely reasonable choice, but the important thing was recognizing exactly where its limits are, instead of assuming it behaves like a real search engine.
STEPPING BACK AND LEARNING SYSTEM DESIGN FROM MY OWN PROJECT
Once the app was actually working, I started asking a different kind of question. What happens if this becomes popular. That single question changed how I looked at everything I had already built.
I learned that every single request in Django follows a fixed journey. It starts at the browser, passes through middleware which handles security, sessions, authentication, and CSRF checks before your own code even runs, then goes through the URL resolver which decides which view should handle it, then the view itself which contains your business logic, then the model layer which talks to the database through the ORM, then back up through a template which turns your data into HTML, and finally back to the browser as a response. If a CSRF token is missing, or a login check fails, the request can get stopped by middleware before your view logic ever runs at all.
I also learned that Django naturally separates an application into three layers, sometimes called MVT. The presentation layer is your templates, your HTML and CSS, and its only job is to display data, never to touch the database directly. The business layer is your views, containing your actual rules, like checking if two passwords match, checking if an email already exists, or verifying a Turnstile token. The data layer is your models and the ORM, whose only job is talking to the database. Keeping these three responsibilities separate means changing how a page looks never risks breaking your database logic, and changing a business rule never risks breaking your template. This idea is called separation of concerns, and it is the same underlying idea used in massive production systems, just with more layers added on top as the system grows, like caching, background workers, and load balancers.
I also stopped thinking about scaling as randomly adding trendy tools. Every piece exists to solve one specific problem. A load balancer exists because a single server eventually cannot handle enough traffic alone. Redis exists to avoid recalculating the same expensive query, like a leaderboard, on every single request. Moving from SQLite to PostgreSQL exists because a real production workload needs a database built to handle many simultaneous connections properly. Background workers exist to move slow tasks, like sending an email, out of the main request response cycle so users are not left waiting. Understanding why each piece exists mattered far more to me than memorizing their names.
EVEN GIT GAVE ME A REALITY CHECK
At one point I ran git status and got an error saying it was not a git repository at all. The cause was almost embarrassingly simple. I was just sitting in the wrong folder on my computer. Once I moved into the actual project directory, everything worked immediately.
Small as it was, it reinforced a habit I now use constantly. Before assuming a tool is broken, check your current environment first. Half of debugging is just making sure you are actually looking at the thing you think you are looking at.
WHAT I WOULD IMPROVE NEXT
The project works, and I am proud of it, but I do not think of it as finished. If I were pushing this toward real production, here is the order I would tackle things in. Move the database from SQLite to PostgreSQL. Add a proper search system once the amount of data grows. Add Redis caching for expensive queries like the leaderboard. Move slow tasks like sending emails into background workers instead of the main request cycle. Move uploaded images into proper object storage instead of local disk. Deploy behind a real production server setup instead of Django's built in development server. And add proper logging and monitoring, so I actually find out when something breaks instead of a user telling me first.
WHAT I ACTUALLY LEARNED
The biggest lesson from this whole project was not simply that I learned Django. It was that I learned how to actually think about building software.
I learned to ask who is allowed to do this action. Where should this specific validation actually live. What happens if a user sends bad or missing data. What happens if an external service, like an email server, quietly fails. Where should secrets live, and who should never be able to see them. Which layer does this piece of logic really belong to. And probably most importantly, how do I actually debug something once it breaks, instead of guessing randomly.
Those questions matter far more than memorizing Django syntax, because syntax you can always look up in five seconds. Knowing what to check first when something goes wrong is the part that actually stays with you.
I did not just build a chai voting website. I learned how to think like a software engineer, one small bug at a time. And that is the part of this project I am going to carry forward into everything I build next.
Top comments (0)