FastAPIで独自のフォーラムを構築:ステップ10 - カテゴリ
Emily Parker
Product Engineer · Leapcell

前の記事で、フォーラムに画像アップロード機能を追加し、投稿のコンテンツを充実させました。
現在、すべての投稿が同じホームページフィードに混在しています。フォーラムのコンテンツが増えるにつれて、これは非常に整理が悪くなります。ユーザーは特定のトピックにのみ興味があるかもしれませんが、関係のないコンテンツに気を取られてしまいます。
これを解決するために、この記事ではカテゴリ機能を紹介します。「技術的な議論」「雑談」などのさまざまなボードを作成し、ユーザーが投稿時にカテゴリを選択できるようにし、カテゴリ別に投稿を閲覧できるようにします。
ステップ1:データベースモデルの更新
カテゴリ情報を保存するために新しいテーブルcategoriesが必要であり、それをリンクするためにpostsテーブルに外部キーを追加する必要があります。
models.pyを開き、Categoryモデルを追加し、Postモデルを更新します。
models.py(更新後)
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import TSVECTOR from database import Base # ... (UserおよびCommentモデルは変更なし) ... 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")) image_url = Column(String, nullable=True) # --- 新しいフィールド --- category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) # --------------- # owner = relationship("User", back_populates="posts") comments = relationship("Comment", back_populates="post", cascade="all, delete-orphan") search_vector = Column(TSVECTOR, nullable=True) # --- 新しいリレーションシップ --- category = relationship("Category", back_populates="posts") # --------------- # class Category(Base): __tablename__ = "categories" id = Column(Integer, primary_key=True, index=True) name = Column(String, unique=True, index=True, nullable=False) description = Column(String, nullable=True) posts = relationship("Post", back_populates="category")
このステップでの主な変更点は次のとおりです。
- 新しい
Categoryモデルを作成しました。 Postモデルにcategory_idを外部キーとして追加しました。PostとCategoryの間のrelationshipを確立し、post.categoryまたはcategory.postsを介してカテゴリ情報にアクセスできるようにしました。
ステップ2:データベーステーブル構造の更新
次に、このテーブルをデータベースに実際に作成し、postsテーブルを変更する必要があります。
categoriesテーブルの作成
CREATE TABLE categories ( id SERIAL PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL, description TEXT );
デフォルトカテゴリの作成
カテゴリをすぐに利用できるように、手動で2つ作成しましょう。
INSERT INTO categories (name, description) VALUES ('Technical', 'Discuss FastAPI, Python, databases, and other technical topics'), ('General', 'Share daily life, hobbies, etc.');
postsテーブルの変更
-- category_id列を追加 ALTER TABLE posts ADD COLUMN category_id INTEGER; -- 外部キー制約を追加 ALTER TABLE posts ADD CONSTRAINT fk_category FOREIGN KEY(category_id) REFERENCES categories(id);
既存の投稿の処理
postsテーブルの既存のデータについては、デフォルトのcategory_idを割り当てる必要があります。
-- すべての既存の投稿を「General Chat」カテゴリ(IDが2であると仮定)に更新 UPDATE posts SET category_id = 2 WHERE category_id IS NULL;
Null不可に設定
最後に、データ整合性を確保するために、category_id列をNOT NULLに設定します。
ALTER TABLE posts ALTER COLUMN category_id SET NOT NULL;
Leapcellを使用してデータベースを作成した場合、
これらのSQLステートメントをWebベースの操作パネルで直接実行できます。

