0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FastAPIで独自のフォーラムを構築する:ステップ6 - コメントと返信

Posted at

Cover

前の記事では、フォーラムに投稿編集機能を追加し、ユーザーが公開したコンテンツを修正できるようにしました。

投稿以外にも、フォーラムではインタラクションが不可欠です。ユーザーは興味深い(あるいは物議を醸す)投稿を見たときに、自分の意見を表明したくなるでしょう。

この記事では、フォーラムにインタラクション機能を追加します。投稿のコメントと返信を実装し、ユーザーが投稿を中心に議論できるようにします。

ステップ1:データベースモデルの更新

コメントを保存するための新しいテーブルが必要です。さらに、コメント自体が階層構造を形成するために返信をサポートする必要があります。

models.pyで、Commentモデルを追加し、リレーションシップを確立するためにUserおよびPostモデルを更新します。

models.py(更新版)

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")

    # 返信のための自己参照リレーションシップ
    parent = relationship("Comment", back_populates="replies", remote_side=[id])
    replies = relationship("Comment", back_populates="parent", cascade="all, delete-orphan")

ここで行った主な変更点は次のとおりです。

  1. Commentモデルを作成し、それぞれpost_idおよびowner_idを介して投稿およびユーザーにリンクしました。parent_idフィールドは、別のコメントのidを指します。NULLの場合はトップレベルのコメントであり、それ以外の場合は返信です。
  2. UserおよびPostモデルを更新し、Commentへのリレーションシップを追加しました。cascade="all, delete-orphan"は、ユーザーまたは投稿が削除されたときに、関連するコメントも削除されることを保証します。

次に、この新しいテーブルをデータベースに作成します。対応するSQLステートメントは次のとおりです。

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
);

Leapcellを使用してデータベースが作成された場合、

ImageLc

これらのSQLステートメントを、Webベースの操作パネルで直接実行できます。

ImageDb

ステップ2:投稿詳細ページとコメントセクションの作成

現在、すべての投稿はホームページに表示されています。コメントセクションのためのスペースを確保するために、各投稿に個別の詳細ページを作成する必要があります。

まず、templatesフォルダに新しいファイルpost_detail.htmlを作成します。

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>

このテンプレートには、投稿の詳細、新しいコメントを投稿するためのフォーム、およびすべてのコメントを表示する領域が含まれています。簡単にするために、現在は1レベルの返信のみを表示しています。

ステップ3:バックエンドルートロジックの実装

次に、投稿詳細ページを表示し、コメントを送信するための新しいルートをmain.pyに追加します。

main.py(新しいルートを追加)

# ... (以前のインポートは変更なし) ...
from sqlalchemy.orm import selectinload

# ... (以前のコードは変更なし) ...

# --- Routes ---

# ... (以前のルート /, /posts, /api/posts などは変更なし) ...

@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)
):
    # 投稿をクエリ
    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")

    # コメント、および作成者と返信情報を事前ロードしてクエリ
    # N+1クエリを回避するためにselectinloadを使用
    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)


# ... (後続のルート /posts/{post_id}/edit, /register, /login, /logout などは変更なし) ...

2つの新しいルートを追加しました。

  • GET /posts/{post_id}: post_idに基づいてデータベースから投稿を検索し、その投稿に関連するすべてのコメントをクエリします。最後に、post_detail.htmlテンプレートをレンダリングし、投稿、コメント、および現在のユーザー情報を渡します。
  • POST /posts/{post_id}/comments: このルートは、コメントと返信の送信を処理し、Commentオブジェクトを作成してデータベースに保存します。フォームからcontentとオプションのparent_idを受け取ります。parent_idが存在する場合、これは返信を意味します。

ステップ4:ホームページにエントリポイントを追加

すべて準備ができたので、ホームページから投稿詳細ページへのエントリポイントが必要です。templates/posts.htmlを編集して、投稿タイトルをリンクに変換します。

<h3>{{ post.title }}</h3>/posts/{{ post.id }}にリンクする<a>タグでラップするだけです。

templates/posts.html(更新版)

... (ファイルヘッダーとスタイルシートは変更なし) ...
<body>
    ... (ヘッダーと投稿フォームセクションは変更なし) ...
    <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>

実行と検証

uvicornサーバーを再起動します。

uvicorn main:app --reload

http://127.0.0.1:8000にアクセスしてログインします。ホームページでは、すべての投稿タイトルがクリック可能なリンクになっていることがわかります。

ImageP1

いずれかの投稿タイトルをクリックすると、ページがその投稿の詳細ページにリダイレクトされます。

詳細ページの最下部にあるコメントボックスにコンテンツを入力して、「Submit」をクリックします。ページがリフレッシュされた後、コメントがコメントセクションに表示されます。

ImageP2

コメントの下に、より小さな返信ボックスがあります。そこにコンテンツを入力して送信すると、コメントが返信として表示されます。

ImageP3

まとめ

フォーラムは現在、コメントと返信機能をサポートしており、ユーザーが互いに交流できるようになりました。

フォーラムの機能が複雑になるにつれて、コミュニティの秩序を維持することがますます重要になります。ユーザーができることをどのように制御しますか?

次の記事では、権限管理を紹介します。管理者などのシステムを通じて、コミュニティの健全な発展を確保します。たとえば、ユーザーの発言を禁止することです。


Xでフォローする:@LeapcellJapan


ブログでこの記事を読む

関連記事:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?