Project 4: Network
Network - CS50's Web Programming with Python and JavaScript
Table of Contents
> Pre-Implementation
- Project Overview
- Architecture
- Distribution Code Analysis
- Data Modeling
- API Design
- Frontend Strategy
- Potential Pitfalls
- What I Aim to Learn
> Implementation
- Post Model
- Follow Model
- Model
- Model Testing (Django Shell)]
- Admin Configuration
- Designing the Posts Endpoint
- GET /posts - Retrieving Posts
- POST /posts - Creating Posts
- Frontend Implementation (network.js)
- Fetching Posts
- Creating Posts from the Frontend
- CSRF Issue and Solution
- Testing
- Implementing User Profiles
- Building the Profile View
- Implementing Follow and Unfollow
- Profile Template
- Debugging: Profile link not updating
- Implementing the Following Feed
- Refactoring the Frontend
- Separating Pages from APIs
- Dynamic API Selection with data-url
- Debugging Issues Encountered
- Implementing Pagination
- Backend Pagination with Django Paginator
- Serializing the Current Page
- Returning Pagination Information
- Updating the Frontend
- Separating Pagination Rendering
- Dynamic API URLs
- Rendering Navigation Buttons
- Debugging
- Pagination on the Profile Page
- Implementing Post Editing
- Identifying the Post Owner
- Rendering the Edit Button
- Switching to Edit Mode and Updating the Post (js)
- Edit post Backend (edit_post view)
- Debugging
- Implementing the Like Feature
- Backend Implementation
- Sending Authentication State
- Rendering the Like Button
- Updating Only One Post
- Comparing Edit and Like
- Debugging
- CSS / UI Design
> Test
- Authorization Testing: Preventing Unauthorized Post Edits
> Result: Video
Pre-Implementation
Project Overview
This project focuses on building a social network application using Django as a backend API server and JavaScript as a frontend controller. This project emphasizes API-driven architecture(JSON-based communication), SPA-like user experience(no full page reloads), and State-driven UI updates on the client side. The goal is not just to implement CRUD functionality, but to design a system where FE and BE responsibilities are clearly separated.
Architecture
System Flow
User Action -> JavaScript (fetch) -> Django API -> Database -> JSON Response -> JavaScript -> DOM Update
- In this architecture, the server does not render HTML dynamically. Instead, it returns data, and the frontend is responsible for rendering the UI.
Responsibility Separation
> Backend (Django)
- Data modeling and persistence / Business logic / API endpoints returning JSON
> Frontend (JavaScript)
- Handling user interactions / Managing UI state / Rendering content dynamically
Distribution Code Analysis
Before implementation, I analyzed the provided distribution code. This is not a partially completed application, but a minimal skeleton designed to enforce an API-first and SPA-style architecture.
models.py
Only a basic user model is defined.
views.py
Contains login, logout, register, index(template rendering) views. There are no API endpoints and no JSON responses implemented.
index.html
The HTML structure is empty and acts as a container.
Data Modeling
ERD
User (1:N->Post, N:N->likes, 1:N->follower/following)
- id
- username
- email
- password
Post
- id
- user (FK → User)
- content
- timestamp
- likes (ManyToMany → User)
Follow
- id
- follower (FK → User)
- following (FK → User)
- id
- username
- password
Post
- id
- user (FK → User)
- content
- timestamp
- likes (ManyToMany → User)
Follow
- id
- follower (FK → User)
- following (FK → User)
Post Model Design
Each post will include author, content, timestamp, likes. I decided to use a ManyToMany field for likes, as it allows tracking which users liked a post.
Follow Relationship Design
Two approaches were considered.
- Option A: ManyToMany field on User.
- Option B: Separate Follow model.
I chose B because relationships can be treated as first-class data, easier to extend, more explicit and readable queries, and better alignment with real-world system design.
API Design
Designing the API before implementation helps maintain consistency and prevents ad-hoc endpoint creation.
Planned Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /posts | Retrieve posts (with pagination/filtering) |
| POST | /posts | Create a new post |
| PUT | /posts/:id | Edit a post |
| PUT | /posts/:id/like | Toggle like |
| PUT | /follow/:id | Follow or unfollow a user |
| GET | /profile/:id | Retrieve user profile |
Frontend Strategy
State-Driven UI
The UI will dynamically change based on authentication state, ownership of content(author or viewer), like status, follow relationships. The UI is treated as a function of application state.
Rendering Approach
- Use JavaScript to create and update DOM elements.
- Avoid full rerendering when possible
- Update only affected components(ex. like count)
Event Flow Example
User clicks 'Like" -> JavaScript sends a PUT request -> Server updates database -> JSON response returned -> UI updates without page reload
Potential Pitfalls (Pre-identified)
I asked several risk areas before implementation.
- CSRF token handling in POST/PUT request.
- Synchronizing frontend state with backend data.
- Avoiding duplicate DOM rendering.
- Handling pagination with filtering.
What I Aim to Learn
- API-first backend design
- Client-side state management without frameworks
- Asynchronous request handling (fetch)
- Debugging data flow between frontend and backend
Implementation
Post Model
Key Fields
- User (ForeignKey -> User)
- timestamp
- auto_now_add=True automatically records the creation time of each post.
- likes (ManyToMany -> User)
- This represents a many-to-many relationship. A user can like multiple posts and a post can be liked by multiple users.
- blank=True allows posts to have zero likes.
- null is not used because ManyToMany relationships do not support NULL at the database level.
Like Relationship Behaviour
Unlike ForeignKey fields, ManyToMany relationships use an intermediate table.
- When a user is deleted -> related likes are automatically removed.
- No need to explicitly define on_delete
str Method
def __str__(self):
return f"{self.user} - {self.content[:30]}"
Improves readability in Django admin, Django shell, and debugging output.
likes_count Property
@property
def likes_count(self):
return self.likes.count()
- This provides a clean interface for accessing like counts {{lpost.likes_count}}, instead of exposing internal logic {{post.likes.count}}.
- This design improves readability, encapsulation, maintainability.
Follow Model
The Follow model represents user relationships. Instead of using a simple ManyToMany field, this project treats follow relationships as first-class entities.
Why a separate model?
- Enables extensibility (ex. timestamps, status flags)
- Makes relationships explicit
- Improves query clarity
- Reflects real-world system design
Fields
- follower / following (ForeignKey -> User)
- related_name="following", related_name="followers" : This enables user.following.all(), user.followers.all()
- created_at
- Not required, but it is useful for sorting, analytics, future feature expansion.
Data Integrity Constraints
class Meta:
constraints = [
models.UniqueConstraint(
fields=["follower", "following"],
name="follow_constraint"
)
]
- This ensures a user cannot follow the same user multiple times.
Preventing Self-Follow
One potential issue in the current design is that a user can follow themselves.
To prevent this at the database level, an additional constraint can be added.
- Q(follower=F"following")) means follower == following.
- ~ means not.
=> Follower and following should not be the same.
Model
class User(AbstractUser):
pass
class Post(models.Model):
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="posts"
)
content = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
likes = models.ManyToManyField(
User,
related_name="liked_posts",
blank=True
)
def __str__(self):
return f"{self.user} - {self.content[:30]}"
@property
def likes_count(self):
return self.likes.count()
class Follow(models.Model):
follower = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="following"
)
following = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="followers"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["follower", "following"],
name="follow_constraint"
),
models.CheckConstraint(
check=~Q(follower=F("following")),
name="prevent_self_follow"
)
]
def __str__(self):
return f"{self.follower} follows {self.following}"
Model Testing (Django Shell)
Basic model behaviour was verified using the Django shell.
- Creating users and posts
- Adding/removing likes
- Testing ManyToMany relationships
- Creating follow relationships
- Verifying reverse lookups such as 'user.liked_posts.all()'
All relationships behaved as expected, confirming that the data model is correctly structured.
Admin Configuration
To simplify data inspection and debugging, models were registered in Django admin.
admin.site.register(User)
Post Admin
class PostAdmin(admin.ModelAdmin):
list_display = ("id", "user", "content", "timestamp")
search_fields = ("content", "user__username")
admin.site.register(Post, PostAdmin)
- list_display: Defines which fields are shown in the admin list view. -> Improves visibility of data.
- search fields: Enables search functionality based on post content and author's username.
Follow Admin
class FollowAdmin(admin.ModelAdmin):
list_display = ("follower", "following")
admin.site.register(Follow, FollowAdmin)
Provides a clear overview of user relationships.
Designing the Posts Endpoint
According to the specification, both New Post and All Posts operate on the same 'posts' resource. I handled both functionalities in a single endpoint by differentiating HTTP methods.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /posts | Retrieve all posts |
| POST | /posts | Create a new post |
This follows REST-style API design where the endpoint represents the resource and the HTTP method represents the action.
GET /posts - Retrieving Posts
def posts(request):
#Get all posts
if request.method == "GET":
posts = Post.objects.all().order_by("-timestamp")
posts_data = []
for post in posts:
posts_data.append({
"id": post.id,
"user": post.user.username,
"content": post.content,
"timestamp": post.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
"likes_count": post.likes_count,
"liked_by_user": request.user in post.likes.all()
})
return JsonResponse(posts_data, safe=False)
Querying and Ordering Data
posts = Post.objects.all().order_by("-timestamp")
Posts are retrieved in reverse chronological order so that the newest posts appear first.
Why Serialization Is Needed
Although Django QuerySets contain model data, they are still Python objects: <QuerySet [<Post: ...>]>. These objects cannot be directly returned as JSON responses. Therefore, each 'Post' object is manually serialized into a dictionary structure.
Converting Related Objects
post.user is a Django model object, not JSON-compatible data. To make it usable on the frontend, it's converted into "user": post.user.username which JavaScript can properly interpret.
liked_by_user State
Checks whether the currently authenticated user exists in the post's like relationship. This produces a boolean value that will later be used for Like/Unlike button rendering and Client-side UI state management.
Returning JSON Arrays
By default, Django's JsonResponse expects a dictionary object. Since this endpoint returns a list of posts instead of dictionary, safe=False is required.
POST /posts - Creating Posts
elif request.method == "POST":
if not request.user.is_authenticated:
return JsonResponse({"error": "Login required"}, status=401)
data = json.loads(request.body)
content = data.get("content", "").strip()
if not content:
return JsonResponse({"error": "Post content cannot be empty."}, status=400)
post = Post.objects.create(user=request.user, content=content)
return JsonResponse({
"message": "Post created.",
"post_id": post.id
}, status=201)
else:
return JsonResponse({"error": "GET/POST request required."}, status=405)
Authentication Handling
Instead of applying @login_required to the entire view, authentication was checked only inside the POST branch.
Receiving JSON Data
The frontend sends data using body: JSON.stringify({ content: content }). Django receives this raw JSON string through request.body which is then converted into a Python dictionary data = json.loads(request.body).
Safe Data Extraction
data.get("content", "").strip()
Using get() is safer than direct indexing data["content"] because missing keys would otherwise raise an exception.
Preventing Empty Posts
- if not content: & data.get("content") would still allow whitespace-only input.
- By combining .strip() and default empty string, the implementation prevents empty posts, whitespace-only posts and None.strip() errors.
Saving the Post
The returned 'post' object provides immediate access to post.id, post.content, post.timestamp.
API Response
The response includes success confirmation and the newly created post ID. This allows the frontend to immediately identify and update the new post in the UI.
Handling Invalid Methods
status=405 was added to explicitly reject unsupported HTTP methods such as PUT, DELETE and PATCH.
Frontend Implementation (network.js)
Initial Setup
After creating network.js, the file was connected through layout.html and verified using console.log().
DOMContentLoaded
document.addEventListener('DOMContentLoaded', function() {
Ensures JavaScript runs only after the HTML structure has fully loaded. Without this, querySelector() could execute before target elelments exists in the DOM tree.
Fetching Posts
function loadPosts() {
const postsContainer = document.querySelector('#posts-container');
postsContainer.innerHTML = ''; // Prevent duplicate append when loading
fetch('/posts')
.then(response => response.json())
.then(posts => {
posts.forEach(post => {
const postDiv = document.createElement('div');
postDiv.innerHTML = `
<h3>${post.user}</h3>
<p>${post.content}</p>
<small>${post.timestamp}</small>
<p>Likes: ${post.likes_count}</p>`;
postsContainer.append(postDiv);
});
});
}
Request Flow
Database -> Django View -> JsonResponse -> fetch('/posts') -> response.json() -> DOM Rendering
Rendering Posts
For each post,
1. Create a div element
2. Insert post data into innerHTML
3. Append the element to the container.
- The container is also cleared before rendering to prevent duplicate rendering.
Creating Posts from the Frontend
function createPost() {
const content = document.querySelector('#post-content').value;
if (!content.trim()) { //Prevent creating empty posts
return;
}
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
fetch('/posts',{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
content: content
})
})
.then(response => response.json())
.then(data => {
document.querySelector('#post-content').value = ''; // Clear the textarea
loadPosts(); //Reload after creating a new post
});
}
Refactoring into loadPosts()
Originally, GET rendering logic existed inline. While implementing POST request, I extracted it into loadPost() so that posts could be reloaded after successful creation.
Sending POST Requests
fetch(...) sends user input to the backend API.
Why .then() is Necessary
fetch() is asynchronous. Without .then(), subsequent code may excute before the server responds. Therefore clearing the textarea and reloading posts must occur inside .then() after the request complete successfully.
Preventing Empty Input
if (!content.trim()){return;} was used instead of if (!content) because whitespace-only input should also be rejected.
CSRF Issue and Solution
> During testing, POST requests initially failed with Forbidden (CSRF token missing.).
- Django blocked the request because fetch-based requests do not automatically include CSRF tokens.
Traditional Django Forms vs Fetch Requests
In previous projects, CSRF protection was handled automatically using {% csrf_token %} inside HTML forms.
However, with fetch-based AJAX requests, JavaScript manually constructs the HTTP request therefore JavaScript must also manually include the CSRF token.
Reading the Token
The token was retrieved from the hidden input generated by {% csrf_token %} using document.querySelector('[name=csrfmiddlewaretoken]').value;.
Sending the Token
Django expects CSRF tokens in the X-CSRFToken header for AJAX requests.
Alternative Approaches Considered
- Reading the token from browser cookies (getCookie('csrftoken'))
- Using a meta tag (querySelector('meta[name="csrf-token"]'))
However, I selected the hidden input approach for simplicity and consistency with Django's built-in CSRF system.
Testing
| Scenario | Result |
|---|
| Not logged in → POST | 401 error |
| Logged in → POST | Success |
| Empty/whitespace content | Rejected |
Implementing User Profiles
Why I Chose Server-Side Rendering
Unlike the posts page, I decided to implement the profile page using Django templates.
> The posts page follows an API-driven approach
- Browser -> fetch('/posts') -> JsonResponse -> JavaScript renders HTML
> The profile page follows Django's traditional rendering flow
- Browser -> /profile/<username> -> Django View -> render() -> HTML Response
- Since profile pages are accessed through dedicated URLs and do not require dynamic updates, server-side rendering provided a simpler solution.
Building the Profile View
def profile(request, username):
profile_user = User.objects.get(username=username)
posts = profile_user.posts.all().order_by("-timestamp")
followers_count = profile_user.followers.count()
following_count = profile_user.following.count()
#Follow/Unfollow status
is_following = False
if request.user.is_authenticated:
is_following = Follow.objects.filter(follower=request.user, following=profile_user).exists()
return render(request, "network/profile.html", {
"profile_user": profile_user,
"posts": posts,
"followers_count": followers_count,
"following_count": following_count,
"is_following": is_following
})
Retrieving the Profile Owner
- The username parameter from the URL is used to retrieve the profile owner.
- To avoid confusion between the authenticated user and profile owner, I stored the result in a variable named 'profile_user'.
Retrieving User Posts
- I considered two approaches: Post.objects.filter(user=profile_user) OR profile_user.posts.all()
- Since the Post model already defines related_name="posts", I chose profile_user.posts.all() because it more clearly expresses the intent of retrieving posts that belong to the profile owner.
Followers and Following Counts
The 'Follow' model defines reverse relationships using related_name="followers" which allows profile_user.followers.count() to retrieve follower(and following) counts directly.
Determining Follow Status
- To decide whether to display a Follow or Unfollow button, I needed to determine whether the authenticated user already follows the profile owner.
- Using .exists() returns a boolean value without retrieving the full object, making it efficient for UI state checks.
Implementing Follow and Unfollow
@login_required
def follow(request, username):
profile_user = User.objects.get(username=username)
Follow.objects.get_or_create(follower=request.user, following=profile_user)
if request.user == profile_user:
return redirect("profile", username=username)
@login_required
def unfollow(request, username):
profile_user = User.objects.get(username=username)
Follow.objects.filter(follower=request.user, following=profile_user).delete()
if request.user == profile_user:
return redirect("profile", username=username)
Separate Views
I created separate views for Follow and Unfollow, and conected them through urls.py.
Creating a Follow Relationship
Follow.objects.create(...) creates a new relationship in the Follow table. After creation, the user is redirected back to the profile page(return redirect(...)). Django internally resolves this route and generates the correct URL.
Removing a Follow Relationship
To unfollow a user, Follow.objects.filter(...).delete() removes the corresponding relationship from the database.
Preventing Duplicate Follows
- The model already contains a database-level constraint(UniqueConstraint(...)) which prevents duplicate follow relationships.
- Although the UI only displays one button at a time(Follow/Unfollow), using get_or_create() would provide additional protection against duplicate requests.
Profile Template
- The profile page displays Username, Followers count, Following count, User posts: <h2>{{ profile_user.username }}</h2>
- Posts are rendered using Django template loops: {% for post in posts %}
- Buttons are displayed only when {% if user.is_authenticated and user != profile_user %}
Debugging: Profile link not updating
> After modifying <h3><a href="/profile/${post.user}">${post.user}</a></h3>
- The profile links did not appear in the browser.
- The issue was caused by cached JavaScript files. A hard refresh forced the browser to reload the updated script and immediately resolved the problem.
Implementing the Following Feed
@login_required
def following_posts(request):
following_users = Follow.objects.filter(follower=request.user).values_list("following", flat=True)
posts = Post.objects.filter(user__in=following_users).order_by("-timestamp")
posts_data = []
for post in posts:
posts_data.append({
"id": post.id,
"user": post.user.username,
"content": post.content,
"timestamp": post.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
"likes_count": post.likes_count,
"liked_by_user": request.user in post.likes.all()
})
return JsonResponse(posts_data, safe=False)
Goal
The specification requires a page that displays only posts written by users that the current user follows. Unlike the profile page, which is rendered directly by Django templates, the Following feed reuses the existing post-rendering logic built with JavaScript and JSON APIs.
Retrieving Followed Users
> To identify which users the current user follows, at first, I considered using Follow.objects.filter(follower=request.user). However, this returns Follow objects <QuerySet [Follow(user1->user2),Follow(user1->user3)]>. What I actually needed was the list of followed users.
- Using values_list("following", flat=True) extracts only the following field values <QuerySet [2, 3]> where each value represents a user ID. Without flat=True, Django would return tuples instead.
Retrieving Posts from Followed Users
The __in lookup translates into an SQL IN clause, allowing Django to retrieve posts whose authors belong to the followed users set.
Reusing Existing Serialization Logic
The Following feed returns the same post data structure as the All Posts page. Because the only difference is the queryset being retrieved, the JSON serialization logic remains identical. A future improvement would be extracting the serialzation process into a separate helper function to eliminate duplicated code.
Refactoring the Frontend
The Limitation of loadPosts()
Originally, loadPosts() always requested fetch('/posts'). This worked for the All Posts page but could not be reused for the Following feed.
Making loadPosts Reusable
function loadPosts(url) ... fetch(url) ...
I refactored the function to accept a URL parameter loadPosts(url) which allows loadPosts('/posts') or loadPosts('/api/following') depending on the page. The rendering logic remains exactly the same, only the API endpoint changes. This significantly reduced duplicated JavaScript code.
Separating Pages from APIs
- Initially I mapped path("following", views.following_posts) directly to the JSON endpoint. - - However, navigating to /following displayed raw JSON in the browser. This happened because the endpoint returned JsonResponse(...) rather than HTML.
path("following", views.following_page, name="following"), path("api/following", views.following_posts, name="following_posts"),
path("following", views.following_page, name="following"),
path("api/following", views.following_posts, name="following_posts"),
- To solve this, I separated Page Route and API Route.
- Now, /following returns HTML while /api/following returns JSON. This follows a cleaner separation between user-facing pages and API endpoints.
Dynamic API Selection with data-url
<div id="posts-container" data-url="/api/following"></div>
> To allow the same JavaScript code to work on multiple pages, I introduced a custom data attribute.
- The JavaScript reads container.dataset.url and passes the value to loadPosts(). This creates a simple but flexible mechanism for selecting different API endpoints without modifying the rendering logic.
Debugging Issues Encountered
Issue 1: Following Page Displayed All Posts
> Problem: The Following page still showed posts from all users.
> Cause: DOMContentLoaded always excuted loadPosts('/posts') which meant the frontend always requested the All Posts API.
> Solution: Replace the hardcoded URL with
const container = document.querySelector('#posts-container');
if (container) {
loadPosts(container.dataset.url);
}
Issue 2: View Returned None
> Error: The view network.views.follow didn't return an HttpResponse object.
> Cause: A return statement existed only inside a conditional branch. As a result, some execution paths reached the end of the function without returning a response.
> Solution: Move return redirect(...) outside the conditional block. Django views must return an HttpResponse object in every execution path.
Issue 3: GET /undefined
> Problem: After refactoring loadPosts(url), post creation triggered GET /undefined.
> Cause: createPost() still called loadPosts() without supplying a URL. As a result, url===undefined and the browser attempted GET /undefined.
> Solution: Retrieve the current page's API endpoint
.then(data => {
document.querySelector('#post-content').value = ''; // Clear the textarea
const container = document.querySelector('#posts-container');
loadPosts(container.dataset.url); //Reload after creating a new post
});
}
Implementing Pagination
Displaying every post on a single page is inefficient as the number of posts grow. I used Django's Paginator to return only ten posts per request. This pagination logic was applied to every page that displays posts lists (All Posts, Following, Profile).
Backend Pagination with Django Paginator
Applying Pagination
- Previously, the posts endpoint followed this flow: Database -> QuerySet -> Serialize all posts -> JsonResponse -> JavaScript.
- To limit each response to ten posts, I used Django's Paginator before serialization.
post_paginator = Paginator(posts, 10) #10 posts per 1 page
current_page = request.GET.get("page")
posts_page = post_paginator.get_page(current_page)
- Paginator manages the entire queryset, while get_page() returns only the posts belonging to the requested page.
- Unlike page(), get_page() handles invalid page numbers by returning a valid page instead of raising an exception.
Serializing the Current Page
- Every post in the queryset was serialized.
- for post in posts_page: keeps each API response limited to ten posts.
Returning Pagination Information
- Originally, the endpoint returned only a list of posts. (JsonResponse(posts_data, safe=False)
- JavaScript also needs to know current page, total pages, and whether previous/next pages exist.
return JsonResponse({
"posts": posts_data,
"current_page": posts_page.number,
"num_pages": post_paginator.num_pages,
"has_next": posts_page.has_next(),
"has_previous": posts_page.has_previous()
})
- The response is now a dictionary instead of a list, safe=False is no longer necessary.
Updating the Frontend
Parsing the New Response
After pagination, the server returns multiple pieces of information. Therefore, .then(posts => ...) became .then(data => ...). And posts are accessed through data.posts.
Separating Pagination Rendering
- Instead of making loadPosts() responsible for everything, I extracted pagination rendering into its own function renderPagination(data).
- So the flow became loadPosts() -> Render posts -> renderPagination() -> Render buttons. This keeps responsibilities separated and improves readability.
function renderPagination(data){
const paginationDiv = document.querySelector('#pagination');
paginationDiv.innerHTML = '';
const container = document.querySelector('#posts-container');
const baseUrl = container.dataset.url;
//Create previous button if there are prev pages.
if (data.has_previous) {
const previousBtn = document.createElement('button');
previousBtn.textContent = 'Previous';
previousBtn.onclick = () => {
loadPosts(`${baseUrl}?page=${data.current_page - 1}`);
};
paginationDiv.append(previousBtn);
}
//Current of total pages
const currentPageNum = document.createElement('span');
currentPageNum.textContent = `${data.current_page} of ${data.num_pages}`;
paginationDiv.append(currentPageNum);
//Create next button if there are next pages.
if (data.has_next) {
const nextBtn = document.createElement('button');
nextBtn.textContent = 'Next';
nextBtn.onclick = () => {
loadPosts(`${baseUrl}?page=${data.current_page + 1}`);
};
paginationDiv.append(nextBtn);
}
}
Dynamic API URLs
- Previously, loadPosts() always requested /posts.
- After refactoring, the page itself determines which API should be used.
- Each page stores its endpoint in a custom data attribute.
<div id="posts-container" data-url="/posts"></div> OR <div id="posts-container" data-url="/api/following">
- JavaScript reads this value using container.dataset.url and stores it in const baseUrl = container.dataset.url;
- Navigation buttons the request loadPosts(`${baseUrl}?page=${...}`).
- This allows the same JavaScript code to work for both All Posts and Following pages.
Rendering Navigation Buttons
- The server provides current page, total pages, previous page availability and next page availability. JavaScript uses these values to generate navigation controls dynamically.
- For example, if (data.has_previous){} creates the Previous button, while if (data.has_next){} creates the Next button. Clicking a button simply requests another page from the same API. loadPosts(`${baseUrl}?page=${data.current_page + 1}`)
Debugging
Pagination Controls Did Not Appear
> Problem
- Pagination buttons weren't displayed.
- After checking the rendering flow, I realized that renderPagination() had been implemented but never called.
> Solution
- Adding renderPagination(data); to the end of loadPosts() immediately fixed the issue.
Pagination on the Profile Page
- The profile page uses Django template rendering instead of JavaScript.
- Because the entire page is rendered on the server, pagination links are generated directly in the template.
<div class="pagination">
{% if posts.has_previous %}
<a href="?page={{ posts.previous_page_number }}">Previous</a>
{% endif %}
<span>{{ posts.number }} of {{ posts.paginator.num_pages }}</span>
{% if posts.has_next %}
<a href="?page={{ posts.next_page_number }}">Next</a>
{% endif %}
</div>
- These methods are provided automatically by Django's Page object. Likewise, {{ posts.paginator.num_pages }} accesses the associated Paginator object to retrieve the total number of pages.
- Unlike API-based pages, no JavaScript is required for profile pagination because the browser simply requests another HTML page through standard links.
Implementing Post Editing
The specification requires users to edit only their own posts without reloading the page. This feature combines authorization on the backend with dynamic DOM manipulation on the frontend.
The overall flow
Click Edit -> Replace post content with a textarea -> Click Save -> JavaScript sends PUT request -> Django updates database -> JSON response -> Reload posts
Identifying the Post Owner
- Only the author of a post should be allowed to edit it.
- When serializing each post, I added an additional field: "is_owner": request.user == post.user
- Django compares the currently authenticated user with the post author and returns either True or False. This boolean is included int he JSON response so that JavaScript can determine whether the Edit button should be displayed.
Rendering the Edit Button
if (post.is_owner) {
const editBtn = document.createElement('button');
editBtn.textContent = 'Edit';
editBtn.onclick = () => {
editPost(post, postDiv, editBtn);
};
postDiv.append(editBtn);
}
- While rendering posts, JavaScript checks the ownership flag. Only posts owned by the current user receive an Edit button.
- Instead of creating a separate Save button, I reused the existing button by changing its text and replacing its click handler after entering edit mode.
Switching to Edit Mode and Updating the Post (js)
function editPost(post, postDiv, editBtn) {
const content = postDiv.querySelector('.post-content');
const textarea = document.createElement('textarea');
textarea.value = post.content;
content.replaceWith(textarea);
...- When the Edit button is clicked, the original post content is replaced with a textarea.
- const content = postDiv.querySelector('.post-content') locates the paragraph displaying the post content.
- Then replace that element with a textarea so the user can edit the existing text directly.
- The original content is copied into the textarea.
editBtn.textContent = 'Save';
editBtn.onclick = () => {
// Save the edited post
fetch (`/editpost/${post.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({
content: textarea.value
})
})
.then(response => response.json())
.then(data => {
const container = document.querySelector('#posts-container');
loadPosts(container.dataset.url); //Reload after editing
});
};
}
- After switching to edit mode, the same button becomes the Save button. Clicking Save sends a PUT request.
- Unlike POST, which creates a new resource, PUT updates an existing one.
- The edited text is sent as JSON. After a successful response, the post list is reloaded so that the updated content appears immediately.
Edit post Backend (edit_post view)
@login_required
def edit_post(request, post_id):
if request.method != "PUT":
return JsonResponse({"error": "PUT request required."}, status=400)
post = get_object_or_404(Post, id=post_id)
if request.user != post.user:
return JsonResponse({"error": "You are not authorized"}, status=403)
data = json.loads(request.body)
content = data.get("content", "").strip()
if not content:
return JsonResponse({"error": "Post content cannot be empty."}, status=400)
post.content = content
post.save()
return JsonResponse({"message": "Post updated."})
Backend Authorization
- The edit endpoint first validates the request method. Only PUT requests are accepted.
- Using get_object_or_404() automatically returns a 404 response if the post does not exist.
- The serer verifies ownership. Only the original author is allowed to edit the post. This prevents users from modifying someone else's content even if they manually construct a request.
Updating the Database
- The edited content is extracted from the JSON request body. Using .strip() prevents posts containing only whitespace.
- Calling save() writes the updated content to the database. The server returns a JSON success response.
Debugging
Save Button Did Not Work
> Problem
The Edit button successfully switched to Save, but clicking Save did nothing. The browser console reported: Uncaught ReferenceError: csrfToken is not defined
> Investigation
At first, I suspected that the CSRF token itself was missing. However, the template already contained {% csrf_token %}, meaning the token existed in the HTML. The actual issue was that JavaScript never retrieved it.
> Solution
I created this:
function getCSRFToken() {
return document.querySelector('[name=csrfmiddlewaretoken]').value;
}
Both createPost() and editPost() reuse this function: 'X-CSRFToken': getCSRFToken()
This removed duplicated code while ensuring every modifying request includes the required CSRF token.
What I learned (Edit)
Implementing Post Editing demonstrated how frontend state and backend authorization work together. The frontend is responsible for presenting the editing interface and sending asynchronous requests, while the backend remains responsible for validating ownership before modifying the database. This feature also reinforced the importance of reusable helper functions and separating user interface logic from server-side security checks.
Implementing the Like Feature
Goal
The specification requires users to ike and unlike posts without reloading the page. Unlike the Edit feature, which reloads the post list after saving, the Like feature updates only the affected post by modifying the existing DOM.
The flow
Click Like -> JavaScript sends PUT request -> Django updates the Like relationship -> JSON response -> Update Like count and button text
Backend Implementation
@login_required
def like_post(request, post_id):
if request.method != "PUT":
return JsonResponse({"error": "PUT request required."}, status=405)
post = get_object_or_404(Post, id=post_id)
#Toggle like or unlike
if request.user in post.likes.all():
post.likes.remove(request.user)
else:
post.likes.add(request.user)
return JsonResponse({
"likes_count": post.likes.count(),
"liked_by_user": request.user in post.likes.all()
})
Toggling the Like State
- The endpoint accepts only PUT requests.
- The current user's like status determines whether the relationship should be added or removed.
- Unlike editing a post, this request updates the ManyToMany relationship between the current user and the post instead of modifying the post itself.
- The server returns the updated state. Returning both values allows the frontend to update the interface immediately without requesting the entire post list again.
Sending Authentication State
The frontend also needs to know whether the current visitor is authenticated.While serializing each post, I included "is_authenticated": request.user.is_authenticated.
Rendering the Like Button
if (post.is_authenticated) {
const likeBtn = document.createElement('button');
likeBtn.textContent = post.liked_by_user ? 'Unlike' : 'Like';
likeBtn.onclick = () => {
likePost(post, postDiv, likeBtn);
};
postDiv.append(likeBtn);
}
- During rendering, JavaScript checks the authentication state. The button text depends on whether the current user has already liked the post. Each button is associated with its corresponding post by passing the post object, its container, and the button itself to likePost().
Updating Only One Post
function likePost(post, postDiv, likeBtn) {
fetch(`/likepost/${post.id}/like`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
}
})
.then(response => response.json())
.then(data => {
//DOM update
const likes = postDiv.querySelector('.likes-count');
likes.textContent = `Likes: ${data.likes_count}`;
likeBtn.textContent = data.liked_by_user ? 'Unlike' : 'Like';
});
}
Clicking the button sends a PUT request. After receiving the JSON response, JavaScript updates only the affected elements. No additional fetch request is required, and the rest of the page remains unchanged.
Comparing Edit and Like
- The Edit feature and the Like feature use different update strategies.
- For Edit, I initially modified the DOM by replacing the post content with a textarea, but after saving I simply reloaded the entire post list using loadPosts(). This approach avaoided restoring the original DOM manually, but it also meant fetching every visible post again.
- For the Like feature, instead of reloading all posts, I updated only the affected DOM elements using the data returned from the server. This reduced unnecessary network requests, avoided rebuilding the entire post list, and provided a smoother user experience. The Like feature is closer to the behaviour expected in a Single Page Application.
Debugging
CSRF Token Error
Problem
Clicking the Like button produced this error: Uncaught TypeError: Cannot read properties of null (reading 'value') at getCSRFToken
Investigation
My previous implementation retrieved the CSRF token from: document.querySelector('[name=csrfmiddlewaretoken]').value. This worked on pages containing {% csrf_token %}. However, following.html did not include this hidden input. As a result, document.querySelector...) returned null, causing .value to throw an exception.
Solution
I changed the helper function to read the token directly from the browser's cookies.
function getCSRFToken() {
return document.cookie.split('; ')
.find(row => row.startsWith('csrftoken='))?.split('=')[1];
}
- Django stores the CSRF token as a cookie, making this approach reusable across every page without requiring a hidden input element.
- ?. prevents runtime errors if the cookie cannot be found.
CSS / UI Design
- I used a dark blue as the primary color for the navigation links and buttons, and used a light grey background for the main content area to distinguish it from the white post cards.
- Posts are displayed vertically as individual cards. Long post content is wrapped using CSS so that it does not overflow horizontally.
- I also styled the pagination controls and aligned the post action buttons to the right.
- Pagination controls were styled so that the Previous and Next buttons keep their positions even when one of them is unavailable. Instead of removing the unavailable button from the layout, I used visibility:hidden to preserve its space.
- I updated the pagination rendering logic so that the Previous and Next controls remain visually aligned across different pages.
-Long post content could overflow horizontally when it contained a long string without spaces. I used overflow-wrap and word-break to ensure that long content wraps within the post card.
- I grouped the Like and Edit buttons inside a separate container and aligned the buttons to the right side of each post card. This keeps the post content visually separate from the available actions.
- I initially rendered the Previous and Next buttons only when they were available. However, this caused the position of the current page indicator to change depending on the current page. To keep the pagination layout consistent, I always create both buttons and use visibility: hidden when a button is unavailable. This preserves the button's layout space while hiding it visually.
- I reused the same post-card styling on the profile page so that posts have a consistent appearance across All Posts, Following, and Profile pages.
Test
Authorization Testing: Preventing Unauthorized Post Edits
I tested whether a user could edit another user's post by sending a PUT request directly to the edit endpoint through the browser console.
- Log in as user2
- Identify a post created by user1 and obtain its post ID
- Send a PUT request to /editpost/20 with an edit request
- Check the HTTP response and the returned JSON error message
> Result
- The server returned 403 Forbidden with the error message.
- This confirmed that the server-side authorization check prevented user2 from editing user1's post.
- Authorization must be enforced on the server rather than relying only on hiding the Edit button in the frontend.

댓글 없음:
댓글 쓰기