Project 2: Commerce
Commerce - CS50's Web Programming with Python and JavaScript (harvard.edu)
Table of Contents
> ERD for the Commerce Project
- ERD (Text)
- Core Entities and Relationships
> Model Layer (models.py)
- Design Approach
- User & Watchlist (ManyToMany)
- Listing Model
- Bid Model
- Comment Model
- Migration Issue (AutoField Warning)
- Testing Models (Shell & Admin)
> View Layer & Templates
- Overview of Required Views
- Active Listings Page (index view)
- Listing Detail (listing_detail view)
- Create Listing
- Watchlist Page
> Testing & System Validation
- Database Reset
- Test Setup
- Create Listing
- Listing Detail Verification
- Bidding System
- Comment System & Timezone Issue
- Watchlist Feature
- Categories
- Closing Auction & Winner Logic
- Accessing Inactive Listings
- Final State After Fixes
> UI/UX Improvements and Frontend Refactoring
- Consistent Listing Preview Across Pages
- Component-Based UI: Partial Template
- Bootstrap Grid System for Responsive Layout
- Critical Layout Bug: Nested Grid Issue
- Listing Detail Page Redesign (2-Column Layout)
- UX Enhancements
- Global Styling Strategy
- Layout & Spacing System
- Navbar & Page Structure
- Debugging: Browser Cache Issue
- Final Thoughts
- ERD (Text)
- Core Entities and Relationships
> Model Layer (models.py)
- Design Approach
- User & Watchlist (ManyToMany)
- Listing Model
- Bid Model
- Comment Model
- Migration Issue (AutoField Warning)
- Testing Models (Shell & Admin)
> View Layer & Templates
- Overview of Required Views
- Active Listings Page (index view)
- Listing Detail (listing_detail view)
- Create Listing
- Watchlist Page
> Testing & System Validation
- Database Reset
- Test Setup
- Create Listing
- Listing Detail Verification
- Bidding System
- Comment System & Timezone Issue
- Watchlist Feature
- Categories
- Closing Auction & Winner Logic
- Accessing Inactive Listings
- Final State After Fixes
> UI/UX Improvements and Frontend Refactoring
- Consistent Listing Preview Across Pages
- Component-Based UI: Partial Template
- Bootstrap Grid System for Responsive Layout
- Critical Layout Bug: Nested Grid Issue
- Listing Detail Page Redesign (2-Column Layout)
- UX Enhancements
- Global Styling Strategy
- Layout & Spacing System
- Navbar & Page Structure
- Debugging: Browser Cache Issue
- Final Thoughts
> Result & What I Learned
ERD for the Commerce Project
ERD (Text)
User
│
├──< Listing (1:N) [created by]
│ ├── title
│ ├── description
│ ├── starting_bid
│ ├── image_url
│ ├── category
│ ├── is_active
│ ├── created_at
│ └── owner (FK → User)
│
│ ├──< Bid (1:N)
│ │ ├── amount
│ │ ├── created_at
│ │ ├── bidder (FK → User)
│ │ └── listing (FK)
│ │
│ └──< Comment (1:N)
│ ├── content
│ ├── created_at
│ ├── author (FK → User)
│ └── listing (FK)
│
└───<───>─── Listing (M:N) [Watchlist]
Core Entities and Relationships
1. User and Listing (1:N)
A User can create multiple auction listings, but each Listing is owned by exactly one user.
> Key attributes of Listings
- title
- description
- starting_bid
- image_url (optional)
- category
- is_active (used to filter active listings per category)
- created_at (used for sorting by newest)
- owner (ForeignKey -> User)
> This one-to-many relationship reflects the idea that listings are authored and managed by a single owner, who is also the only user allowed to close the auction.
2. Listing and Bid (1:N)
Each Listing can have multiple Bids, while each Bid belongs to exactly one listing.
> Key attributes of Bid:
- amount
- created_at
- bidder (ForeignKey -> User)
- listing (ForeignKey -> Listing)
> This structure allows 1. Multiple users to compete on the same listing / 2. Tracking bid order by timestamp / 3. Identifying the highest bid for a listing
3. Listing and Comment (1:N)
Each Listing can have multiple Comments, but each Comment belongs to one listing.
> Key attributes of Comments:
- content
- created_at
- author (ForeignKey -> User)
- listing (ForeignKey -> Listing)
> This ensures that comments are always tied to a specific listing, and any update to comments is immediately reflected on the listing detail page.
4. User and Watchlist (Many-to-Many)
The Watchlist is implemented as a ManyToMany relationship between User and Listing.
This design choice is intentional. The watchlist does not represent a standalone entity with its own attributes. Instead, it simply expresses the relationship: "User A is watching Listing B". Because the watchlist has no name, creation date, sharing or ownership data. '
Summary: ERD
- Ownership (User -> Listing)
- Competition (Listing -> Bid)
- Discussion (Listing -> Comment)
- Interest tracking (User <-> Listing, via Watchlist)
> This ERD is expressed in a textual form rather than a formal diagram notation. Relationship cardinalities(1:N, M:N) and ownership are emphasized over optionality and visual notation, as the goal is to explain the conceptual data model behind the Commerce project.
Model Layer (models.py)
Design Approach
When designing Django models, I ask the following questions for each entity:
1. Can this entity exist on its own?
2. Does it necessarily belong to another entity?
3. Does it represent data, or merely a relationship between data?
- These questions directly determine whether a model becomes an independent table, which fields it must contain, whether it should reference other tables using ForeignKey or ManyToManyField
| Entity | Can exist independently? | Relationship type |
|---|---|---|
| User | Yes | Base entity |
| Listing | No (must belong to a User) | ForeignKey |
| Bid | No (must belong to User + Listing) | ForeignKey ×2 |
| Comment | No (must belong to User + Listing) | ForeignKey ×2 |
| Watchlist | No (pure relationship) | ManyToMany |
User & Watchlist (ManyToMany)
A User can watch multiple listings, and a Listing can be watched by multiple users. The watchlist itself doesn't have its own attributes, such as name or creation date, so it is modeled as a pure relationship, not a standalone entity.
class User(AbstractUser):
#A user can watch multiple listings
#A listing can be watched by multiple users
watchlist = models.ManyToManyField(
"Listing",
blank=True,
related_name="watchers"
)
Why ManyToManyField?
The watchlist represents "User A is watching Listing B". Since no additional metadata is required, introducing a separate Watchlist model would be unnecessary.
> A ManyToManyField is therefore the correct and simplest representation.
- "Listing": Passed as a string to avoid circular imports because User is defined before Listing.
- blank=True: Allows users to have an empty watchlist
- related_name="watchers": Enables reverse access
- user.watchlist.all() -> All listings a user is watching
- listing.watchers.all() -> All users watching a listing
Listing Model
A Listing represents a single auction item. It cannot exist without an owner, so it must reference a User.
class Listing(models.Model):
#Each listing can have multiple bids and comments
title = models.CharField(max_length=80)
description = models.TextField()
starting_bid = models.DecimalField(max_digits=10, decimal_places=2)
image_url = models.URLField(blank=True)
category = models.CharField(max_length=50, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
owner = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="listings"
)
def __str__(self): return self.title
def __str__(self):
return self.title
Fields
- title: Short, length-limited string -> CharField
- description: Unrestricted text -> TextField
- starting_bid: Monetary value -> DecimalField (To prevent floating-point precision errors)
- image_url: External image reference -> URLField / blank=True -> optional)
- category: Short label (Fashion, Toys..) -> CharField / blank=True -> optional)
- is_active: Auction state(True/False) -> BooleanField / Used to filter active listings and category-specific active listings
- created_at: Creation timestamp -> DateTimeField / Records the initial creation time and does not change on updates -> auto_now_add=True (auto_now: update)
> The owner field establishes a User -> Listing = 1:N relationship
- on_delete=models.CASCADE: Ensures database integrity. A listing without an owner is meaningless. If a user is deleted, all their listings are deleted.
- related_name="listings": Enables listing.owner -> the user who created the listing / user.listings.all() -> all listings created by a user
Bid Model
Each Bid represents a single a single bidding action and is stored as its own database record. A bid cannot exist without a User(a bidder) and a Listing(the auction item)
class Bid(models.Model):
amount = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
bidder = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="bids"
)
#Listing on which the bid was placed
listing = models.ForeignKey(
Listing,
on_delete=models.CASCADE,
related_name="bids"
)
def __str__(self): return f"{self.amount} by {self.bidder}"
def __str__(self):
return f"{self.amount} by {self.bidder}"
Why Two Foreign Keys?
A bid must answer two questions: Who placed the bid? and on Which listing was the bid placed?
> This leads to two ForeignKey fields.
- bidder
- Forward: bid.bidder
- Reverse: user.bids.all() -> all bids made by a user
- listing
- Forward: bid.listing
- Reverse: listing.bids.all() -> all bids on a listing
> Using related_name="bids" in both relationships allows querying bids naturally from both perspectives.
Deletion Rules
- If a user is deleted, their bids are deleted.
- If a listing deleted, all associated bids are deleted.
=> This preserves logical consistency: Bids without a bidder or a listing are meaningless.
Comment Model
Each Comment is stored as an independent record. A listing can have many comments, but each comment belongs to exactly one user and one listing.
class Comment(models.Model):
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
author = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="comments"
)
#Listing to which the comment belongs
listing = models.ForeignKey(
Listing,
on_delete=models.CASCADE,
related_name="comments"
)
def __str__(self): return f"Comment by {self.author} on {self.listing}"
def __str__(self):
return f"Comment by {self.author} on {self.listing}"
Timestamp Design
- created_at uses auto_now_add=True to record when the comment was first written.
- Even if comments were editable in the future, the creation time should remain fixed. In that case, an additional 'updated_at' field would be added.
- Since the Commerce project does not required comment editing, a single timestamp is sufficient.
Relationships
> author
- comment.author -> The user who wrote the comment.
- user.comments.all() -> All comments written by a user.
> listing
- comment.listing -> The listing the comment belongs to
- listing.comments.all() -> All comments on a listing
> As with bids, comments are deleted automatically if their user or a listing is deleted.
Migration Issue (AutoField Warning)
> When running python manage.py makemigration, Django displayed warnings indicating that an auto-created primary key(AutoField) was used because no primary key type was explicitly defined in the models.
- This warning isn't an error. By default, Django automatically adds an id field as the primary key if none is specified. Starting from Django 3.2, BigAutoField is recommended as the default key type for new projects, which is why this warning appears.
# settings.py
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
- Since the migration file had already been created at this point, I didn't modify the DEFAULT_AUTO_FIELD setting. Changing the default primary key type after generating migrations can introduce unnecessary inconsistency.
- For this project, the warning can be safely ignored, and the migrations can be applied normally using python manage.py migrate.
Testing Models (Shell & Admin)
Shell Test
- After defining the models, I verified the integrity of the data model using the Djanogo shell. Creating a user and a listing confirmed that foreign key relationships and reverse lookups via 'related_name' worked as intended. This step ensured that the domain model was correctly reflected in Django's ORM before moving on to views and templates.
1) Check initial status
2) Create a user
3) Create a listing, FK: owner FK -> Successful user connection / starting_bid is DecimalField, and even if int is added, Django automatically converts it.
4) User -> Listing: 'related_name="listings"' works right / User:Listing = 1:N
5) Listing -> User
6) Listing -> Bid / Comment: Empty because Bid/comment has not yet been created
Admin Test
=> Bid.amount Save / Bid.bidder -> User FK / Bid.listing ->Listing FK
= l.bids.all(), u.bids.all()
+ Add Comment (Content: Test Content, Author: test, Listing: Listing object (1))
=> Comment -> User FK / Comment -> Listing FK
= l.comments.all(), u.comments.all()
> Using Django Admin, I verified that all model relationships worked as intended.
- Listings correctly referenced their owners, bids and comments were properly associated with both users and listings.
- This confirmed that the data layer was stable before moving on to implementing views and templates.
How Django Admin Handles ForeignKey Fields
> While creating a Bid object in the admin interface, I was able to select an existing user named 'test' from the bidder dropdown.
- This behaviour is a direct result of the following model definition: bidder = models.ForeignKey(User,...)
> Why did 'test' appear in the dropdown?
- Django Admin interprets ForeignKey(User) as "This field references rows from the User table."
- When generating the admin form, Django queries User.objects.all() and uses the result set to populate the drop choices.
- Because the user 'test' already existed in the database, it appeared as a selectable option. This same mechanism applies to all ForeignKey fields in the admin interface.
Defining __str__
> Before defining __str__, model instances appeared in Django Admin as 'Listing object (1)'. This is technically correct but not human-friendly.
- After adding __str__ methods, objects became immediately recognisable.
: Listings: TestItem / Bids: 0.23 by test / Comments: Comment by test on TestItem
Testing the Watchlist(Many-to-Many) Relationship in Admin and Django Shell
> To verify that the User <-> Listing relationship works in both directions, I tested it directly through Django Admin.
1. Navigate to Admin -> Users -> 'test'
2. Open the Watchlist field.
3. Confirm that 'TestItem' was selectable.
4. Use the "+" button to create a new listing if needed.
5. Select 'TestItem' and save.
> in Django Shell
To ensure the ORM behaved correctly beyond the admin page, I verified the relationship in the Django shell.
- This confirms user.watchlist.all(): all listings the user is watching and listing.watchers.all(): all users watching a listing.
- This directly validates the project requirement: "User should be able to add and remove items from their watchlist." Both forward and reverse access paths function correctly.
View Layer & Templates
This project is structured around these layers. Model defines the data structure and relationships, View contains business logic and validation, and Template+URL handles presentation and routing. According to the project specification, features 2-6 are implemented in the View layer.
Overview of Required Views
> (2) Create Listing
Requires a create_listing(request) view.
- GET: render an empty form
- POST: validate input, create Listing, redirect
> (3) Active Listings Page (Default Route)
Implemented in index(request).
- Query only active listings and pass them to the template.
> (4) Listing Detail Pate
Requires listing_detail(request, listing_id)
- Show listing details, calculate current price, handle bids, toggle watchlist, close auction, add comments, display winner.
> (5) Watchlist
Requires watchlist(request) and toggle_watchlist(request, listing_id)
- Uses request.user.watchlist.all()
> (6) Categories
Requires categories(request) and category_detail(request, category_name)
Active Listings Page (index view)
Requirements
The default route must display all currently active listings. And it show title, description, current price, image(opt).
Querying the Database
Instead of retrieving all listings with Listing.objects.all(), filter only active listings with Listing.objects.filter(is_active=True).
> Why filter(is_active=True)?
- This works because the Listing model defines is_active = models.BooleanField(default=True).
- is_active: A closed auction is not the same as a deleted record. If a listing were deleted, bid history and winner information would be lost. Using an is_active preserves auction history while allowing logical closure.
The index View Implementation
def index(request):
listings = Listing.objects.filter(is_active=True)
return render(request, "auctions/index.html", {
"listings": listings
})
- "listings": template variable name
- listings: Python variable
The dictionary passed to render() bridges backend data to the frontend template.
-> index view(modified)
def index(request):
listings = Listing.objects.filter(is_active=True)
for listing in listings:
highest_bid = listing.bids.order_by("-amount").first()
listing.current_price = highest_bid.amount if highest_bid else listing.starting_bid
return render(request, "auctions/index.html", {
"listings": listings
})
- This logic determines the current price. Instead of passing 'current_price' separately in the render() context, I attached it directly to the listing object like 'listing.current_price'. Because Django objects are mutable Python objects, adding a temporary attribute like this make it accessible in the template.
Common QuerySet Methods
When retrieving model data, the pattern begins with Model.objects.
- .all(): Retrieve all records
- .filter(condition): Filter records
- .exclude(condition): Exclude matching records
- .get(id=1): Retrieve a single object
- .exists(): Check if records exist
- .order_by("-created_at"): Sort results
Rendering in the Template(index.html)
{% block body %}
<h2>Active Listings</h2>
{% for listing in listings %}
<div style="border:1px solid #ccc; padding:10px; margin-bottom:10px;">
<h2>
<a href="{% url 'listing_detail' listing.id %}">
{{ listing.title }}
</a>
</h2>
{% if listing.image_url %}
<img src="{{ listing.image_url }}" width="200">
{% endif %}
<p>{{ listing.description }}</p>
<p><strong>Current Price:</strong> ${{ listing.current_price }}</p>
</div>
{% empty %}
<p>No active listings.</p>
{% endfor %}
{% endblock %}
- The template iterates through the listings and displays their information.
Architecture
The Model defines is_active, the View filters active listings and the Template renders the filtered result. The View layer determines what data is retrieved, decides which subset of data is shown and prepares data for presentation.
Listing Detail ('listing_detail' view)
The listing_detail view is responsible for: Displaying full listing details / Calculating the current price / Handling bids / Toggling the watchlist / Creating comments / Closing auctions / Determining and displaying the winner
1. Overall Design
GET -> Display data / POST -> Mutate Data
This division keeps request handling predictable and avoids intermixing rendering logic with mutation logic.
2. Retrieving the Listing Object Safely & Calculating the Current Price
def listing_detail(request, listing_id):
error = None
listing = get_object_or_404(Listing, pk=listing_id)
is_watchlisted = False
if request.user.is_authenticated:
is_watchlisted = request.user.watchlist.filter(pk=listing.pk).exists()
highest_bid = listing.bids.order_by("-amount").first()
current_price = highest_bid.amount if highest_bid else listing.starting_bid
> Why can we use 'listing_id' without defining it in the model?
- Django automatically creates a primary key field(id) for every model. 'listing_id' is a URL parameter passed into the view. That value is then used as 'pk=listing_id' to retrieve the corresponding object.
> Why 'get_object_or_404' instead of .get()?
- Using 'Listing.objects.get(pk=listing_id) raises a Listing.DoesNotExist exception if the object does not exist, which results in a 500 server error.
- In web applications, users may manually modify URLs, access outdated links, attempt to reach deleted resources. get_object_or_404 handles this by automatically raising Http404, returning a proper 404 page instead of crashing the server.
> Calculating the Current Price
- If bids exist, the highest bid is the current price.
- If no bids exist, the starting bid is the current price.
- Descending order(-amount) ensures the first object is the highest bid.
- The conditional expression: value_if_true if condition else value_if_false
3. POST Structure Design
> Initially, I considered checking conditions like 'if "bid" in request.POST:' or 'if request.method == "POST" and ... '
- However, I set the final structure to this - more explicit, readable.
if request.method == "POST":
action = request.POST.get("action")
if action == "bid":
elif action == "watchlist": ...
4. POST: Bid Logic
if action == "bid":
if not request.user.is_authenticated:
return redirect("login")
bid_raw = request.POST.get("bid")
try:
bid_amount = Decimal(bid_raw)
except (TypeError, ValueError):
error = "Invalid bid amount."
else:
if not listing.is_active:
error = "Auction is closed."
elif bid_amount <= current_price:
error = "Your bid must be higher than the current price."
else:
Bid.objects.create(
listing=listing,
bidder=request.user,
amount=bid_amount
)
return redirect("listing_detail", listing_id=listing.id)
> Specification requirements: User must be signed in / Auction must be active / Bid must be greater than starting bid or highest_bid / Errors must be displayed when validation fails
- This reduces logically to:
request.user.is_authenticated
AND listing.is_active
AND bid_amount > current_price
> Safer Input Handling
- Originally, bid_amount = float(request.POST["bid"]) - This had two risks: KeyError if input is missing and ValueError if input is not numeric.
- So I used improved version with exception handling for invalid input.
> Why Decimal instead of float?
- Because 'starting_bid' is a DecimalField in models.py. Using Decimal ensures type consistency and prevents floating-point precision issues.
> Validation Order
- The bid logic follows a structured validation order: Authentication check - Auction state check - Business rule validation - Successful bid creation
5. POST: Watchlist (Toggle Logic)
elif action == "watchlist":
if not request.user.is_authenticated:
return redirect("login")
if request.user.watchlist.filter(pk=listing.pk).exists():
request.user.watchlist.remove(listing)
else:
request.user.watchlist.add(listing)
return redirect("listing_detail", listing_id=listing.id)
> Specification: If the user is signed in, the user should be able to add or remove the item from their Watchlist - This is a toggle operation that reverses the state(on/off, add/remove, true/false).
> Initial implementation: if listing in request.user.watchlist.all():. This loads the entire queryset into memory.
-> Improved version: if request.user.watchlist.filter(pk=listing.pk).exists():
- .filter(pk=...) adds a WHERE condition at the database level.
- .exists() executes something similar to SELECT 1 ... LIMIT 1.
It avoids loading all watchlist items into Python memory, making it significantly more efficient.
6. POST: Comment Logic
elif action == "comment":
if not request.user.is_authenticated:
return redirect("login")
content = request.POST.get("comment")
if content:
Comment.objects.create(
listing=listing,
author=request.user,
content=content
)
return redirect("listing_detail", listing_id=listing.id)
comments = listing.comments.all().order_by("-created_at")
> Why are comments fetched outside POST?
- It must run during GET. After POST, we redirect(POST-> redirect -> GET). If comment retrieval were placed inside POST, it would be discarded after the redirect. This follows the Post/Redirect/GET(PRG) pattern, which prevents duplicate submissions and keeps logic clean.
7. POST: Closing the Auction
> While designing this feature, I realised the model needed a 'winner' field.
winner = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="won_listings"
)
- SET_NULL: If the winner user is deleted, the listing must remain. Only the winner reference should be removed.
elif action == "close":
if (
not request.user.is_authenticated
or request.user != listing.owner
or not listing.is_active
):
return redirect("listing_detail", listing_id=listing.id)
if highest_bid:
listing.winner = highest_bid.bidder
listing.is_active = False
listing.save(update_fields=["winner", "is_active"])
return redirect("listing_detail", listing_id=listing.id)
> Permission and State Validation
- Initially I wrote it as separate checks, later I refactored into a single condition.
- If a highest bid exists, then set 'is_active' to False and save the changes. 'update_fields' limits the database update to only modified fields.
8. GET: Determining the Winner
#GET
comments = listing.comments.all().order_by("-created_at")
is_winner = False
if (
request.user.is_authenticated
and not listing.is_active
and listing.winner == request.user
):
is_winner = True
return render(request, "auctions/listing.html",{
"listing": listing,
"current_price": current_price,
"error": error,
"comments": comments,
"is_winner": is_winner,
"is_watchlisted": is_watchlisted
})
> Specification: If a user is signed in on a closed listing page, and the user has won that auction, the page should say so. - This condition must always be evaluated before rendering. If true, the template is going to display "You won this auction."
9. Listing Detail Template(listing.html)
After the detail view was implemented, several UI features were added to listing.html. These include bidding interface, watchlist toggle, auction closing, winner message, and comments.
{% extends "auctions/layout.html" %}
{% block body %}
<!--Listing Detail-->
<h2>{{ listing.title }}</h2>
{% if listing.image_url %}
<img src="{{ listing.image_url }}" alt="{{ listing.title }}" style="max-width:400px;">
{% endif %}
<p><strong>Description:</strong> {{ listing.description }}</p>
<p><strong>Current Price:</strong> ${{ current_price }}</p>
<!--Watchlist-->
{% if user.is_authenticated %}
<form method="POST">
{% csrf_token %}
<input type="hidden" name="action" value="watchlist">
{% if is_watchlisted %}
<button type="submit">Remove from Watchlist</button>
{% else %}
<button type="submit">Add to Watchlist</button>
{% endif %}
</form>
{% endif %}
<!--Error-->
{% if error %}
<p style="color:red">{{ error }}</p>
{% endif %}
<!--Bid-->
{% if user.is_authenticated and listing.is_active %}
<h3>Place a Bid</h3>
<form method="POST">
{% csrf_token %}
<input type="hidden" name="action" value="bid">
<input type="number" name="bid" step="0.01" min="{{ current_price }}" placeholder="Enter your bid" required>
<button type="submit">Place Bid</button>
</form>
{% endif %}
<!--Close Auction-->
{% if user == listing.owner and listing.is_active %}
<form method="POST">
{% csrf_token %}
<input type="hidden" name="action" value="close">
<button type="submit">Close Auction</button>
</form>
{% endif %}
<!--Winner-->
{% if not listing.is_active %}
{% if is_winner %}
<p style="color:green">You won this auction!</p>
{% else %}
<p>This auction is closed.</p>
{% endif %}
{% endif %}
<!--Category & Owner-->
{% if listing.category %}
<p><strong>Category:</strong> {{ listing.category }}</p>
{% endif %}
<p><strong>Owner:</strong> {{ listing.owner }}</p>
<!--Comment-->
<h3>Comments</h3>
{% for comment in comments %}
<div style="margin-bottom:10px; border-bottom:1px solid #ccc;">
<strong>{{ comment.author }}</strong>
<small>{{ comment.created_at }}</small>
<p>{{ comment.content }}</p>
</div>
{% empty %}
<p>No comments yet.</p>
{% endfor %}
<!--Add Comment-->
{% if user.is_authenticated %}
<h4>Add a Comment</h4>
<form method="POST">
{% csrf_token %}
<input type="hidden" name="action" value="comment">
<textarea name="comment" rows="3" placeholder="Write your comment" required></textarea>
<button type="submit">Post Comment</button>
</form>
{% endif %}
{% endblock %}
> Bid Input
- step="0.01" : Defines the allowed increment for numeric input. Users can enter values such as '0.01 -> 0.02 -> 0.03 -> ... -> 1.25'. This relfects cent-level auction increments.
- min="{{ current_price }}" : Prevents users from submitting bids lower than the current price.
- required : Ensures that the form cannot be submitted without entering a value.
> Close Auction Button
Only the listing owner should be able to close an auction.
- The template condition {% if user == listing.owner and listing.is_active %} ensures that the current user is the listing creator, and the auction is still active.
> Winner Message
The view calculates an 'is_winner' and passes it to the template.
> Watchlist Toggle
To change the watchlist button dynamically, the view determines whether the listing is already in the user's watchlist.
- Add below to the view and pass "is_watchlisted": is_watchlisted to the template.
is_watchlisted = False
if request.user.is_authenticated:
is_watchlisted = request.user.watchlist.filter(pk=listing.pk).exists()
- The template uses 'is_watchlisted' to toggle the button.
10. Refactoring & Improvements
During development, several issues were corrected.
1. Moved error=None inside the view (removed global state).
2. Fixed ordering bug(order_by("amount") -> order by("-amount"))
3. Replaced float with Decimal
4. Removed duplicate highest_bid calculations.
5. Refactored close condition into a single logical expression.
6. Optimized watchlist lookup using .exists()
Create Listing
1. Category Model
> Originally, the Listing model had the following field:
category = models.CharField(max_length=50, blank=True)
- This approach has several problems.
1) Users can freely type category names, which leads to inconsistent data.
2) Categories cannot be managed easily.
3) Duplicate category names may appear.
> To solve these issues, I created a separate Category model.
class Category(models.Model):
name = models.CharField(max_length=50, unique=True)
def __str__(self):
return self.name
- unique=True prevents duplicate category names and ensures data integrity.
> Next, I replaced the CharField in the Listing model with a ForeignKey relationship.
category = models.ForeignKey(
Category,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="listings"
)
- This means: Each Listing belongs to one Category and a Category can contain multiple Listings.
- on_delete=models.SET_NULL ensures that if a category is deleted, the associated listings remain in the database with their category set to NULL.
- null=True : Allows NULL values in the database.
- blank=True : Allows the field to be optional in forms.
- related_name="listings" : Allows reverse queries such as 'category.listings.all()
After creating the model, I ran migrations and added initial categories(Fashion, Toys, Electronics) through the Django Admin.
2. Creating the Listing Form (ModelForm)
> According to the specification, users must be able to input title, description, starting bid, image URL(opt), category(opt).
- I used Django's ModelForm system.
from django import forms
from .models import Listing
class ListingForm(forms.ModelForm):
class Meta:
model = Listing
fields = [
"title",
"description",
"starting_bid",
"image_url",
"category",
]
> ModelForm automatically generates HTML form fields based on the model's database fields.
- It also provides built-in validation such as required field, decimal type, URL format, ForeignKey validation.
- Without ModelForm, every field would require manual validation such as:
title = request.POST.get("title")
if not title: error = "Title required"
> Some model fields were intentionally excluded from the form: owner, is_active, winner These fields must be controlled by the application logic rather than user input.
3. Implementing the create_listing View
@login_required
def create_listing(request):
if request.method == "POST":
form = ListingForm(request.POST)
if form.is_valid():
listing = form.save(commit=False)
listing.owner = request.user
listing.save()
return redirect("listing_detail", listing_id=listing.id)
else:
form = ListingForm()
return render(request, "auctions/create_listing.html", {
"form": form
})
> POST request flow
When the user submits the form:
1) ListingForm(request.POST) binds user input to the form (creating a Bound Form).
2) form.is_valid() runs automatic validation based on the model fields.
3) form.save(commit=False) creates a Listing object but does not save it yet.
- This is necessary because the form doesn't include the owner field.
- After assigning the owner, the listing can safely be saved.
- Finally, the user is redirected to the newly created listing page.
> GET request flow
- If the request is not POST, the view simply creates an empty form and renders the template.
- The @login_required decorator ensures that only authenticated users can create listings.
4. create_listing.html
<form method="POST">
{% csrf_token %}
<div class="form-group">
{{ form.title.label_tag }}
{{ form.title }}
{{ form.title.errors }}
</div>
- In the template 'create_listing.html', I rendered each field manually instead of using {{{ form.as_p }}
- Each form field in Django is represented by a BoundField object.
- {{ form.title }} : Renders the HTML input element.
- {{ form.title.label_tag }} : Renders the <label> element. Using label_tag ensures the <label> is properly connected to the input field via the 'for' attribute, which improves accessibility.
- {{ form.title.errors }} : Displays validation errors.
- Django automatically generates HTML like: <input type="text" name="title" maxlength="100" required id="id_title">
5. Debugging the View
> I encountered the following error: ValueError: The view create_listing didn't return an HttpResponse object.
- Reason: This occurred because the view only returned a response for POST requests. GET requests returned None.
- Fix: The issue was fixed by ensuring the view always returns a render response outside the POST block.
6. Testing the Feature
1) Creating a listing
- Navigating to /create/ and submitting valid data successfully creates a listing.
2) Required field validation
- Leaving 'title' or 'description' empty triggers browser validation.
3) Starting bid validation
- During testing, I noticed that users could submit 0 or negative values for the starting bid.
- After adding MinValueValidator, values below 0.01 are rejected.
starting_bid = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[MinValueValidator(0.01)]
)
4) Optional fields
- Listings can still be created without an image or category.
5) Login requirement
- Issue: Initially, 'Page not found 404' occurs when I entered /create/ after logging out.
- Fix: Set LOGIN_URL = "login" in settings.py.
- Result: Accessing /create/ while logged out redirects to the login page.
6) Database verification
- I verified the saved data through the Django shell.
>>> from auctions.models import Listing
>>> l = Listing.objects.last()
>>> print(l.category)
None
>>> l.owner
<User: admin>
>>> l.created_at
datetime.datetime(2026, 3, 5, 7, 47, 39, 901695, tzinfo=datetime.timezone.utc)
>>> Listing.objects.all().delete()
(5, {'auctions.Listing': 5})
7) Invalid listing IDs
- Accessing /listing/9999/ correctly returns a 404 page, confirming that the view safely handles invalid URLs.
Watchlist Page
The watchlist page displays all listings saved by the current user. Only authenticated users can access this page.
1. Watchlist view
@login_required
def watchlist(request):
listings = request.user.watchlist.all()
return render(request, "auctions/watchlist.html", {
"listings": listings
})
2. Watchlist template
{% for listing in listings %}
<div>
<h3>
<a href="{% url 'listing_detail' listing.id %}">
{{ listing.title }}
</a>
</h3>
<p>{{ listing.description }}</p>
{% if listing.image_url %}
<img src="{{ listing.image_url }}" style="max-width:200px;">
{% endif %}
</div>
{% empty %}
<p>Your watchlist is empty.</p>
{% endfor %}
- The template iterates through the listings and displays their title, description, and optional image. Each listing links back to the 'listing_detail' page. And if the watchlist is empty, a message is displayed.
Watchlist Feature Flow
User clicks "Add to Watchlist" -> Watchlist relationship saved -> User visits /watchlist -> request.user.watchlist.all() -> Listings displayed -> User clicks listing -> Listing detail page opens
Categories Feature
The categories system consists of two pages
1) A page listing all categories
2) A page showing listings within a specific category
Categories View & Template
def categories(request):
categories = Category.objects.all()
return render(request, "auctions/categories.html", {
"categories": categories
})
- This view retrieves all categories.
{% for category in categories %}
<li>
<a href="{% url 'category_listings' category.id %}">
{{ category.name }}
</a>
</li>
{% endfor %}
- The template displays each category as a link.
Category Listings View & Template
def category_listings(request, category_id):
category = get_object_or_404(Category, pk=category_id)
listings = Listing.objects.filter(
category=category,
is_active=True
)
return render(request, "auctions/category_listings.html", {
"category": category,
"listings": listings
})
- When a category is clicked, the application shows all active listings in that category.
<h2>{{ category.name }}</h2>
{% for listing in listings %}
<div>
<h3>
<a href="{% url 'listing_detail' listing.id %}">
{{ listing.title }}
</a>
</h3>
<p>{{ listing.description }}</p>
{% if listing.image_url %}
<img src="{{ listing.image_url }}" style="max-width:200px;">
{% endif %}
</div>
{% empty %}
<p>No active listings in this category.</p>
{% endfor %}
- This template displays listings within the selected category. Images are also displayed if available. If no active listings exist in the category, a message is shown.
> category.id: The URL parameter is <int:category_id> in the path of urls.py ("category/<int:category_id>/..."). The actual URL is like /categories/1/. That's why the template tells Django to create a category_listings URL, and to insert category.id as a parameter.
- The flow of action is: Category page -> category.id=3 -> /categories/3/ -> View -> category=Category.objects.get(pk=3) -> Listing.objects.filter(category=category)
Testing & System Validation
Database Reset
- While testing, I used 'python manage.py flush'. This command deletes all database data but preserves the table structure. It removes Users(including admin), Listings, Bids, Comments, Watchlist relationships, Sessions.
- After running flush, the admin user must be recreated.
1. Test Setup
- To simulate user interactions, I created three accounts: seller, buyer1, buyer2.
- Additionally, several categories were created via the Django admin panel.
2. Create Listing
- Using the seller account, I created two listings via the /create/ page: Wireless Noise-Cancelling Headphones, Remote Control Car(without image)
- This verified that listings can be created with or without optional fields, and the starting_bid validation works correctly after fixing the Decimal("0.01") issue.
3. Listing Detail Verification
On the listing detail page, I confirmed that the following elements render correctly: Title, Description, Current price(initially equal to starting bid), Image(if provided), Watchlist button, Bid form, Category and owner, Comments section and comment form.
4. Bidding System
Initial Behaviour
- buyer1 placed a bid of 130 -> current price updated correctly.
- Attempting to bid 110 triggered a browser validation error:
Ensure this value is greater than or equal to 130."
Ensure this value is greater than or equal to 130."
- buyer2 placed a higher bid(140) -> current price updated.
Improvements
1) Display Highest Bidder
- Added logic in the view: highest_bidder = highest_bid.bidder if highest_bid else None
- Passed to template: "highest_bidder": highest_bidder
- Rendered in UI:
{% if highest_bidder %}
<p><strong>Highest Bidder:</strong> {{ highest_bidder }}</p>
{% endif %}
2) Prevent Seller from Bidding
Originally, the seller could bid on their own listing.
> Fix (backend):
if request.user == listing.owner:
error = "You cannot bid on your own listing."
> Fix (frontend):
{% if user.is_authenticated and listing.is_active and user != listing.owner %}
This ensures both UI-level and server-side enforcement.
3) Fix Validation Mismatch
There was a mismatch: HTML allowed >= current_price & Django required > current_price
> Fix (add:0.01)
<input type="number" name="bid" step="0.01" min="{{ current_price|add:0.01 }}"
placeholder="Enter your bid" required>
=> Now, if current price=150, minimum bid = 150.01. And Frontend and backend logic are consistent.
5. Comment System & Timezone Issue
Problem
Comment timestamps displayed incorrect time (9:26 AM instead of 6:26 PM in Korea)
Solution
- settings.py: TIME_ZONE = 'Asia/Seoul'
- Template: <small>{{ comment.created_at|localtime|date:"Y-m-d H:i T" }}</small>
=> Now timestamps reflect KST correctly.
6. Watchlist Feature
Tested with buyer1:
- Add to watchlist, Remove from watchlist -> works.
- Empty state message displays correctly: "Your watchlist is empty."
7. Categories
Verified:
- Categories page lists all categories
- Clicking a category filters listings correctly
- Only 'is_active=True' listings are shown
- Empty category message works: No active listings in this category."
8. Closing Auction & Winner Logic
Behaviour
- Only the seller can see the Close Auction button
- After closing: Listing disappears from Active Listings, Bid form is disabled, and "This auction is closed." message appears.
Winner Handling(Improved)
- Added in view: winner = listing.winner
- Template:
<!--Winner-->
{% if not listing.is_active %}
<p>This auction is closed.</p>
{% if winner %}
<p><strong>Winner:</strong> {{ winner.username }}</p>
{% endif %}
{% if is_winner %}
<p style="color:green">You won this auction!</p>
{% endif %}
{% endif %}
Result
- Winner is explicitly shown
- Winning user gets a personalised message
- Non-winners just see that the auction is closed
9. Accessing Inactive Listings
Inactive listings: Do not appear on the main page(index), but can still be accessed via direct URL(/listing/<id>/).
10. Final State After Fixes
- Seller connot bid
- Highest bidder displayed
- Winner displayed after auction closes
- Timezone correctly localised(KST)
- Watchlist fully functional
- Category filtering works
- Validation logic consistent (FE +BE)
- UI conditions aligned with business rule
UI/UX Improvements and Frontend Refactoring
1. Consistent Listing Preview Across Pages
Initially, the index page displayed the current price, but the category and watchlist pages did not. This created a UX inconsistency: Users had to click into each listing just to check its price.
Solution & Implementation
- I standardized all listing previews to include title, description, image(optional), current price(added).
- In views.py, I added current price calculation to all relevant views. Then displayed it in templates.
for listing in listings:
highest_bid = listing.bids.order_by("-amount").first()
listing.current_price = highest_bid.amount if highest_bid else listing.starting_bid
Result
All listing previews now provide the same essential information, improving usability and reducing unnecessary navigation.
2. Component-Based UI: Partial Template
Initially, listing UI was duplicated across: index.html, watchlist.html, category_listings.html. This made maintenance harder.
Refactor: _listing_card.html
I made a reusable component.
<div class="card h-100 card-hover">
{% if listing.image_url %}
<img src="{{ listing.image_url }}" class="card-img-top" style="height:200px; object-fit:cover;">
{% endif %}
<div class="card-body d-flex flex-column">
<h5 class="card-title">
<a href="{% url 'listing_detail' listing.id %}" class="stretched-link">
{{ listing.title }}
</a>
</h5>
<p class="card-text">{{ listing.description|truncatewords:15 }}</p>
<p class="mt-auto">
<strong>Current Price:</strong> ${{ listing.current_price}}
</p>
</div>
</div>
- h-100: Ensures equal card height
- flex-column + mt-auto: Keeps price aligned at the bottom
- truncatewords: Prevents layout breaking
- stretched-link: Makes entire card clickable
3. Bootstrap Grid System for Responsive Layout (index.html)
I used Bootstrap's 12-column grid system. This ensures the UI scales cleanly across devices.
<div class="container mt-4">
<h2 class="mb-4">Active Listings</h2>
<div class="row">
{% for listing in listings %}
<div class="col-12 col-sm-6 col-md-4 col-lg-3 mb-4">
{% include "auctions/_listing_card.html" %}
</div>
{% empty %}
<p>No active listings.</p>
{% endfor %}
</div>
</div>
4. Critical Layout Bug: Nested Grid Issue
Problem
Cards were rendered too narrow.
Cause
Grid classes were applied twice:
- index.html -> already had 'col-*'
- listing_card.html -> also had 'col-md-4'
This resulted in nested columns, shrinking width unexpectedly.
Fix
Removed grid wrapper from _listing_card.html. Layout(grid) belongs to the parent, componenets should not define layout.
5. Listing Detail Page Redesign (2-Column Layout)
Problem
Original layout stacked everything vertically, which was inefficient on wide screens.
Challenge
image_url is optional -> layout could break.
Solution: Consistent 2-Column Layout
{% if listing.image_url %}
<div class="col-md-6 mb-3">
<img src="{{ listing.image_url }}" alt="{{ listing.title }}"
class="img-fluid rounded" style="width:100%; max-height:400px; object-fit:cover;">
</div>
<!--Right side(infos)-->
<div class="col-md-6">
{% else %}
<!--No image(HTML placeholder)-->
<div class="col-md-6 mb-3">
<div style="height:400px; background:#f8f9fa;
display:flex; align-items:center; justify-content:center;
color:#6c757d; font-size:20px; border-radius:8px;">
No Image Available
</div>
</div>
<!--Right side-->
<div class="col-md-6">
{% endif %}
- Left: Image or placeholder(To keep UI consistent, avoid layout shifting, easier to maintain)
- Right: Listing information
6. UX Enhancements
Improved Forms
widgets= {
"title": forms.TextInput(attrs={"class": "form-control"}),
"description": forms.Textarea(attrs={"class": "form-control"}),
"starting_bid": forms.NumberInput(attrs={"class": "form-control"}),
"image_url": forms.URLInput(attrs={"class": "form-control"}),
"category": forms.Select(attrs={"class": "form-control"})
}
- Applied Bootstrap styling via forms.py
Interaction Feedback
Each action has a distinct visual meaning.
- Watchlist -> btn-outline-primary
- Bid -> btn-success
- Close auction -> btn-danger
7. Global Styling Strategy
Defined reusable design tokens.
:root {
--primary: #2563eb;
--primary-dark: #1d4ed8;
--primary-light: #eff6ff;
--border-color: #e5e7eb;
--text-muted: #6b7280;
- Benefits: Centralized color management easy theme adjustments, consistent branding.
8. Layout & Spacing System
Standardized spacing. Section spacing(mt-5), element spacing(mb-2). This avoids inconsistent margins across components.
9. Navbar & Page Structure
Improvements
Unified styling with primary color. And removed default body padding, replaced with .container.
10. Debugging: Browser Cache Issue
After updating CSS, changes didn't appear.
Cause
Browser caching
Fix
Hard refresh (Ctrl + Shift + R)
11. Final Thoughts
This phase emphasized frontend architecture over feature development.
-UI consistency matters as much as functionality.
- Component reuse reduces long-term complexity.
- Separation of concerns (layout vs. component) is critical.
- Backend logic and frontend validation must stay aligned.
- Small UX details significantly improve usability.
Result
What I Learned
Through this project, I developed a deeper understanding of how Django applications are structured and how different layers interact.
Django ORM & Data Modeling: I learned how to design relational data models using ForeignKey and ManyToManyField, and how these relationships translate into efficient database queries.
ModelForm and Validation: Using ModelForm simplified form handling by providing automatic validation and clean integration with database models, compared to manual request handling.
Separation of Concerns(MVC Pattern): I reinforced the importance of separating logic between models, views, and templates, making the code more maintainable and scalable.
Frontend-Backend Consistency: Aligning HTML validation(min, step) with backend logic was critical to prevent conflicting behaviours and ensure reliable input validation.
UI/UX Matters: Improving layout consistency, component reuse, and visual feedback greatly enhances the user experience.
Debugging & Iteration: Many issues required careful debugging, which helped me build a more systematic approach to problem-solving.

댓글 없음:
댓글 쓰기