Antworte auf Kommentare in einem perfekten Blog mit FastAPI
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Im vorherigen Artikel haben wir ein grundlegendes Kommentarsystem zu unserem FastAPI-Blog hinzugefügt, das es Benutzern ermöglicht, Diskussionen über Beiträge zu führen.
Diese Kommentare waren jedoch unidirektional. Andere konnten Ihre Beiträge kommentieren, aber Sie konnten ihren Kommentaren nicht antworten.
Um den Kommentarbereich interaktiver zu gestalten, werden wir in diesem Artikel eine Funktion für Kommentarantworten für unseren Blog implementieren. Benutzer können auf bestehende Kommentare antworten, und diese Antworten werden in einem verschachtelten (oder eingerückten) Format angezeigt, um die hierarchische Beziehung klar darzustellen.
Schritt 1: Datenmodell aktualisieren
Um die Antwortfunktion zu implementieren, müssen wir eine Eltern-Kind-Beziehung zwischen den Kommentaren herstellen. Eine Antwort ist im Wesentlichen ein Kommentar, hat aber einen "Elternkommentar". Wir werden dies erreichen, indem wir dem Comment
-Modell eine selbstreferenzielle Beziehung hinzufügen.
1. Das Kommentar-Modell ändern
Öffnen Sie die Datei models.py
und fügen Sie die Attribute parentId
, parent
und replies
zum Comment
-Modell hinzu.
# models.py import uuid from datetime import datetime from typing import Optional, List from sqlmodel import Field, SQLModel, Relationship class User(SQLModel, table=True): id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True) username: str = Field(unique=True, index=True) password: str comments: List["Comment"] = Relationship(back_populates="user") class Post(SQLModel, table=True): id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True) title: str content: str createdAt: datetime = Field(default_factory=datetime.utcnow, nullable=False) comments: List["Comment"] = Relationship(back_populates="post") class Comment(SQLModel, table=True): id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True) content: str createdAt: datetime = Field(default_factory=datetime.utcnow, nullable=False) postId: uuid.UUID = Field(foreign_key="post.id") userId: uuid.UUID = Field(foreign_key="user.id") post: Post = Relationship(back_populates="comments") user: User = Relationship(back_populates="comments") # --- New Fields --- # Stores the parent comment's ID, can be null parentId: Optional[uuid.UUID] = Field(default=None, foreign_key="comment.id") # Defines the relationship with the parent comment # sa_relationship_kwargs helps SQLAlchemy correctly handle the self-referencing relationship parent: Optional["Comment"] = Relationship( back_populates="replies", sa_relationship_kwargs=dict(remote_side="Comment.id") ) # Defines the relationship with the list of child comments (replies) replies: List["Comment"] = Relationship(back_populates="parent")
parentId
: Ein optionales Feld, das als Fremdschlüssel fungiert und auf dieid
dercomment
-Tabelle selbst verweist. Für Top-Level-Kommentare istparentId
None
.parent
undreplies
: Diese verwendenRelationship
, um die Eltern-Kind-Beziehung innerhalb desComment
-Modells zu definieren. Dies ermöglicht uns den einfachen Zugriff auf alle Antworten auf einen Kommentar übercomment.replies
.
Da wir die Funktion create_db_and_tables
in main.py
so konfiguriert haben, dass sie Modelländerungen automatisch erkennt und das Datenbankschema beim Start der Anwendung aktualisiert, müssen wir keine SQL-Befehle manuell ausführen.
Wenn Sie SQL manuell ausführen müssen und Ihre Datenbank auf Leapcell erstellt wurde,
können Sie SQL-Anweisungen einfach über die grafische Benutzeroberfläche ausführen. Gehen Sie einfach zur Seite "Datenbankverwaltung" auf der Website, fügen Sie die Anweisung in die SQL-Oberfläche ein und führen Sie sie aus.
Schritt 2: Den Kommentar-Service anpassen
Die Service-Schicht muss angepasst werden, um übergeordnete Kommentare beim Erstellen eines neuen Kommentars zu verknüpfen und die flache Kommentarliste beim Abfragen in eine baumartige Struktur zu organisieren.
Öffnen Sie comments_service.py
und nehmen Sie folgende Änderungen vor:
# comments_service.py import uuid from typing import List, Optional from sqlmodel import Session, select from models import Comment def structure_comments(comments: List[Comment]) -> List[Comment]: """Wandelt eine flache Kommentarliste in eine Baumstruktur um""" comment_map = {} # Zuerst die replies-Liste initialisieren und alle Kommentare zur schnellen Suche in eine Map einfügen for comment in comments: comment.replies = [] comment_map[comment.id] = comment root_comments = [] # Durch die Kommentare iterieren, um die Eltern-Kind-Beziehungen aufzubauen for comment in comments: if comment.parentId: parent_comment = comment_map.get(comment.parentId) if parent_comment: parent_comment.replies.append(comment) else: root_comments.append(comment) return root_comments def get_comments_by_post_id(post_id: uuid.UUID, session: Session) -> List[Comment]: """Findet alle Kommentare zu einem Beitrag und strukturiert sie in eine Baumstruktur""" statement = select(Comment).where(Comment.postId == post_id).order_by(Comment.createdAt) comments = session.exec(statement).all() # Den strukturierten Kommentarbaum zurückgeben return structure_comments(comments) def create_comment( content: str, user_id: uuid.UUID, post_id: uuid.UUID, session: Session, parent_id: Optional[uuid.UUID] = None # Optionalen parent_id-Parameter hinzufügen ) -> Comment: """Erstellt einen neuen Kommentar, der optional mit einem Elternkommentar verknüpft ist""" new_comment = Comment( content=content, userId=user_id, postId=post_id, parentId=parent_id # parentId setzen ) session.add(new_comment) session.commit() session.refresh(new_comment) return new_comment
Logikerklärung:
get_comments_by_post_id
ruft nun alle Kommentare für einen Beitrag ab (sowohl Top-Level als auch Antworten) und ruft dannstructure_comments
auf, um sie zu verarbeiten.- Die neue Methode
structure_comments
ist der Kern dieser Logik. Sie iteriert durch alle Kommentare: Wenn ein Kommentar eineparentId
hat, wird er imreplies
-Array seines Elternteils platziert; andernfalls ist es ein Top-Level-Kommentar. Die Funktion gibt letztendlich eine Liste aller Top-Level-Kommentare zurück, von denen jeder potenziell eine verschachtelte Liste von Antworten enthält. - Die Methode
create_comment
fügt einen optionalenparent_id
-Parameter hinzu. Wenn diese ID angegeben wird, wird der neu erstellte Kommentar mit dem entsprechenden Elternkommentar verknüpft.
Schritt 3: Die Route aktualisieren
Der Controller muss die optionale parentId
aus dem Request Body empfangen und an den Service übergeben. Diese Änderung ist sehr einfach.
Öffnen Sie routers/comments.py
:
# routers/comments.py import uuid from typing import Optional from fastapi import APIRouter, Depends, Form from fastapi.responses import RedirectResponse from sqlmodel import Session from database import get_session import comments_service from auth_dependencies import login_required router = APIRouter() @router.post("/posts/{post_id}/comments") def create_comment_for_post( post_id: uuid.UUID, content: str = Form(...), parentId: Optional[str] = Form(None), # <-- Empfängt optional parentId user: dict = Depends(login_required), session: Session = Depends(get_session) ): user_id = uuid.UUID(user["id"]) # Konvertiert parentId in den UUID-Typ, falls vorhanden parent_uuid = uuid.UUID(parentId) if parentId else None comments_service.create_comment( content=content, user_id=user_id, post_id=post_id, session=session, parent_id=parent_uuid # Übergibt parent_id an den Service ) return RedirectResponse(url=f"/posts/{post_id}", status_code=302)
Schritt 4: Die Frontend-Ansicht aufrüsten
Dies ist der Teil mit den meisten Änderungen. Wir müssen die Vorlage post.html
aktualisieren, um Kommentare und ihre Antworten rekursiv zu rendern. Wir müssen auch etwas JavaScript hinzufügen, um das Antwortformular dynamisch anzuzeigen.
1. Eine Kommentar-Vorlage erstellen
Um eine rekursive Darstellung von Kommentaren zu erreichen, ist es am besten, ein wiederverwendbares "Makro" zu erstellen.
Erstellen Sie eine neue Datei namens _comment.html
im Verzeichnis templates
:
{# templates/_comment.html #} {% macro render_comment_tree(comments, user, post, depth) %} {% for comment in comments %} <div class="comment-item" style="margin-left: {{ depth * 20 }}px;"> <p class="comment-content">{{ comment.content }}</p> <small> Von <strong>{{ comment.user.username }}</strong> am {{ comment.createdAt.strftime('%Y-%m-%d') }} </small> {% if user %} <button class="reply-btn" data-comment-id="{{ comment.id }}">Antworten</button> {% endif %} </div> {# Ruft sich rekursiv auf, um Antworten zu rendern #} {% if comment.replies %} {{ render_comment_tree(comment.replies, user, post, depth + 1) }} {% endif %} {% endfor %} {% endmacro %}
Diese Vorlage definiert ein Makro namens render_comment_tree
. Es iteriert durch das übergebene comments
-Array und ruft sich rekursiv für das replies
-Array jedes Kommentars auf, wobei die depth
erhöht wird, um eine visuelle Einrückung zu erzeugen.
2. post.html
aktualisieren
Ändern Sie nun templates/post.html
, um dieses neue Makro zu verwenden und ein universelles Antwortformular mit der entsprechenden JavaScript-Logik hinzuzufügen.
{# templates/post.html #} {# ... Inhaltsbereich des Beitrags ... #} <div class="post-content">{{ post.content | replace('\n', '<br>') | safe }}</div> </article> {# Importiert das Makro #} {% from '_comment.html' import render_comment_tree %} <section class="comments-section"> <h3>Kommentare</h3> <div class="comment-list"> {% if comments %} {# Ruft das Makro auf, um den Kommentarbaum zu rendern #} {{ render_comment_tree(comments, user, post, 0) }} {% else %} <p>Noch keine Kommentare. Seien Sie der Erste!</p> {% endif %} </div> {% if user %} <form id="comment-form" action="/posts/{{ post.id }}/comments" method="POST" class="comment-form"> <h4>Einen Kommentar hinterlassen</h4> <div class="form-group"> <textarea name="content" rows="4" placeholder="Schreiben Sie hier Ihren Kommentar..." required></textarea> {# Fügt ein verstecktes parentId Eingabefeld hinzu #} <input type="hidden" name="parentId" id="parentIdInput" value="" /> </div> <button type="submit">Kommentar absenden</button> <button type="button" id="cancel-reply-btn" style="display: none;">Antwort abbrechen</button> </form> {% else %} <p><a href="/auth/login">Anmelden</a>, um einen Kommentar zu hinterlassen.</p> {% endif %} </section> <script> document.addEventListener('DOMContentLoaded', () => { const commentForm = document.getElementById('comment-form'); const parentIdInput = document.getElementById('parentIdInput'); const formTitle = commentForm.querySelector('h4'); const cancelReplyBtn = document.getElementById('cancel-reply-btn'); const commentList = document.querySelector('.comment-list'); commentList.addEventListener('click', (e) => { if (e.target.classList.contains('reply-btn')) { const commentId = e.target.getAttribute('data-comment-id'); const commentItem = e.target.closest('.comment-item'); // Verschiebt das Formular direkt unter den zu antwortenden Kommentar commentItem.after(commentForm); // Setzt die parentId und aktualisiert den Formular-Titel parentIdInput.value = commentId; formTitle.innerText = 'Antwort an ' + commentItem.querySelector('strong').innerText; cancelReplyBtn.style.display = 'inline-block'; } }); cancelReplyBtn.addEventListener('click', () => { // Setzt den Formularzustand zurück parentIdInput.value = ''; formTitle.innerText = 'Einen Kommentar hinterlassen'; cancelReplyBtn.style.display = 'none'; // Verschiebt das Formular zurück an das Ende des Kommentarbereichs document.querySelector('.comments-section').appendChild(commentForm); }); }); </script> <a href="/" class="back-link">← Zurück zum Start</a> {% include "_footer.html" %}
JavaScript-Logikerklärung:
- Es gibt nur ein Kommentarformular auf der Seite.
- Wenn ein Benutzer auf die Schaltfläche "Antworten" eines Kommentars klickt, ruft JavaScript-ID dieses Kommentars ab und setzt sie als Wert des versteckten
parentId
-Eingabefelds im Formular. - Gleichzeitig verschiebt es das gesamte Formular direkt nach dem zu beantwortenden Kommentar und aktualisiert den Titel des Formulars, um dem Benutzer eine klare Kontextualisierung zu bieten.
- Eine Schaltfläche "Antwort abbrechen" wird angezeigt, wenn geantwortet wird. Das Klicken darauf setzt das Formular zurück und verschiebt es zurück an das Ende des Kommentarbereichs.
Ausführen und Testen
Starten Sie nun Ihre Anwendung neu:
uvicorn main:app --reload
Öffnen Sie Ihren Browser und navigieren Sie zur Detailseite eines beliebigen Beitrags. Suchen Sie einen Kommentar und klicken Sie auf die Schaltfläche "Antworten" daneben.
Sie sehen, wie sich das Kommentarformular unterhalb dieses Kommentars bewegt.
Geben Sie Ihren Inhalt ein und senden Sie ihn ab. Nachdem die Seite aktualisiert wurde, sehen Sie Ihre Antwort eingerückt unter dem Elternkommentar.
Sie können weiterhin auf Antworten antworten und so mehrere Konversationslevel erstellen.
Damit verfügen Sie nun über ein vollständiges Kommentar-Antwort-System.