Project 1: Wiki
Wiki - CS50's Web Programming with Python and JavaScript
Table of Contents
1. Understanding the Distribution Code Structure
- Project-Level URL Configuration(wiki/urls.py)
- App-Level URL Definitions(encyclopedia/urls.py)
- View Logic Overview(encyclopdeia/views.py)
- Utility Layer and Data Access(encyclopedia/util.py)
- Architectural Pattern Summary
2. Entry Page: Rendering Markdown-Based Content
- URL Routing for Entries
- View Logic and Request Handling
- Data Access via the Utility Layer
- Entry Template(entry.html)
- Error Handling Strategy(error.html)
- Markdown-to-HTML Conversion
- Request Flow Summary: Entry Page
3. Index Page: Listing All Entries
- Template Logic in index.html
- Separation of Concerns
- Advantages of the Index Page Design
- Summary: Index Page
4. Search Functionality
- Search Form Design(layout.html)
- GET vs POST in HTML Forms
- Accessing Query Data in Django Views
- Handling Empty Search Queries
- render() vs redirect()
- Exact Match Handling
- Early Return Pattern
- Substring Matching for Partial Results
- Search Results Template(search.html)
- Django Template Syntax in Practice
- Summary: Search Page
5. New Page
- Key Differences Between GET and POST Requests
- Design Considerations
- Why the View Must Handle Both GET and POST
- Detecting Duplicate Titles
- Why redirect() is Used After a Successful Save
- Implementation Steps
- Adding the New Pate URL
- Resolving AttributeError: module 'encyclopedia.views' had no attribute 'new'
- Verifying the URL -< View -< Template Flow
- Creating new.html
- Implementing the new View Function
- Summary: New Page Creation
6. Edit Page
- UI Issues in entry.html
- Duplicate Title Display
- Template Inheritance Problems
- Comparing Entry and Edit Pages
- Edit View Logic
- Get vs POST
- Encoding Issues on Windows(Korean Input)
- Request Flow Summary: Edit Pate
7. Random Page
- Implementation
- Errors Encountered and Solutions
- Request Flow Summary: Random Page
8. Final Check and Bug Fixes
- Preventing Empty Title Errors on Page Creation
- Implementing Case-Insensitive Search
- Improving error.html for Better UX
- Fixing a Missing Title Rendering Bug on Entry Pates
9. Result: Images and Video
10. Conclusion
Distribution Code: Structure
encyclopedia/ The Django app folder that holds the main logic for the wiki functionality
__pycache__/
migrations/
static/
encyclopedia/styles.css
templates/
encyclopedia/
index.html Displays a list of all entries
layout.html Base layout with navigation (sidebar, search form)
__init__.py
admin.py
apps.py
models.py
tests.py
urls.py Defines routes. Each route is tied to a view function
util.py Provides functions: list_entries, get_entry, save_entry / These abstract away file I/O and make it easier for view logic to operate on entries without duplicating code
views.py Contains Python view functions which accept web requests, and return rendered HTML pages.
entries/ A directory of Markdown files representing sample encyclopedia entries
CSS.md
Django.md
Git.md
HTML.md
Python.md
wiki/ The Django project folder containing configuration
__pycache__
__init__.py
asgi.py
settings.py General configuration
urls.py Global URL dispatcher. Typically routes traffic to the encyclopedia app
wsgi.py
db.sqlite3
manage.py Launches the development server and runs tasks like migrations
> I'm expected to add:
- entry.html: Display a single encyclopedia entry
- search.html: Show search results
- new.html
- edit.html
- error.html(optional)
Process
Code Analysis
Before implementing any features for the project Wiki, I analyzed the distribution code to understand how requests flow through the application and how responsibilities are separated across files.
Project-Level URL Configurations(wiki/urls.py)
The wiki/urls.py file serves as the entry point for all incoming HTTP requests to the project. Its responsibility is not to process requests directly, but to decide which Django app should handle them. All routes starting from the domain root(/) are forwarded to the encyclopedia app.
> Key observations:
- 'wiki/urls.py' doesn't contain any page logic.
- All user-facing pages are handled by a single app 'encyclopedia'.
- Since this project has only one app, every request is delegated to 'encyclopedia/urls.py'.
App-Level URL Definitions(encyclopedia/urls.py)
The 'encyclopedia/urls.py' file defines actual user-accessible URLs and maps them to specific views functions.
> Key points
- 'urlpatterns' contains the URL patterns handled by this app. / The empty string("") represents the root URL(/), 'views.index' is the function responsible for rendering the index page. / 'name="index"' allows this route to be referenced inside templates using Django's {% url %} tag.
- At this stage, only the main index route(/) is defined. Routes such as /search, /wiki/<TITLE> do not yet exist.
- I have to define a new path in 'encyclopedia/urls.py', create the corresponding view function in 'views.py'.
View Logic(encyclopedia/views.py)
The 'views.py' file contains the request-handling logic of the application.
- Each view function receives an HTTP request object. It retrieves or processes data as needed. And it returns an HTTP response, usually by rendering a template.
> def index(request)
- Calls 'util.list_entries()' to retrieve all encyclopedia entry names. Then passes this list to the template as a context variable named entries. Renders index.html, which displays the entries as a list.
> This establishes the core execution flow of the application.
- URL -> View Function -> Utility Function -> Template Rendering
Utility Layer(encyclopedia/util.py)
Unlike most Django projects, this assignment doesn't use a database. Instead, all encyclopedia entries are stored as Markdown files in the 'entries/' directory.
The 'util.py' module acts as a strict abstraction layer between the views and the file system.
> Constraints: View functions must not use open(), os, or direct file access. All interaction with entry files must go through 'util.py'
> Key functions
- list_entries(): Returns a sorted list of entry titles. Removes the .md extension. Used by the index page and search functionality.
- get_entry(title): Returns the raw Markdown content of an entry as a string. Returns 'None' if the entry does not exist. Used when displaying or editing a specific entry.
- save_entry(title, content): Creates a new entry or overwrites an existing one. Used by both the 'new' and 'edit' page.
This design centralized all file I/O logic in a single module and keeps the view layer clean and focused on request handling.
Architectural Pattern Summary
Project URLs(wiki/urls.py) -> App URLs(encyclopedia/urls.py) -> View Functions, Logic (encyclopedia/views.py) -> Data Access(util.py) -> Template Rendering(templates/)
1. Entry Page
The Entry Page displays an individual encyclopedia entry when a user visits a URL.
> This four elements must be connected for the entry page to function correctly.
1. URL configuration (encyclopedia/urls.py)
2. View logic (encyclopedia/views.py)
3. Template (encyclopedia/templates/encyclopedia/entry.html)
4. Markdown to HTML conversion (markdown2)
URL Routing (encyclopedia/urls.py)
First, define a URL pattern that captures the entry title directly from the URL.
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
path("<str:title>/", views.entry, name="entry")
]
- <str:title>/ captures the URL segment as a string parameter.
- When a user visits '/Python', Django extracts "Python" and passes it to the 'entry' view function.
- The route is named 'entry', allowing it to be referenced inside templates if needed.
View Logic (encyclopedia/views.py)
The 'entry' view function is responsible for handling requests to individual entry pages.
import markdown2
from django.shortcuts import render
from . import util
def index(request):
return render(request, "encyclopedia/index.html", {
"entries": util.list_entries()
})
def entry(request, title):
content = util.get_entry(title)
if content is None:
return render(request, "encyclopedia/error.html",{
"message": "Page not found."
})
else:
return render(request, "encyclopedia/entry.html",{
"title":title,
"content": markdown2.markdown(content)
})
> The execution flow
1. The title parameter is received from the URL.
2. util.get_entry(title) attempts to load the corresponding Markdown file.
3. If no entry exists, None is returned, an error page is rendered.
4. If the entry exists, the Markdown content is converted to HTML using markdown2. The converted HTML and title are passed to the template.
Data Access Layer (encyclopedia/util.py)
The project doesn't use a database. Instead, all entries are stored as Markdown files in the 'entries/' directory.
def get_entry(title):
"""
Retrieves an encyclopedia entry by its title. If no such
entry exists, the function returns None.
"""
try:
f = default_storage.open(f"entries/{title}.md")
return f.read().decode("utf-8")
except FileNotFoundError:
return None
- The function returns the raw Markdown string, not HTML.
- If the file doesn't exist, it returns None.
- Markdown-to-HTML conversion is intentionally not handled here.
- All file access is abstracted into 'util.py', and 'views' never read files directly.
Entry Template (entry.html)
<h1>{{ title }}</h1>
{{ content|safe }}
- {{ content|safe }} renders the already-converted HTML.
- By default, Django escapes HTML to prevent XSS attacks. Since markdown2.markdown() returns a trusted HTML string, the 'safe' filter explicitly tells Django to render it as HTML instead of plain text. Without '|safe', HTML tags would appear as raw text and break the page layout.
- At this page, no layout or styling is required. Additional design elements can be added later if needed.
Error Handling (error.html)
<h1>Error</h1>
<p>{{ messsage }}</p>
- Instead of hard-coding error messages in this template, messages are passed dynamically from the view. This approach allows the same template to be reused for different error scenarios while keeping presentation and logic clearly separated. ( "message": "Page not found.")
Markdown-to-HTML conversion
Markdown conversion occurs in the 'view', not in 'util.py and the template.
> The sequence
1. Load Markdown using 'util.get_entry(title)
2. Convert Markdown to HTML in 'views.py'
3. Pass HTML to the template
4. Render using {{ content|safe }}
- This ensures that 'util.py' remains a pure data-access layer, and the templates remain free of business logic.
Request Flow: Entry Page
User URL(/Python) -> encyclopedia/urls.py -> views.entry(request, title) -> util.get_entry(title) -> Markdown-to-HTML conversion -> entry.html rendered
2. Index Page(index.html)
The index page is the entry point of the Wiki. It displays a list of all available encyclopedia entries and provide navigation to individual entry pages. This page retrieves data from the backend and renders it dynamically in a template.
{% extends "encyclopedia/layout.html" %}
{% block title %}
Encyclopedia
{% endblock %}
{% block body %}
<h1>All Pages</h1>
<!--{% url 'URL name' title %}-->
<ul>
{% for entry in entries %}
<li>
<a href="{% url 'entry' entry %}">{{ entry }}</a>
</li>
{% endfor %}
</ul>
{% endblock %}
Template Logic
- {% for entry in entries %}: Iterates over the list provided by the view. Each entry represents a single encyclopedia title, for example, "Django" or "Python".
- {% url 'entry' entry %} : Generates a URL using Django's URL reversing system. / 'entry' refers to the name of the URL pattern defined 'encyclopedia/urls.py'. / entry is passed as the title parameter in the URL.
- This ensures that URLs are not hard-coded, and if the URL structure changes later, the templates doesn't need to be updated.
- For example, if entry is "Django", the generated link points to /Django.
Separation & Advantages
- View: Retrieves the list of entries
- Template: Controls how the list is diaplayed
- URL configuration: Defines how entry titles map to view functions.
> The template itself contains no business logic and does not access the file system or perform data processing. Presentation only.
- New entries automatically appear without modifying the template. URL changes are centralized in 'urls.py' and the index page remains simple.
Summary : Index Page
On the index page, the Django template iterates over the entries list, assigning each value to the variable entry in turn. The same entry value is used in two different contexts: {{ entry }} renders the entry title as visible text on the page, while {% url 'entry' entry %} uses that values as a parameter to dynamically generate a link. Django resolves the URL pattern named entry and inserts the current entry title into the URL.
The URL pattern path("<str:title>/", views.entry, name="entry") defines a dynamic URL segment. It means that any string apperaring at that position in the URL is captured as title and passed to the entry view function. When a user clicks a link such as /Django, the string "Django" is extracted from URL and received as the title argument in def entry(request, title).
3. Search Page(layout.html & search.html)
The layout.html file acts as the base template for the entire application. All other pages inherit from this template, which ensures a consistent layout and shared UI elements across the site.
The search form is placed in the sidebar and uses the GET method, allowing search queries to be included directly in the URL. The form's action attribute must point to the search URL. Otherwise, submitting the form will not trigger the search functionality.
1. Search Form(layout.html)
> layout.html
<head>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<link href="{% static 'encyclopedia/styles.css' %}" rel="stylesheet">
</head>
<body>
<div class="row">
<div class="sidebar col-lg-2 col-md-3">
<h2>Wiki</h2>
<form action="{% url 'search' %}" method="get">
<input class="search" type="text" name="q" placeholder="Search Encyclopedia">
</form>
<div>
<a href="{% url 'index' %}">Home</a>
</div>
<div>
Create New Page
</div>
<div>
Random Page
</div>
{% block nav %}
{% endblock %}
</div>
<div class="main col-lg-10 col-md-9">
{% block body %}
{% endblock %}
</div>
</div>
> form action="{% url 'search' %}" method="get">
- Originally, pressing enter in the search box triggered a GET request to the current page, simply appending ?q=... to the URL without invoking the search logic. To ensure that search requests are handled by the search view, the form's action must explicitly point to the search URL.
- Using method="get" sends the user's input as a query string(?q=...), which is appropriate for search functionally. Since 'layout.html' is inherited by all pages, placing the search form here ensures that search is globally available across the application.
> path("search/", views.search, name="search")
- Add the search URL by writing this path in encyclopedia/urls.py.
2. GET vs. POST in HTML forms
- HTML forms support two main submission methods.
1) GET: Appends data to the URL as a query string.
2) POST: Sends data in the request body.
- The search input uses name="q", meaning Django receives the data as a key-value pair: "q":"Python"
> views.py - def search(request)
def search(request):
query = request.GET.get("q", "").strip()
#Redirect to index if the search query is empty
if query == "":
return redirect("index")
entries = util.list_entries()
#If the query exactly matches an entry, redirect to that entry page
if query in entries:
return redirect("entry", title=query)
#Otherwise, find all entries that contain the query as a substring
searchResults = []
for entry in entries:
if query.lower() in entry.lower():
searchResults.append(entry)
#Render the search results page
return render(request, "encyclopedia/search.html",{
"query": query,
"searchResults": searchResults
})
-> (modified)
for entry in entries:
if entry.lower() == query.lower():
return redirect("entry", title=query)
3. Accessing Query Data in Django Views
query = request.GET.get("q", "").strip()
> You can retrieve data using the get method on an object called request, but the get method can only be used on dictionary-like objects. However, the request argument when defining a view in Django is not a dictionary object.
- In Django, request.GET can be used to obtain the contents of the request. And the get method returns None if there's no target data. request.GET can converts the request into a dictionary object. Therefore, request.GET.get is used to convert the request into dictionary data while retrieving the data.
The request object passed to a view is not a dictionary. Django provides request.GET, which is a QueryDict that behaves like a dictionary and allows safe data access.
> What does this line do
- Retrieves the value associated with "q" from the GET request.
- If "q" does not exist, uses an empty string as a default.
- Removes leading and trailing whitespace with .strip()
> Why the default value is necessary
- Users may submit the form with no input, directly visit /search/ via the address bar.
- Without this, missing keys could cause errors.
4. Handling Empty Search Queries
If the search query is empty, the user is redirected to the index page instead of running the search logic.
if query == "":
return redirect("index")
- When the user presses Enter without typing anything or directly enters /search/ in the address bar, query=request.GET.get("q", " ").strip() returns query=="".
- Without this condition, since an empty string is a subset of all strings, every entry would appear in the search results.
> render vs. redirect
- render: URL remains unchanged / Display a page with specific data / Returns an HttpResponse with the rendered template
- redirect: Changes the URL to the target location / Navigate the user to another page or resource / Returns an HttpResponseReidrect ot the specified URL
= redirect("index") instructs the browser to navigate to a new URL, unlike render, which displays a template while keeping the same URL. Since an empty query means "do not search", redirecting is the correct control flow.
5. Exact Match Handling with Redirect
If the query exactly matches an entry title, the user is redirected directly to that entry page.
if query in entries:
return redirect("entry", title=query)
- This fines the URL pattern named "entry" and passes the query as the title parameter, which is then received by def entry(request, title)
- This avoids unnecessary rendering of a search results page when an exact match exists.
6. Early Return Pattern
The search view uses early returns instead of nested if-else blocks. Each condition that fully determines the responses returns immediately.
- This pattern improves readability, clearly separates different input cases, prevents unnecessary computation.
7. Substring Matching for Partial Results
For non-exact matches, the search logic iterates over all entries and collects those where the query is a substring of the entry title.
searchResults = []
for entry in entries:
if query.lower() in entry.lower():
searchResults.append(entry)
- in: Checks for substring containment, not equality.
- 'for entry in entries' is different from 'query in entries', which checks whether the query exactly matches an element in the list.
- .lower(): Returns a new string with all uppercase letters converted to lowercase, leaving numbers and symbols unchanged.
> An alternative: Uses list comprehension, but the logical intent remains the same.
results = [
entry for entry in entries
if query.lower() in entry.lower()
]
8. VSC code colour
Q. In the line if query.lower() in entry.lower():, the character in VSC is white for 'query.lower()' and yellow for 'entry.lower()'.
A. This is because 'entry' is retrieved from 'util.list_entries', which is explicitly a list of strings, so it is guaranteed to be a string. Conversely, 'query' is retrieved as 'GET.get()', from user input. And it could be None or an unexpected type without safeguards.
9. Search.html
{% extends "encyclopedia/layout.html" %}
{% block title %}
Search Results
{% endblock %}
{% block body %}
<h1>Search Results for "{{ query }}"</h1>
{% if searchResults %}
<ul>
{% for entry in searchResults %}
<li>
<a href="{% url 'entry' entry %}">{{ entry }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>No Results Found.</p>
{% endif %}
{% endblock %}
- {% if searchResults %} returns False if the list is empty, and True if it contains at least one item. And if it's True, {% if searchResults %} iterates through the entries for output.
> Django Template Syntax
- {{ }}: Value output / Outputs the variable value passed from the view unchanged
- {% %}: Executes actions / Includes conditional statements like {% if ... %}, loops, URLs, endfor tags, and function calls.
Summary: Search Page
When a user enters a search term in the sidebar and submits the form, a GET request is sent to the /search/ URL with the query stored under the key. The search view safety retrieved and normalises this value, then evaluates it through a series of early-return conditions. Empty queries redirect back to the index page, exact matches redirect directly to the corresponding entry page, and partial matches generate a list of relevant results. This flow prioritises efficiency and clarity while maintaining a clean separation between routing, logic, and presentation.
4. Create New Page
Key Differences between GET and POST
1. Visibility
- GET: Parameters are included in the URL and visible to everyone.
- POST: Data is not displayed in the URL but in the HTTP message body.
2. Security
- GET: Less secure as the URL contains part of the data sent.
- POST: Safer as the parameters are not stored in web server logs or browser history.
3. Cache
- GET: Requests can be cached and remain in the browser history
- POST: Cannot be cached
4. Server State
- GET: Retrieve data without modifying the server's state.
- POST: Send data to the server for processing and may modify the server's state
5. Amount of Data
- GET: Limited by URL length
- POST: Has no limitation as it sends data through the HTTP message body
6. Data Type
- GET: Supports only string data types
- POST: Supports different data types such as string, numeric, and binary
Considerations
1) Why the New Page Must Handle Both GET and POST Requests
- A GET request to /new/ means "I want to create a new page". Its role is to display an empty form where the user can enter a title and content.
- A POST request to /new/ means "Save this content". In this case, the server must validate the submitted data, decide whether it can be stored or return an error if something is wrong.
> From an HTTP perspective
- GET requests expose data in the URL, are cacheable, and should not change server state.
- POST requests send data in the request body and are designed for form submissions and data creation.
> Since creating a new wiki entry modifies application data, handling POST requests is essential.
2) Where and How to Check for Duplicate Titles
- Duplicate title checks should be performed in views.py, and only when handling a POST request.
- The view is responsible for validating user input before saving it. To check whether an entry already exists, the view should call the utility function responsible for data access.
3) Why Redirect Is Used Instead of Render After a Successful Save
- After successfully saving a new entry, the application should redirect, not render.
- render returns HTML directly while keeping the current URL. redirect instructs the browser to navigate to a different URL.
- In this case, after saving, the user is expected to move to the newly created entry page.
- Django follows the POST-Redirect-GET pattern. If render were used after a POST request, refreshing the page would resend the same POST data, potentially creating duplicate entries. By using redirect, the browser performs a new GET request after the save, making the operation safe and idempotent on refresh.
Implementation Process
1. Adding the New Page URL
> First, register a new route in encyclopedia/urls.py.
path("new/", views.new, name="new")
- This defines /new/ as the URL responsible for creating a new entry and assigns it the name "new", which allows the URL to be referenced from templates.
> Next, convert "Create New Page" text in layout.html into an actual link.
<a href="{% url 'new' %}">Create New Page</a>
2. AttributeError: module 'encyclopedia.views' has no attribute 'new'
> After adding the URL and clicking the link, the browser raised the following error.
- This error occurred because the URL configuration correctly attemptedto route /new/ to views.new, but the new view function hadn't yet been defined. This confirmed that the URL-to-view connection was working as expected.
3. Verifying URL -> View -> Template Flow &
> To verify the request flow, I created a minimal new.html template and a temporary view: <h1>New Page</h1> & def new(request): return render(request, "encyclopedia/new.html")
- However, clicking "Create New Page" rendered the error page(error.html) instead.
> Solving process
- This issue was caused by the order of URL patterns in encyclopedia/urls.py.
- Django evaluates URL patterns from top to bottom. Because the dynamic pattern("<str:title>/") appeared before path("new/", ...), the URL /new/ was incorrectly matched as title="new". As a result, Django executed views.entry(request, "new"). Since no entry "new" existed, util.get_entry("new") returned None, and the error page was rendered.
- So I placed fixed string URLs before dynamic URL patterns
urlpatterns = [
path("", views.index, name="index"),
path("search/", views.search, name="search"),
path("new/", views.new, name="new"),
path("<str:title>/", views.entry, name="entry")
]
- This ensures that specific routes are matched before generic ones.
4. Creating new.html
The new.html template contains a form that submits a POST request to create a new entry.
{% extends "encyclopedia/layout.html" %}
{% block title %}
New Page
{% endblock %}
{% block body %}
<h1>Create New Page</h1>
<form method="post">
{% csrf_token %}
<div class="form-group">
<label for="title">Entry Title</label>
<input class="form-control" type="text" name="title" id="title" placeholder="Title">
</div>
<div class="form-group">
<label for="content">Entry Content</label>
<textarea class="form-control" name="content" id="content" rows="10" placeholder="Content"></textarea>
</div>
<button class="btn btn-primary" type="submit">Save</button>
</form>
{% endblock %}
- {% csrf_token %} is required because Django blocks POST request without a CSRF token.
- Bootstrap form and button components were used for styling(https://getbootstrap.com/docs/4.4/components/forms/#form-controls)
- Each <input> includes a name attribute, which Django uses to extract submitted values.
- label elements are connected to inputs via for and id attributes to improve accessibility.
5. Implementing the new View Function(views.py - def new(request))
def new(request):
if request.method == "POST":
title = request.POST.get("title", "").strip()
content = request.POST.get("content", "").strip()
if util.get_entry(title) is not None:
return render(request, "encyclopedia/error.html", {
"message": "An entry with this title already exists."
})
else:
util.save_entry(title, content)
return redirect("entry", title=title)
else:
return render(request, "encyclopedia/new.html")
-> (modified)
def new(request):
if request.method == "POST":
title = request.POST.get("title", "").strip()
content = request.POST.get("content", "").strip()
if not title or not content:
return render(request, "encyclopedia/error.html",{
"message":"Title and content cannot be empty."
})
if util.get_entry(title) is not None:
return render(request, "encyclopedia/error.html", {
"message": "An entry with this title already exists."
})
else:
#To handle duplicate titles
content = f"# {title}\n\n{content}"
util.save_entry(title, content)
return redirect("entry", title=title)
else:
return render(request, "encyclopedia/new.html")
- title = request.POST.get("title", "") is used instead of request.POST["key"] to avoid KeyError when fields are missing or empty.
- .strip() removes unnecessary whitespace from user input.
- POST requests validate input, check for duplicate titles, and save data.
- GET requests render the empty form(new.html). Sidebar link <a href="{% url "new" %} is a GET request, so else: executes and renders new.html.
- After successful creation, redirect is used to navigate to the newly created entry page.
- Using redirect instead of render follows Django's POST-Redirect-GET pattern and prevents duplicate submissions on page refresh.
- Although an early return could have been used, I chose the if-else structure to make the two nested conditions in the new function explicit.
Summary
To implement the New Page feature, I needed to understand how Django resolves URLs, how GET and POST requests represent different user intentions, and how view logic coordinates validation, persistence, and navigation. I made my New Page functionality integrates cleanly into the existing Wiki application architecture by carefully ordering URL patterns, safety handling user input, and following Django's request-handling conventions.
5. Creating Edit Page
urls.py(edit)
> To implement the Edit feature, I first added a new URL pattern.
path("edit/", views.edit, name="edit"),
And linked it from entry.html using this
<a href="{% url 'edit' title %}" class="btn btn-secondary">Edit</a>
- This means: Find the URL named 'edit' and pass the current entry title as an argument.
- Initially, I encountered a NoReverseMatch error. (NoReverseMatch at /CSS/ Reverse for 'edit' with arguments '('CSS',)' not found. 1 pattern(s) tried: ['edit/\Z'])
- The URL pattern did not accept any parameters(edit/), while the template was passing one(title).
- Updating the path to include <str:title> resolved the mismatch.
path("edit/<str:title>/", views.edit, name="edit"),
Two UI Issues(entry.html)
> I fixed two UI issues on the entry page.
1. Duplicate Title Issue- The Markdown content already contained a heading, while entry.html was also explicitly rendering {{ title }} in the body. Since the Markdown was converted to HTML using markdown2, it produced its own <h1>
-> Removing {{ title }} from the body solved this.
- However, keeping {% block title %}{{ title }}{% endblock %} did not cause issue again.
- Because this block only affects the <title> tag in the HTML <head>, which is visible in the browser tab, not in the page body.
2. Template Inheritance
- The Edit button initially didn't display with Bootstrap styling.
- This happened because entry.html did not extend layout.html, where Bootstrap is loaded.
- Once entry.html inherited from layout.html, Bootstrap styles applied correctly, restoring consistent UI behaviour across pages(edit, new...)
entry.html(modified)
{% extends "encyclopedia/layout.html" %}
{% block title %}
{{ title }}
{% endblock %}
{% block body %}
{{ content|safe }}
<a href="{% url 'edit' title %}" class="btn btn-secondary">Edit</a>
{% endblock %}
edit.html
{% extends "encyclopedia/layout.html" %}
{% block title %}
Edit {{ title }}
{% endblock %}
{% block body %}
<h1>Edit {{ title }}</h1>
<form method="post">
{% csrf_token %}
<div class="form-group">
<label for="content">Entry Content</label>
<textarea class="form-control" name="content" id="content" rows="10">{{ content }}</textarea>
</div>
<button class="btn btn-primary" type="submit">Save Changes</button>
</form>
{% endblock %}
- Follows the same HTTP semantics as the New Page feature. When accessed via GET(by clicking the Edit Button), Django calls views.edit(request, title) and renders edit.html with the existing Markdown content loaded from util.get_entry(title). This content is passed directly into a <textarea> so the user can edit the raw Markdown, not the rendered HTML.
Edit vs Entry
- entry view: Converts Markdown -> HTML using markdown2.markdown() and displays it.
- edit view.: Shows the raw Markdown inside a <textarea> for editing.
edit View Logic(views.py - def edit)
def edit(request, title):
if request.method == "POST":
content = request.POST.get("content", "").strip()
util.save_entry(title, content)
return redirect("entry", title)
else:
content = util.get_entry(title)
if content is None:
return render(request, "encyclopedia/error.html",{
"message":"Page Not Found"
})
return render(request, "encyclopedia/edit.html", {
"title":title,
"content":content
})
> GET vs POST
- POST request: Read Updated content from request.POST.get("content", "") / Overwrite the existing entry using util.save_entry(title, content) / Redirect to the updated entry page.
= When the form is submitted via POST, request.POST.get("content" retrieves the updated text from the <textarea name="content">. At this stage, Markdown conversion is intentionally avoided. Instead, the updated Markdown is saved directly using util.save_entry, overwriting the existing entry. After saving, the user is redirected back to the entry page, where the Markdown is rendered into HTML again.
- GET request: Retrieve existing content using util.get_entry(title) / If the entry doesn't exist, render error.html / Otherwise, render edit.html with the original Markdown content.
- Although entry() already checks for missing content and renders an error page if an entry doesn't exist, it is still reasonable for edit() to perform its own existence check. The edit view must perform its own validation because it can be accessed independently via URL.
Encoding Issue on Windows(Korean Input)
> Problem: While testing the Edit feature with Korean input, a UnicodeDecodeError occurred(UnicodeDecodeError: 'utf-8' codec can't decode byte 0xbe).
> Cause: Inconsistent encoding between file writing and reading. On Windows, Python defaults to cp949 when no encoding is specified in open(). As a result, util.save_entry wrote Korean text using cp949, while util.get_entry later attempted to read the same file assuming UTF-8, which caused the decoding failure. / CS50's Linux environment does not expose this issue because UTF-8 is the default encoding.
- Browser -> Django form submission: URF-8(web standard)
- File writing on Windows(default): cp949
- File reading later: UTF-8
> Solution: In a Windows environment, the correct solution is to explicitly specify encoding="utf-8" in both save_entry and get_entry. This ensures consistency with Django, Markdown processing and web standards, all of which assume UTF-8. Any files previously saved with the wrong encoding need to be recreated to avoid lingering decode errors.
> Question: But if the problem was with Windows' default encoding method, wouldn't the same issue occur even when inputting English rather than Korean?
A: It is important how character encodings work at the byte level.
- English characters occupy the same byte values in both UTF-8 and cp949. So English works because ASCII bytes are compatible across encodings.
- Korean require multi-byte representations, and the byte sequences used by cp949 and UTF-8 are completely incompatible.
- The real issue is mismatched encodings between file write and file read, not Windows itself. Explicitly setting encoding="utf-8" ensures consistent behaviour regardless of operating system or language input.
Flow Summary
1. User clicks Edit on an entry page
2. /edit/<title>/ URL resolves to edit(request, title)
3. GET -> Load existing Markdown into a form
4. POST -> Save updated Markdown and redirect to entry page
5. Entry page renders Markdown as HTML
6. Random Page
Implementation
To implement a Random Page feature, I added this link to layout.html first.
<a href="{% url 'random' %}">Random Page</a>
The link points to a URL named random. This URL is mapped in urls.py to a corresponding view.
path("random/", views.random, name="random"),
The view logic is responsible for selecting a random encyclopedia entry and redirecting the user to that page.
import random
def random_page(request):
entries = util.list_entries()
title = random.choice(entries)
return redirect("entry", title=title)
Error
However, an AttributeError occurred when the browser was opened.
AttributeError: 'function' object has no attribute 'choice'
> Cause: A name collision in Python's namespace
- 'random' is the name of a standard library module that provides the choice() function.
- At the same time, the view function was also named 'random'.
- In Python, function names and module names share the same namespace.
- When 'def random(request):' is executed, the name 'random' is rebound to the function object, overwriting the reference to the imported 'random' module.
- As a result, random.choice() attempts to call choice() on the view function, not the 'random' module, which causes the error.
> Solution
- (views.py) The issue was resolved by renaming the view function.
def random_page(request):
entries = util.list_entries()
title = random.choice(entries)
return redirect("entry", title=title)
- (urls.py) Update the URL configuration.
path("random/", views.random_page, name="random"),
Flow Summary
1. The user clicks Random Page in the sidebar.
2. A GET request is sent to /random/.
3. Django routes the request to random_page.
4. util.list_entries() retrieves a list of all available entry titles.
5. random.choice(entries) selects one title at random.
5. The user is redirected to the corresponding entry page using redirect("entry", title=title).
Final Check
1. New Page: Empty Title Error
While testing the New Page feature, NoReverseMatch error occurred when submitting the form with an empty title.
NoReverseMatch at /new/
Reverse for 'entry' with keyword arguments '{'title': ''}' not found. 1 pattern(s) tried: ['(?P<title>[^/]+)/\\Z']
> Cause
The error indicates that Django attempted to reverse the URL named entry using an empty string as the title argument. However, the URL pattern is defined as path("<str:title>/", views.entry, name="entry"). The <str:title> doesn't accept empty strings, so Django cannot construct a valid URL when title=="".
This happened because the form submission allowed empty titles, and the code attempted to redirect to redirect("entry", title=title) even when title was empty.
> Fix
To prevent invalid entries from being saved, I added a validation check to the new view.
if not title or not content:
return render(request, "encyclopedia/error.html",{
"message":"Title and content cannot be empty."
})
This ensures that both the title and content must be provided before saving a new entry.
> Reverse for 'entry' with arguments '('',)' not found
After fixing the validation logic, another error appeared on the index page. This error was related to the line in index.html:<a href="{% url 'entry' entry %}">{{ entry }}</a>.
- This error meant that an empty string was present in the entries list. The cause was an earlier failed submission that created a Markdown file with no filename( .md).
=> I manually deleted the invalid .md file with no title.
2. Search: Case-Insensitive Matching
The initial search required an exact, case-sensitive match between the user's query and an existing entry title. For example, searching for 'python' would not match 'Python'.
- My initial idea was if query.lower() in entries.lower():. However this approach is invalid because 'entries' is a list, and .lower() can only be applied to strings.
> Fix
To make the search case-insensitive, each entry must be compared individually inside a loop.
for entry in entries:
if entry.lower() == query.lower():
return redirect("entry", title=query)
3. error.html(modified)
{% extends "encyclopedia/layout.html" %}
{% block title %}
Error
{% endblock %}
{% block body %}
<h1>Error</h1>
<p>{{ message }}</p>
{% endblock %}
3. When I created a new page, the resulting entry page didn't display the title.
> Bug
When I created a new page, the resulting entry page did not display the title.
To fix this, I initially added <h1>{{ title }}</h1> to entry.html. While this solved the issue for newly created pages, it caused a new problem: existing entry pages started displaying the title twice.
> Cause
The reason for this behaviour is that existing entries already contain a Markdown heading (# Title) inside their content. By adding <h1>{{ title }}</h1> in the template, the title was rendered twice. Once by the template and once by the Markdown-to-HTML conversion.
> Fix
- From this, I concluded that the title on the entry page should exist only inside the Markdown content, not in the template. The template should simply render the converted HTML without manually inserting a heading.
- I modified the page creation logic. When a user creates a new page, the title they enter is automatically added to the content in Markdown format before saving it. The title is now always part of the Markdown document, and the template does not need to include <h1> explicitly.
- In the 'new' view, I updated the content just before saving.
content = f"# {title}\n\n{content}"
- I used an f-string(Python's formatted string literal). The expressions {title} and {contnet} are replaced with their respective variable values. For example, if 'title="Create" and content="New Page", the resulting string becomes like this.
# Create
New Page
- In Markdown, # represents a level 1 heading(equivalent to <h1> in HTML). When this content is passed through markdown2.markdown(), it's converted into proper HTML. As a result, the title is rendered correctly on the entry page without requiring any <h1> tag in the template.
- The \n\n is used to separate the heading from the body text, matching the structure of other entries.
Result: Images
4. Substring search
5. When the entry does not exist(Search bar)
6. New Page
7. When the entry akreadt exists8. When a new page is created
9. Edit page
Conclusion
This Wiki Project was more than an exercise in building a simple Django application. It became a practical lesson in how a web application is structured, how data flows through different layers, and how small design decisions affect usability and maintainability.
Through this project, I gained a understanding of Django's request-response cycle, particularly how URLs, views, templates, and utility functions interact. Separating responsibilities between the view layer and the data access layer(utill.py) helped reinforce the importance of clean architecture and modular design.
Implementing features such as search, page creation, editing, and random page navigation highlighted the differences between render and redirect, as well as the practical implications of using GET and POST requests appropriately. Handling edge cases like duplicate entries, empty titles, and case-insensitive search emphasized that applications are defined as much by error handling as by core functionality.
The Markdown-to-HTML conversion demonstrated how raw user-generated content can be safely transformed and presented, a pattern commonly used in content-driven platforms.
This project required reading existing code, extending it thoughtfully, and debugging issues that emerged from both logic and environment-specific constraints.
A separate post documents the Git and submission-related issues I encountered and how I resolved them.

댓글 없음:
댓글 쓰기