ステップ3:投稿作成ロジックの更新
これで、ユーザーが投稿を作成する際にはカテゴリを指定する必要があります。create_postルートを変更してcategory_idを受け入れるようにする必要があります。
main.py(create_postルートの更新)
# ... (以前のインポート) ... @app.post("/api/posts") async def create_post( title: str = Form(...), content: str = Form(...), category_id: int = Form(...), # category_idを追加 image: Optional[UploadFile] = File(None), db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # ... (その他のロジックは変更なし) ... # Postオブジェクトを作成する際にcategory_idを含める new_post = models.Post( title=title, content=content, owner_id=current_user.id, image_url=image_url, category_id=category_id # カテゴリIDを保存 ) db.add(new_post) await db.commit() await db.refresh(new_post) # 3. 投稿後、ホームページではなくカテゴリページにリダイレクト return RedirectResponse(url=f"/categories/{category_id}", status_code=status.HTTP_303_SEE_OTHER)
ステップ4:カテゴリ別閲覧の実装
/postsを唯一の投稿リストページとして使用するのではなく、特定のカテゴリの投稿を表示する新しいルートGET /categories/{category_id}を作成します。
既存のGET /postsルートを、「すべての投稿」集計ページとして機能するように変更します。
main.py(ルートの追加/変更)
# ... (以前のインポート) ... from sqlalchemy.orm import selectinload # ... (依存関係など) ... # ヘルパー関数:データベースからすべてのカテゴリを取得 async def get_all_categories(db: AsyncSession): result = await db.execute(select(models.Category).order_by(models.Category.id)) return result.scalars().all() @app.get("/posts", response_class=HTMLResponse) async def view_posts( request: Request, db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # すべてのカテゴリをクエリ (ナビゲーション用) categories = await get_all_categories(db) # すべての投稿をクエリ stmt = ( select(models.Post) .options(selectinload(models.Post.owner), selectinload(models.Post.category)) .order_by(desc(models.Post.id)) ) result = await db.execute(stmt) posts = result.scalars().all() return templates.TemplateResponse("posts.html", { "request": request, "posts": posts, "categories": categories, "current_user": current_user, "current_category": None # 特定のカテゴリではないことをマーク }) # --- 新しいルート --- @app.get("/categories/{category_id}", response_class=HTMLResponse) async def view_posts_by_category( request: Request, category_id: int, db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # すべてのカテゴリをクエリ (ナビゲーション用) categories = await get_all_categories(db) # 現在のカテゴリをクエリ category_result = await db.execute(select(models.Category).where(models.Category.id == category_id)) current_category = category_result.scalar_one_or_none() if not current_category: raise HTTPException(status_code=404, detail="Category not found") # このカテゴリの投稿をクエリ stmt = ( select(models.Post) .where(models.Post.category_id == category_id) .options(selectinload(models.Post.owner), selectinload(models.Post.category)) .order_by(desc(models.Post.id)) ) result = await db.execute(stmt) posts = result.scalars().all() return templates.TemplateResponse("posts.html", { "request": request, "posts": posts, "categories": categories, "current_user": current_user, "current_category": current_category # 現在のカテゴリ情報を渡す }) # ... (その他のルート) ... @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), selectinload(models.Post.category)) ) post = result.scalar_one_or_none() # ... (後続のコメントクエリロジックなどは変更なし) ...
新しいルートGET /categories/{category_id}を追加しました。これはposts.htmlテンプレートを再利用しますが、そのカテゴリの投稿のみを渡します。また、GET /postsルートを変更し、カテゴリ情報が正しくロードされて渡されるようにしました。
ステップ5:フロントエンドテンプレートの更新
最後に、カテゴリナビゲーションを表示し、投稿時にカテゴリを選択できるようにし、投稿がどのカテゴリに属するかを示すためにテンプレートを更新する必要があります。
templates/posts.html(更新後)
<!DOCTYPE html> <html> <head> <style> /* ... (既存のスタイル) ... */ .category-nav { margin-top: 20px; margin-bottom: 20px; } .category-nav a { margin-right: 15px; text-decoration: none; } .category-nav a.active { font-weight: bold; } .post-category { font-size: 0.9em; color: #888; } </style> </head> <body> <div class="category-nav"> <strong>Categories:</strong> <a href="/posts" class="{{ 'active' if not current_category else '' }}">All</a> {% for category in categories %} <a href="/categories/{{ category.id }}" class="{{ 'active' if current_category and current_category.id == category.id else '' }}"> {{ category.name }} </a> {% endfor %} </div> {% if current_user and not current_user.is_banned %} <h2> Post a new thread {% if current_category %}in {{ current_category.name }} {% endif %} </h2> <form action="/api/posts" method="post" enctype="multipart/form-data"> <input type="text" name="title" placeholder="Post Title" required /><br /> <textarea name="content" rows="4" placeholder="Post Content" required></textarea><br /> <label for="category">Select Category:</label> <select name="category_id" id="category" required> {% for category in categories %} <option value="{{ category.id }}" {{ 'selected' if current_category and current_category.id == category.id else '' }}> {{ category.name }} </option> {% endfor %} </select> <br /><br /> <label for="image">Upload Image (Optional, JPEG/PNG/GIF):</label> <input type="file" name="image" id="image" accept="image/jpeg,image/png,image/gif" /> <br /><br /> <button type="submit">Post</button> </form> {% elif current_user and current_user.is_banned %} {% else %} {% endif %} <hr /> <h2> Post List - {{ current_category.name if current_category else "All Posts" }} </h2> {% for post in posts %} <div class="post-item"> <a href="/posts/{{ post.id }}"><h3>{{ post.title }}</h3></a> <p>{{ post.content }}</p> <small> Author: {{ post.owner.username if post.owner else 'Unknown' }} | Category: <a href="/categories/{{ post.category.id }}">{{ post.category.name }}</a> </small> </div> {% endfor %} </body> </html>
templates/post_detail.html(更新後)
投稿詳細ページでも、カテゴリ情報を追加します。
... <body> <div class="post-container"> <h1>{{ post.title }}</h1> <p>{{ post.content }}</p> <small> Author: {{ post.owner.username }} | Category: <a href="/categories/{{ post.category.id }}">{{ post.category.name }}</a> </small> </div> ...
実行と検証
uvicornサーバーを再起動します。
uvicorn main:app --reload
http://127.0.0.1:8000/にアクセスします。
上部に新しいカテゴリナビゲーションバー(「すべて」、「Technical」、「General」)が表示されます。
「新しいスレッドを投稿」フォームには、新しく必須の「カテゴリを選択」ドロップダウンがあります。

投稿リストでは、各投稿がどのカテゴリに属しているかを示します。

カテゴリ(例:「General」)をクリックしてみてください。ページは/categories/2にリダイレクトされ、そのカテゴリの投稿のみが表示され、フォームの「カテゴリを選択」ドロップダウンは「General」にデフォルト設定されます。

結論
Categoryモデルを追加し、ルートとテンプレートを更新することで、フォーラムのカテゴリ機能を正常に実装しました。これにより、ユーザーがフォーラムのコンテンツを見つけて閲覧することがより便利になりました。
