DEV Community

Cover image for Build Your Own Forum with FastAPI: Step 6 - Comments and Replies
Leapcell
Leapcell

Posted on

Build Your Own Forum with FastAPI: Step 6 - Comments and Replies

Cover

In the previous article, we added the post editing feature to our forum, allowing users to modify their published content.

Besides posting, interaction is essential for a forum. When users see an interesting (or controversial) post, they'll want to express their opinions.

In this article, we will add an interaction feature to our forum: implementing post comments and replies, allowing users to have discussions around posts.

Step 1: Update the Database Model

We need a new table to store comments. Furthermore, comments themselves need to support replies to form a hierarchical structure.

In models.py, add the Comment model and update the User and Post models to establish relationships.

models.py (Updated)

from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship
from database import Base

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=True, index=True)
    hashed_password = Column(String)

    posts = relationship("Post", back_populates="owner", cascade="all, delete-orphan")
    comments = relationship("Comment", back_populates="owner", cascade="all, delete-orphan")

class Post(Base):
    __tablename__ = "posts"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String, index=True)
    content = Column(String)
    owner_id = Column(Integer, ForeignKey("users.id"))

    owner = relationship("User", back_populates="posts")
    comments = relationship("Comment", back_populates="post", cascade="all, delete-orphan")

class Comment(Base):
    __tablename__ = "comments"

    id = Column(Integer, primary_key=True, index=True)
    content = Column(String, nullable=False)
    post_id = Column(Integer, ForeignKey("posts.id"))
    owner_id = Column(Integer, ForeignKey("users.id"))
    parent_id = Column(Integer, ForeignKey("comments.id"), nullable=True)

    owner = relationship("User", back_populates="comments")
    post = relationship("Post", back_populates="comments")

    # Self-referencing relationship for replies
    parent = relationship("Comment", back_populates="replies", remote_side=[id])
    replies = relationship("Comment", back_populates="parent", cascade="all, delete-orphan")
Enter fullscreen mode Exit fullscreen mode

Here are the main changes we made:

  1. Created the Comment model, linking it to posts and users via post_id and owner_id, respectively. The parent_id field points to the id of another comment. If it's NULL, it's a top-level comment; otherwise, it's a reply.
  2. Updated the User and Post models, adding a relationship to Comment. cascade="all, delete-orphan" ensures that when a user or post is deleted, their associated comments are also deleted.

Next, create this new table in your database. The corresponding SQL statement is as follows:

CREATE TABLE comments (
    id SERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    post_id INTEGER NOT NULL,
    owner_id INTEGER NOT NULL,
    parent_id INTEGER,
    CONSTRAINT fk_post FOREIGN KEY(post_id) REFERENCES posts(id) ON DELETE CASCADE,
    CONSTRAINT fk_owner FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE,
    CONSTRAINT fk_parent FOREIGN KEY(parent_id) REFERENCES comments(id) ON DELETE CASCADE
);
Enter fullscreen mode Exit fullscreen mode

If your database was created using Leapcell,

ImageLc

you can execute these SQL statements directly in its web-based operation panel.

ImageDb

Step 2: Create the Post Detail Page and Comment Section

Currently, all posts are displayed on the homepage. To make space for a comment section, we need to create a separate detail page for each post.

First, create a new file post_detail.html in the templates folder.

templates/post_detail.html

<!DOCTYPE html>
<html>
  <head>
    <title>{{ post.title }} - My FastAPI Forum</title>
    <style>
      body {
        font-family: sans-serif;
        margin: 2em;
      }
      .post-container {
        border: 1px solid #ccc;
        padding: 20px;
        margin-bottom: 20px;
      }
      .comment-form {
        margin-top: 20px;
      }
      .comments-section {
        margin-top: 30px;
      }
      .comment {
        border-left: 3px solid #eee;
        padding-left: 15px;
        margin-bottom: 15px;
      }
      .comment .meta {
        font-size: 0.9em;
        color: #666;
      }
      .replies {
        margin-left: 30px;
      }
    </style>
  </head>
  <body>
    <div class="post-container">
      <h1>{{ post.title }}</h1>
      <p>{{ post.content }}</p>
      <small>Author: {{ post.owner.username }}</small>
    </div>

    <hr />

    <div class="comment-form">
      <h3>Post a Comment</h3>
      {% if current_user %}
      <form action="/posts/{{ post.id }}/comments" method="post">
        <textarea name="content" rows="4" style="width:100%;" placeholder="Write your comment..." required></textarea><br />
        <button type="submit">Submit</button>
      </form>
      {% else %}
      <p><a href="/login">Log in</a> to post a comment.</p>
      {% endif %}
    </div>

    <div class="comments-section">
      <h2>Comments</h2>
      {% for comment in comments %} {% if not comment.parent_id %}
      <div class="comment">
        <p>{{ comment.content }}</p>
        <p class="meta">Posted by {{ comment.owner.username }}</p>

        {% if current_user %}
        <form action="/posts/{{ post.id }}/comments" method="post" style="margin-left: 20px;">
          <input type="hidden" name="parent_id" value="{{ comment.id }}" />
          <textarea name="content" rows="2" style="width:80%;" placeholder="Reply..." required></textarea><br />
          <button type="submit">Reply</button>
        </form>
        {% endif %}

        <div class="replies">
          {% for reply in comment.replies %}
          <div class="comment">
            <p>{{ reply.content }}</p>
            <p class="meta">Replied by {{ reply.owner.username }}</p>
          </div>
          {% endfor %}
        </div>
      </div>
      {% endif %} {% endfor %}
    </div>
    <a href="/posts">Back to Home</a>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

This template includes the post's details, a form for posting new comments, and an area to display all comments. For simplicity, we are currently only displaying one level of replies.

Step 3: Implement Backend Route Logic

Next, we'll add new routes in main.py to handle displaying the post detail page and submitting comments.

main.py (Add new routes)

# ... (previous imports remain unchanged) ...
from sqlalchemy.orm import selectinload

# ... (previous code remain unchanged) ...

# --- Routes ---

# ... (previous routes /, /posts, /api/posts, etc. remain unchanged) ...

@app.get("/posts/{post_id}", response_class=HTMLResponse)
async def view_post_detail(
    request: Request,
    post_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: Optional[models.User] = Depends(get_current_user)
):
    # Query for the post
    result = await db.execute(select(models.Post).where(models.Post.id == post_id).options(selectinload(models.Post.owner)))
    post = result.scalar_one_or_none()

    if not post:
        raise HTTPException(status_code=404, detail="Post not found")

    # Query for comments, and preload author and reply info
    # Use selectinload to avoid N+1 queries
    comment_result = await db.execute(
        select(models.Comment)
        .where(models.Comment.post_id == post_id)
        .options(selectinload(models.Comment.owner), selectinload(models.Comment.replies).selectinload(models.Comment.owner))
        .order_by(models.Comment.id)
    )
    comments = comment_result.scalars().all()

    return templates.TemplateResponse("post_detail.html", {
        "request": request,
        "post": post,
        "comments": comments,
        "current_user": current_user
    })


@app.post("/posts/{post_id}/comments")
async def create_comment(
    post_id: int,
    content: str = Form(...),
    parent_id: Optional[int] = Form(None),
    db: AsyncSession = Depends(get_db),
    current_user: Optional[models.User] = Depends(get_current_user)
):
    if not current_user:
        return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)

    new_comment = models.Comment(
        content=content,
        post_id=post_id,
        owner_id=current_user.id,
        parent_id=parent_id
    )
    db.add(new_comment)
    await db.commit()

    return RedirectResponse(url=f"/posts/{post_id}", status_code=status.HTTP_303_SEE_OTHER)


# ... (subsequent routes /posts/{post_id}/edit, /register, /login, /logout, etc. remain unchanged) ...
Enter fullscreen mode Exit fullscreen mode

We added two new routes:

  • GET /posts/{post_id}: Finds the post from the database based on post_id and queries for all comments related to that post. Finally, it renders the post_detail.html template, passing the post, comments, and current user information to it.
  • POST /posts/{post_id}/comments: This route handles the submission of comments and replies, creating a Comment object and saving it to the database. It receives content and an optional parent_id from the form. If parent_id exists, it means this is a reply.

Step 4: Add an Entry Point on the Homepage

Everything is ready, we just need an entry point from the homepage to the post detail page. Modify templates/posts.html to turn the post title into a link.

We just need to wrap <h3>{{ post.title }}</h3> with an <a> tag, linking it to /posts/{{ post.id }}.

templates/posts.html (Updated)

... (file header and stylesheet remain unchanged) ...
<body>
    ... (header and post form sections remain unchanged) ...
    <hr />
    <h2>Post List</h2>

    {% for post in posts %}
    <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 10px;">
      <a href="/posts/{{ post.id }}"><h3>{{ post.title }}</h3></a>

      <p>{{ post.content }}</p>
      <small>Author: {{ post.owner.username if post.owner else 'Unknown' }}</small>

      {% if current_user and post.owner_id == current_user.id %}
      <div style="margin-top: 10px;">
        <a href="/posts/{{ post.id }}/edit">Edit</a>
      </div>
      {% endif %}
    </div>
    {% endfor %}
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Run and Verify

Restart your uvicorn server:

uvicorn main:app --reload
Enter fullscreen mode Exit fullscreen mode

Visit http://127.0.0.1:8000 and log in. On the homepage, you will find that all post titles have now become clickable links.

ImageP1

Click on any post title, and the page will redirect to that post's detail page.

Enter content in the comment box at the bottom of the detail page and click 'Submit'. After the page refreshes, your comment will appear in the comment section.

ImageP2

Below the comment, there is a smaller reply box. Enter content in it and submit, and you will see a comment as a reply.

ImageP3

Summary

The forum now supports comment and reply features, allowing users to interact with each other.

As the forum's functionality becomes more complex, maintaining community order becomes increasingly important. How do we control what a user can do?

In the next article, we will introduce permission management. Through systems like administrators, we will ensure the healthy development of the community. For example, banning a user from speaking.


Follow us on X: @LeapcellHQ


Read on our blog

Related Posts:

Top comments (0)