From a409f2972f6287ab8c045e77d5e49d4f6b852aff Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 21 Jul 2026 17:20:53 +0200 Subject: [PATCH 1/5] =?UTF-8?q?Update=20comments=20:=20Comments=20now=20us?= =?UTF-8?q?e=20soft=E2=80=91delete,=20edit,=20and=20admin=20hard=E2=80=91d?= =?UTF-8?q?elete.=20Each=20comment=20stores=20is=5Fdeleted,=20deleted=5Fat?= =?UTF-8?q?=20and=20edited=5Fat.=20Authors/admins=20can=20soft=E2=80=91del?= =?UTF-8?q?ete,=20authors=20can=20edit=20inline=20and=20admins=20can=20per?= =?UTF-8?q?manently=20remove=20already=E2=80=91deleted=20comments.=20Backe?= =?UTF-8?q?nd,=20templates,=20CSS,=20JS=20and=20tests=20are=20all=20update?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flask_setup/routes.py | 12 ++ frontend/static/css/article.css | 55 ++++- frontend/static/scripts/comment-reply.js | 85 ++++++++ frontend/static/scripts/suneditor-init.js | 4 + frontend/templates/article_detail.html | 42 +++- .../V14__add_comment_soft_delete_and_edit.sql | 4 + src/application/domain/comment.py | 22 +- .../input_ports/comment_management.py | 43 +++- src/application/services/comment_service.py | 199 ++++++++++-------- .../input_adapters/dto/comment_response.py | 83 ++++++-- .../flask/flask_comment_adapter.py | 64 +++++- .../output_adapters/dto/comment_record.py | 9 + .../models/sqlalchemy_comment_model.py | 10 + .../sqlalchemy/sqlalchemy_comment_adapter.py | 12 +- tests/test_domain_factories.py | 6 + .../dto/test_comment_response.py | 84 +++++++- .../tests_flask/test_flask_comment_adapter.py | 80 ++++++- .../test_article_integration.py | 4 +- .../test_workflow_integration.py | 119 +++++++++-- tests/tests_services/test_comment_service.py | 199 +++++++++++++----- 20 files changed, 929 insertions(+), 207 deletions(-) create mode 100644 migrations/V14__add_comment_soft_delete_and_edit.sql diff --git a/flask_setup/routes.py b/flask_setup/routes.py index 46e00d1..f74819c 100644 --- a/flask_setup/routes.py +++ b/flask_setup/routes.py @@ -67,6 +67,18 @@ def _register_comment_routes(app: Flask, adapters: dict) -> None: methods=["POST"], endpoint="comment.delete_comment", ) + app.add_url_rule( + "/articles//comments//edit", + view_func=com.edit_comment, + methods=["POST"], + endpoint="comment.edit_comment", + ) + app.add_url_rule( + "/articles//comments//delete-permanent", + view_func=com.hard_delete_comment, + methods=["POST"], + endpoint="comment.hard_delete_comment", + ) def _register_auth_routes(app: Flask, adapters: dict) -> None: diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 1ade0f5..113288c 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -773,9 +773,52 @@ a.comment-author:hover { } } -.comment-delete-form { - display: inline; - margin-left: 0.625rem; +.comment-edited { + font-size: var(--font-body-xs); + color: var(--on-surface-variant); + font-style: italic; +} + +.edit-form-container { + margin-top: var(--spacing-sm); +} + +.edit-form { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.edit-editor { + width: 100%; + box-sizing: border-box; + resize: vertical; + min-height: 4rem; + font-size: var(--font-body-base); +} + +.edit-actions { + display: flex; + gap: var(--spacing-xs); +} + +.cancel-edit { + cursor: pointer; +} + +.comment-edit-toggle { + background: none; + border: none; + color: var(--primary); + cursor: pointer; + font-size: var(--font-body-xs); + font-family: var(--font-mono); + padding: 0; + text-transform: uppercase; +} + +.comment-edit-toggle:hover { + text-decoration: underline; } .comment-delete-btn { @@ -798,6 +841,12 @@ a.comment-author:hover { transform: none; } +.comment-edited-line { + font-size: var(--font-body-sm); + color: var(--on-surface-variant); + margin-top: var(--spacing-xs); +} + .comment-empty { color: var(--on-surface-variant); font-size: var(--font-body-base); diff --git a/frontend/static/scripts/comment-reply.js b/frontend/static/scripts/comment-reply.js index 0cdbf3f..3a646d9 100644 --- a/frontend/static/scripts/comment-reply.js +++ b/frontend/static/scripts/comment-reply.js @@ -19,6 +19,17 @@ } }); + document.querySelectorAll('.edit-form-container').forEach(other => { + if (other.style.display === 'block') { + other.style.display = 'none'; + const otherId = other.id.replace('edit-form-', ''); + const otherToggle = document.querySelector('.comment-edit-toggle[data-comment-id="' + otherId + '"]'); + if (otherToggle) otherToggle.textContent = '[Edit]'; + const otherBody = document.getElementById('comment-body-' + otherId); + if (otherBody) otherBody.style.display = ''; + } + }); + const isHidden = container.style.display === 'none'; container.style.display = isHidden ? 'block' : 'none'; replyToggle.textContent = isHidden ? 'Cancel' : 'Reply'; @@ -51,6 +62,58 @@ return; } + const editToggle = e.target.closest('.comment-edit-toggle'); + if (editToggle) { + e.preventDefault(); + const commentId = editToggle.dataset.commentId; + const container = document.getElementById('edit-form-' + commentId); + if (!container) return; + + document.querySelectorAll('.edit-form-container').forEach(other => { + if (other.style.display === 'block' && other.id !== container.id) { + other.style.display = 'none'; + const otherId = other.id.replace('edit-form-', ''); + const otherToggle = document.querySelector('.comment-edit-toggle[data-comment-id="' + otherId + '"]'); + if (otherToggle) otherToggle.textContent = '[Edit]'; + const otherBody = document.getElementById('comment-body-' + otherId); + if (otherBody) { + otherBody.style.display = ''; + const otherEdited = otherBody.nextElementSibling; + if (otherEdited && otherEdited.classList.contains('comment-edited-line')) { + otherEdited.style.display = ''; + } + } + } + }); + + document.querySelectorAll('.reply-form-container').forEach(other => { + if (other.style.display === 'block') { + other.style.display = 'none'; + const otherId = other.id.replace('reply-form-', ''); + const otherToggle = document.querySelector('.reply-toggle[data-comment-id="' + otherId + '"]'); + if (otherToggle) otherToggle.textContent = 'Reply'; + } + }); + + const isHidden = container.style.display === 'none'; + container.style.display = isHidden ? 'block' : 'none'; + editToggle.textContent = isHidden ? 'Cancel' : '[Edit]'; + + const bodyText = document.getElementById('comment-body-' + commentId); + if (bodyText) { + bodyText.style.display = isHidden ? 'none' : ''; + const editedLine = bodyText.nextElementSibling; + if (editedLine && editedLine.classList.contains('comment-edited-line')) { + editedLine.style.display = isHidden ? 'none' : ''; + } + } + + if (isHidden && window.initEditEditor) { + window.initEditEditor(commentId); + } + return; + } + const cancelBtn = e.target.closest('.cancel-reply'); if (cancelBtn) { const container = cancelBtn.closest('.reply-form-container'); @@ -61,6 +124,28 @@ if (toggle) { toggle.textContent = 'Reply'; } + return; + } + + const cancelEdit = e.target.closest('.cancel-edit'); + if (cancelEdit) { + const container = cancelEdit.closest('.edit-form-container'); + if (!container) return; + container.style.display = 'none'; + const commentId = container.id.replace('edit-form-', ''); + const toggle = document.querySelector('.comment-edit-toggle[data-comment-id="' + commentId + '"]'); + if (toggle) { + toggle.textContent = '[Edit]'; + } + const bodyText = document.getElementById('comment-body-' + commentId); + if (bodyText) { + bodyText.style.display = ''; + const editedLine = bodyText.nextElementSibling; + if (editedLine && editedLine.classList.contains('comment-edited-line')) { + editedLine.style.display = ''; + } + } + return; } }); }); diff --git a/frontend/static/scripts/suneditor-init.js b/frontend/static/scripts/suneditor-init.js index 0c6e231..c065f20 100644 --- a/frontend/static/scripts/suneditor-init.js +++ b/frontend/static/scripts/suneditor-init.js @@ -151,6 +151,10 @@ initCommentEditor('reply-editor-' + commentId, 'reply-content-' + commentId); }; + window.initEditEditor = function (commentId) { + initCommentEditor('edit-editor-' + commentId, 'edit-content-' + commentId); + }; + document.addEventListener('DOMContentLoaded', () => { initCommentEditor('comment-editor', 'comment-content'); }); diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 1aae83e..80b348d 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -86,7 +86,9 @@

{{ article.article_title }}

{% macro render_comment(node, depth=0) %} {% set display_depth = depth if depth < 3 else 3 %} - {% set is_deleted = node.comment.comment_content in ('Comment removed', '[deleted]', 'Comment removed') %} + {% set is_deleted = node.comment.is_deleted %} + {% set is_author = current_user and current_user.account_id == node.comment.comment_written_account_id %} + {% set is_admin = current_user and current_user.account_role == 'admin' %}
{% if is_deleted %} @@ -112,23 +114,52 @@

{{ article.article_title }}

{{ node.comment.author_username }} {% endif %} {{ node.comment.comment_posted_at_formatted }} - {% if current_user and current_user.account_role == 'admin' %} + {% if current_user and is_author and not is_deleted %} + + {% endif %} + {% if current_user and (is_author or is_admin) and not is_deleted %}
- + +
+ {% endif %} + {% if current_user and is_admin and is_deleted %} +
+ +
{% endif %}
-
+
{{ node.comment.comment_content | safe }}
- {% if current_user and not is_deleted and depth < 3 %} + {% if node.comment.edited_at and not node.comment.is_deleted %} +
edited at {{ node.comment.edited_at_formatted }}
+ {% endif %} + {% if current_user and is_author and not is_deleted %} + + {% endif %} + {% if current_user and not is_deleted %}
+ {% if depth < 3 %} + {% endif %} {% if node.replies %} {% endif %}
+ {% if depth < 3 %}
+ {% endif %} {% elif node.replies %}
diff --git a/migrations/V14__add_comment_soft_delete_and_edit.sql b/migrations/V14__add_comment_soft_delete_and_edit.sql new file mode 100644 index 0000000..ce9427a --- /dev/null +++ b/migrations/V14__add_comment_soft_delete_and_edit.sql @@ -0,0 +1,4 @@ +ALTER TABLE comments + ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN deleted_at TIMESTAMP, + ADD COLUMN edited_at TIMESTAMP; diff --git a/src/application/domain/comment.py b/src/application/domain/comment.py index 7f4de88..816a403 100644 --- a/src/application/domain/comment.py +++ b/src/application/domain/comment.py @@ -11,10 +11,13 @@ class Comment: Attributes: comment_id (int): Unique identifier for the comment. comment_article_id (int): Reference to the associated article. - comment_written_account_id (int): Reference to the author's account. + comment_written_account_id (int | None): Reference to the author's account. comment_reply_to (int | None): Reference to a parent comment (for replies). comment_content (str): Text content of the comment. comment_posted_at (datetime): Timestamp of when the comment was posted. + is_deleted (bool): Soft-delete flag. True if the comment has been removed. + deleted_at (datetime | None): When the comment was soft-deleted. None if not deleted. + edited_at (datetime | None): Last edit timestamp. None if never edited. """ def __init__( @@ -25,24 +28,19 @@ def __init__( comment_reply_to: int | None, comment_content: str, comment_posted_at: datetime, + is_deleted: bool = False, + deleted_at: datetime | None = None, + edited_at: datetime | None = None, ): - """ - Initialize a comment or reply. - - Args: - comment_id (int): Unique identifier for the comment. - comment_article_id (int): Reference to the associated article. - comment_written_account_id (int): Reference to the author's account. - comment_reply_to (int | None): Reference to a parent comment (for replies). - comment_content (str): Text content of the comment. - comment_posted_at (datetime): Timestamp of when the comment was posted. - """ self.comment_id = comment_id self.comment_article_id = comment_article_id self.comment_written_account_id = comment_written_account_id self.comment_reply_to = comment_reply_to self.comment_content = comment_content self.comment_posted_at = comment_posted_at + self.is_deleted = is_deleted + self.deleted_at = deleted_at + self.edited_at = edited_at @dataclass class CommentWithAuthor: diff --git a/src/application/input_ports/comment_management.py b/src/application/input_ports/comment_management.py index 6c48b4c..2cf39e2 100644 --- a/src/application/input_ports/comment_management.py +++ b/src/application/input_ports/comment_management.py @@ -72,10 +72,11 @@ def mask_comments_by_account_id(self, account_id: int) -> None: @abstractmethod def delete_comment(self, comment_id: int, user_id: int) -> bool | str: """ - Deletes a comment. - - If comment_written_account_id is None (author deleted), hard-deletes immediately. - - Otherwise, first click soft-deletes (content → "Comment removed"). - Second click hard-deletes recursively. + Soft-deletes a comment. + Sets is_deleted=True and deleted_at=now. + Author and admin can soft-delete. + Content preserved in DB but display shows "Comment removed". + Author displayed as "Anonymous" after deletion. Args: comment_id (int): ID of the comment to delete. @@ -85,3 +86,37 @@ def delete_comment(self, comment_id: int, user_id: int) -> bool | str: bool | str: True if deletion was successful, or an error message string. """ pass + + @abstractmethod + def edit_comment(self, comment_id: int, user_id: int, content: str) -> Comment | str: + """ + Edits a comment's content. + Author only (not admin). + Updates content and sets edited_at=now. + Cannot edit a deleted comment. + + Args: + comment_id (int): ID of the comment to edit. + user_id (int): ID of the user requesting the edit. + content (str): New text content of the comment. + + Returns: + Comment | str: The updated Comment entity, or an error message string. + """ + pass + + @abstractmethod + def hard_delete_comment(self, comment_id: int, user_id: int) -> bool | str: + """ + Permanently deletes a comment from the database. Admin only. + Intended for removing already soft-deleted comments. + Children comments get comment_reply_to set to NULL via FK. + + Args: + comment_id (int): ID of the comment to permanently delete. + user_id (int): ID of the requesting user (must be admin). + + Returns: + bool | str: True if deletion was successful, or an error message string. + """ + pass diff --git a/src/application/services/comment_service.py b/src/application/services/comment_service.py index fe43120..c97e342 100644 --- a/src/application/services/comment_service.py +++ b/src/application/services/comment_service.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import UTC, datetime import nh3 @@ -32,31 +32,22 @@ def __init__( article_repository: ArticleRepository, account_repository: AccountRepository, ): - """ - Initialize the service via Dependency Injection. - - Args: - comment_repository (CommentRepository): Port for comment data access. - article_repository (ArticleRepository): Port for article data access. - account_repository (AccountRepository): Port for account data access. - """ self.comment_repository = comment_repository self.article_repository = article_repository self.account_repository = account_repository def _get_account_if_exists(self, user_id: int) -> Account | str: """ - Helper method to retrieve and validate an account. + Retrieves an account by user ID. Returns error string if not found or banned. Args: - user_id (int): The unique identifier of the user to check. + user_id (int): The ID of the user to look up. Returns: - Account | str: The Account domain entity if found, or an error message string. + Account | str: The Account domain entity, or an error message string. """ account = self.account_repository.get_by_id(user_id) if not account: - # TODO: Raise AccountNotFoundException return "Account not found." if account.is_banned: return "Account is banned." @@ -64,16 +55,6 @@ def _get_account_if_exists(self, user_id: int) -> Account | str: @staticmethod def _get_comment_depth(comment_id: int, comment_repo: CommentRepository) -> int: - """ - Walks comment_reply_to chain up to root to compute nesting depth. - - Args: - comment_id (int): ID of the comment to measure depth for. - comment_repo (CommentRepository): Repository for loading comments. - - Returns: - int: Nesting depth (0 for root comment). - """ depth = 0 current_id = comment_id for _ in range(10): @@ -84,44 +65,30 @@ def _get_comment_depth(comment_id: int, comment_repo: CommentRepository) -> int: depth += 1 return depth - def _delete_with_descendants(self, comment_id: int) -> None: - """ - Recursively deletes a comment and all its descendants from the repository. - - Traverses the reply tree depth-first, deleting leaf comments first - to respect foreign key constraints. - - Args: - comment_id (int): ID of the root comment to delete along with its subtree. - """ - children = self.comment_repository.get_by_reply_to(comment_id) - for child in children: - self._delete_with_descendants(child.comment_id) - self.comment_repository.delete(comment_id) - def create_comment(self, article_id: int, user_id: int, content: str) -> Comment | str: """ - Creates a top-level comment on an article. + Creates a new top-level comment on an article. + + Validates the account, checks article existence, sanitizes HTML content, + and persists the comment. Args: - article_id (int): ID of the article being commented on. - user_id (int): ID of the user creating the comment. - content (str): Text content of the comment. + article_id (int): ID of the article to comment on. + user_id (int): ID of the author account. + content (str): Raw comment text (may contain limited HTML). Returns: - Comment | str: The created Comment entity, or an error message string. + Comment | str: The created Comment domain entity, or an error message. """ account_or_error = self._get_account_if_exists(user_id) if isinstance(account_or_error, str): - # TODO: Raise UnauthorizedException later return account_or_error account: Account = account_or_error article = self.article_repository.get_by_id(article_id) if not article: - # TODO: Raise ArticleNotFoundException later return "Article not found." sanitized = nh3.clean( @@ -139,7 +106,7 @@ def create_comment(self, article_id: int, user_id: int, content: str) -> Comment comment_written_account_id=account.account_id, comment_reply_to=None, comment_content=sanitized, - comment_posted_at=datetime.now(), + comment_posted_at=datetime.now(UTC), ) self.comment_repository.save(new_comment) @@ -147,32 +114,30 @@ def create_comment(self, article_id: int, user_id: int, content: str) -> Comment def create_reply(self, parent_comment_id: int, user_id: int, content: str) -> Comment | str: """ - Creates a reply directly to a parent comment. + Creates a reply to an existing comment. + + Validates parent comment exists, is not deleted, and respects max nesting depth. Args: - parent_comment_id (int): The ID of the comment being replied to. - user_id (int): The identifier of the user creating the reply. - content (str): The text content of the reply. + parent_comment_id (int): ID of the parent comment to reply to. + user_id (int): ID of the author account. + content (str): Raw reply text (may contain limited HTML). Returns: - Comment | str: The new Comment domain entity if successful, - or an error message string if unauthorized or parent not found. + Comment | str: The created Comment domain entity, or an error message. """ account_or_error = self._get_account_if_exists(user_id) if isinstance(account_or_error, str): - # TODO: Raise UnauthorizedException later return account_or_error account: Account = account_or_error parent_comment = self.comment_repository.get_by_id(parent_comment_id) if not parent_comment: - # TODO: Raise CommentNotFoundException later return "Parent comment not found." - if "" in parent_comment.comment_content: - # TODO: Raise CannotReplyException later - return "Cannot reply to a removed comment." + if parent_comment.is_deleted: + return "Cannot reply to a deleted comment." parent_depth = self._get_comment_depth(parent_comment.comment_id, self.comment_repository) if parent_depth >= MAX_REPLY_DEPTH: @@ -193,7 +158,7 @@ def create_reply(self, parent_comment_id: int, user_id: int, content: str) -> Co comment_written_account_id=account.account_id, comment_content=sanitized, comment_reply_to=parent_comment.comment_id, - comment_posted_at=datetime.now(), + comment_posted_at=datetime.now(UTC), ) self.comment_repository.save(new_reply) @@ -201,19 +166,17 @@ def create_reply(self, parent_comment_id: int, user_id: int, content: str) -> Co def get_comments_for_article(self, article_id: int) -> list[CommentNode] | str: """ - Retrieves all comments for a specific article and structures them - in a nested tree for display, along with author names. + Retrieves all comments for an article as a nested tree. Args: article_id (int): ID of the article. Returns: - list[CommentNode] | str: The nested tree root nodes, - or an error message string if the article is not found. + list[CommentNode] | str: List of root CommentNode objects with nested replies, + or an error message if the article is not found. """ article = self.article_repository.get_by_id(article_id) if not article: - # TODO: Raise ArticleNotFoundException later return "Article not found." all_comments = self.comment_repository.get_all_by_article_id(article_id) @@ -225,57 +188,121 @@ def get_comments_for_article(self, article_id: int) -> list[CommentNode] | str: def mask_comments_by_account_id(self, account_id: int) -> None: """ - Masks all comments authored by the given account as removed. + Masks all comments by a given account (used during account deletion). - Iterates over comments found via the repository and replaces - their content with a "Comment removed" marker. Called during - account deletion before the account record is deleted. + Sets is_deleted=True, deleted_at=now, and replaces content with a removal notice. Args: - account_id (int): ID of the account whose comments to mask. + account_id (int): ID of the account whose comments should be masked. """ comments = self.comment_repository.get_by_account_id(account_id) for comment in comments: comment.comment_content = "Comment removed" + comment.is_deleted = True + comment.deleted_at = datetime.now(UTC) self.comment_repository.save(comment) def delete_comment(self, comment_id: int, user_id: int) -> bool | str: """ - Deletes a comment. - - If comment_written_account_id is None (author deleted), hard-deletes immediately. - - Otherwise, first click soft-deletes (content → "Comment removed"). - Second click hard-deletes recursively. + Soft-deletes a comment. Author or admin only. Idempotent if already deleted. Args: comment_id (int): ID of the comment to delete. - user_id (int): ID of the user requesting the deletion. + user_id (int): ID of the requesting user. Returns: - bool | str: True if deletion was successful, or an error message string. + bool | str: True on success, or an error message string. """ account_or_error = self._get_account_if_exists(user_id) if isinstance(account_or_error, str): - # TODO: Raise UnauthorizedException later return account_or_error account: Account = account_or_error + comment = self.comment_repository.get_by_id(comment_id) + if not comment: + return "Comment not found." - if account.account_role != AccountRole.ADMIN: - # TODO: Raise InsufficientPermissionsException later - return "Unauthorized : Only admins can delete comments." + is_author = comment.comment_written_account_id == account.account_id + is_admin = account.account_role == AccountRole.ADMIN + if not is_author and not is_admin: + return "Unauthorized: You can only delete your own comments." + + if comment.is_deleted: + return True + + comment.is_deleted = True + comment.deleted_at = datetime.now(UTC) + self.comment_repository.save(comment) + return True + + def edit_comment(self, comment_id: int, user_id: int, content: str) -> Comment | str: + """ + Edits a comment's content. Author only (not admin). Cannot edit a deleted comment. + + Args: + comment_id (int): ID of the comment to edit. + user_id (int): ID of the requesting user (must be the author). + content (str): New comment text (may contain limited HTML). + + Returns: + Comment | str: The updated Comment domain entity, or an error message. + """ + account_or_error = self._get_account_if_exists(user_id) + if isinstance(account_or_error, str): + return account_or_error + account: Account = account_or_error comment = self.comment_repository.get_by_id(comment_id) if not comment: - # TODO: Raise CommentNotFoundException later return "Comment not found." - if ( - "" in comment.comment_content - or comment.comment_written_account_id is None - ): - self._delete_with_descendants(comment_id) - return True + if comment.comment_written_account_id != account.account_id: + return "Unauthorized: You can only edit your own comments." + + if comment.is_deleted: + return "Cannot edit a deleted comment." + + sanitized = nh3.clean( + content, + tags=self.ALLOWED_TAGS, + attributes={"a": {"href", "target"}}, + link_rel="noopener noreferrer", + ) + if not sanitized.strip(): + return "Comment cannot be empty." - comment.comment_content = "Comment removed" + comment.comment_content = sanitized + comment.edited_at = datetime.now(UTC) self.comment_repository.save(comment) + return comment + + def hard_delete_comment(self, comment_id: int, user_id: int) -> bool | str: + """ + Permanently deletes a comment from the database. Admin only. + Only allowed on already soft-deleted comments. + Children get comment_reply_to set to NULL via FK ON DELETE SET NULL. + + Args: + comment_id (int): ID of the comment to permanently delete. + user_id (int): ID of the requesting user (must be admin). + + Returns: + bool | str: True on success, or an error message string. + """ + account_or_error = self._get_account_if_exists(user_id) + if isinstance(account_or_error, str): + return account_or_error + + account: Account = account_or_error + if account.account_role != AccountRole.ADMIN: + return "Unauthorized: Only admins can permanently delete comments." + + comment = self.comment_repository.get_by_id(comment_id) + if not comment: + return "Comment not found." + + if not comment.is_deleted: + return "Comment is not soft-deleted. Use soft-delete first." + + self.comment_repository.delete(comment_id) return True diff --git a/src/infrastructure/input_adapters/dto/comment_response.py b/src/infrastructure/input_adapters/dto/comment_response.py index cd6c933..f615fb3 100644 --- a/src/infrastructure/input_adapters/dto/comment_response.py +++ b/src/infrastructure/input_adapters/dto/comment_response.py @@ -1,6 +1,8 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import datetime +from zoneinfo import ZoneInfo from pydantic import BaseModel, ConfigDict @@ -12,7 +14,17 @@ class CommentResponse(BaseModel): Handles formatting of complex types like dates. Attributes: + comment_id (int): Unique identifier for the comment. + comment_article_id (int): Reference to the associated article. + comment_written_account_id (int | None): Reference to the author's account. + author_username (str): Display name of the comment author. author_avatar_file_id (str | None): UUID of the author's avatar file, or None. + comment_reply_to (int | None): Reference to a parent comment (for replies). + comment_content (str): Text content of the comment. + comment_posted_at_formatted (str): Human-readable posting date in local time. + is_deleted (bool): Soft-delete flag. + edited_at (datetime | None): Last edit timestamp. None if never edited. + edited_at_formatted (str): Human-readable edit time in local time. Empty if never edited. """ model_config = ConfigDict(from_attributes=True) @@ -24,37 +36,79 @@ class CommentResponse(BaseModel): comment_reply_to: int | None comment_content: str comment_posted_at_formatted: str = "" + is_deleted: bool = False + edited_at: datetime | None = None + edited_at_formatted: str = "" + + @staticmethod + def _to_local_full(dt: datetime) -> str: + """ + Converts a UTC datetime to Europe/Paris and returns a full formatted string. + + Args: + dt (datetime): The datetime to convert (assumed UTC if naive). + + Returns: + str: Formatted date like "October 27, 2023 at 16:30". + """ + if dt.tzinfo is None: + dt = dt.replace(tzinfo=ZoneInfo("UTC")) + local = dt.astimezone(ZoneInfo("Europe/Paris")) + return local.strftime("%B %d, %Y at %H:%M") + + @staticmethod + def _to_local_time(dt: datetime) -> str: + """ + Converts a UTC datetime to Europe/Paris and returns just the time. + + Args: + dt (datetime): The datetime to convert (assumed UTC if naive). + + Returns: + str: Formatted time like "16:30". + """ + if dt.tzinfo is None: + dt = dt.replace(tzinfo=ZoneInfo("UTC")) + local = dt.astimezone(ZoneInfo("Europe/Paris")) + return local.strftime("%H:%M") @classmethod def from_domain(cls, comment, author_username: str = "Unknown", author_avatar_file_id: str | None = None): """ Helper factory to create a response DTO from a domain Comment entity. - Formats the posting date into a reader-friendly string. + Formats dates into reader-friendly strings in Europe/Paris local time. + + Maps is_deleted, deleted_at, and edited_at from the domain entity. + If the comment's author account has been deleted (comment_written_account_id is None) + or the comment has been soft-deleted (is_deleted is True), + the author is displayed as "Anonymous" and content shows as 'Comment removed'. Args: - comment (Comment): The domain comment entity. - author_username (str): The username of the comment author. - Overridden to "Anonymous" if comment_content is "Comment removed" or "[deleted]". - author_avatar_file_id (str | None): Optional UUID of the author's avatar file. + comment: The domain Comment entity to convert. + author_username (str): Display name of the comment author. + author_avatar_file_id (str | None): UUID of the author's avatar file. Returns: - CommentResponse: The initialized DTO. + CommentResponse: The response DTO ready for serialization. """ content = comment.comment_content + is_deleted = comment.is_deleted if comment.comment_written_account_id is None: author_username = "Anonymous" - content = "Comment removed" + content = "Comment removed" - if "" in content or content in ("Comment removed", "[deleted]"): + elif is_deleted: author_username = "Anonymous" - - if "" in content: - content = content.replace("", "") + content = "Comment removed" formatted_date = "" if comment.comment_posted_at: - formatted_date = comment.comment_posted_at.strftime("%B %d, %Y at %H:%M") + formatted_date = cls._to_local_full(comment.comment_posted_at) + + edited_at_formatted = "" + if comment.edited_at: + edited_at_formatted = cls._to_local_time(comment.edited_at) return cls( comment_id=comment.comment_id, @@ -64,7 +118,10 @@ def from_domain(cls, comment, author_username: str = "Unknown", author_avatar_fi author_avatar_file_id=author_avatar_file_id, comment_reply_to=comment.comment_reply_to, comment_content=content, - comment_posted_at_formatted=formatted_date + comment_posted_at_formatted=formatted_date, + is_deleted=is_deleted, + edited_at=comment.edited_at, + edited_at_formatted=edited_at_formatted, ) @classmethod diff --git a/src/infrastructure/input_adapters/flask/flask_comment_adapter.py b/src/infrastructure/input_adapters/flask/flask_comment_adapter.py index 9f54f9d..116fcec 100644 --- a/src/infrastructure/input_adapters/flask/flask_comment_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_comment_adapter.py @@ -139,7 +139,7 @@ def reply_to_comment(self, article_id: int, parent_comment_id: int) -> Response: def delete_comment(self, article_id: int, comment_id: int) -> Response: """ - Handles the deletion of a comment (Admin only). + Handles soft-deletion of a comment. Author or admin only. Single-click, no confirm-dialog. Args: article_id (int): ID of the article (for redirection). @@ -166,3 +166,65 @@ def delete_comment(self, article_id: int, comment_id: int) -> Response: flash("Unauthorized or error.", "error") return redirect(url_for("article.read_article", article_id=article_id)) + + def edit_comment(self, article_id: int, comment_id: int) -> Response: + """ + Handles editing a comment's content. Author only (not admin). + Inline textarea toggle in the template. No rate-limit, no honeypot. + + Args: + article_id (int): ID of the article (for redirection). + comment_id (int): ID of the comment to edit. + + Returns: + Response: A redirect to the article detail page. + """ + user = global_request_context.get("current_user") + if not user: + flash("You must be signed in to edit comments.", "error") + return redirect(url_for("auth.login")) + + content = request.form.get("content", "") + result = self.comment_service.edit_comment( + comment_id=comment_id, + user_id=user.account_id, + content=content, + ) + + if isinstance(result, str): + flash(result, "error") + else: + flash("Comment updated.", "success") + + return redirect(url_for("article.read_article", article_id=article_id)) + + def hard_delete_comment(self, article_id: int, comment_id: int) -> Response: + """ + Handles permanent hard-deletion of a comment. Admin only. + Called on already soft-deleted comments to purge them from the database. + + Args: + article_id (int): ID of the article (for redirection). + comment_id (int): ID of the comment to permanently delete. + + Returns: + Response: A redirect to the article detail page. + """ + user = global_request_context.get("current_user") + if not user: + flash("You must be signed in to delete comments.", "error") + return redirect(url_for("auth.login")) + + result = self.comment_service.hard_delete_comment( + comment_id=comment_id, + user_id=user.account_id, + ) + + if isinstance(result, str): + flash(result, "error") + elif result is True: + flash("Comment permanently deleted.", "success") + else: + flash("Unauthorized or error.", "error") + + return redirect(url_for("article.read_article", article_id=article_id)) diff --git a/src/infrastructure/output_adapters/dto/comment_record.py b/src/infrastructure/output_adapters/dto/comment_record.py index 943d819..2a650b2 100644 --- a/src/infrastructure/output_adapters/dto/comment_record.py +++ b/src/infrastructure/output_adapters/dto/comment_record.py @@ -20,11 +20,17 @@ class CommentRecord(BaseModel): comment_reply_to: int | None comment_content: str comment_posted_at: datetime + is_deleted: bool = False + deleted_at: datetime | None = None + edited_at: datetime | None = None def to_domain(self) -> Comment: """ Converts the database record into a domain Comment entity. + Maps all fields including is_deleted, deleted_at, and edited_at + to the domain Comment object. + Returns: Comment: The corresponding domain entity. """ @@ -35,4 +41,7 @@ def to_domain(self) -> Comment: comment_reply_to=self.comment_reply_to, comment_content=self.comment_content, comment_posted_at=self.comment_posted_at, + is_deleted=self.is_deleted, + deleted_at=self.deleted_at, + edited_at=self.edited_at, ) diff --git a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py index e95f492..662d46c 100644 --- a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py +++ b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py @@ -2,6 +2,7 @@ from sqlalchemy import ( TIMESTAMP, + Boolean, ForeignKey, Integer, String, @@ -50,3 +51,12 @@ class CommentModel(SqlAlchemyModel): comment_posted_at: Mapped[datetime] = mapped_column( name="comment_posted_at", type_=TIMESTAMP, server_default=func.now() ) + is_deleted: Mapped[bool] = mapped_column( + name="is_deleted", type_=Boolean, default=False, nullable=False, + ) + deleted_at: Mapped[datetime | None] = mapped_column( + name="deleted_at", type_=TIMESTAMP, nullable=True, + ) + edited_at: Mapped[datetime | None] = mapped_column( + name="edited_at", type_=TIMESTAMP, nullable=True, + ) diff --git a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_comment_adapter.py b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_comment_adapter.py index 251799d..12e3c4a 100644 --- a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_comment_adapter.py +++ b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_comment_adapter.py @@ -38,10 +38,12 @@ def _to_domain(self, model: CommentModel) -> Comment: def save(self, comment: Comment) -> None: """ - Saves a new comment or updates an existing one. + Saves a new comment or updates an existing one in the database. + If the comment has a valid positive ID, an UPDATE is performed. + Otherwise, a new record is INSERTed. Args: - comment (Comment): The Comment domain entity to save. + comment (Comment): The domain Comment entity to persist. """ if comment.comment_id and comment.comment_id > 0: self._session.query(CommentModel).filter_by( @@ -51,6 +53,9 @@ def save(self, comment: Comment) -> None: CommentModel.comment_written_account_id: comment.comment_written_account_id, CommentModel.comment_reply_to: comment.comment_reply_to, CommentModel.comment_content: comment.comment_content, + CommentModel.is_deleted: comment.is_deleted, + CommentModel.deleted_at: comment.deleted_at, + CommentModel.edited_at: comment.edited_at, }) self._session.commit() return @@ -61,6 +66,9 @@ def save(self, comment: Comment) -> None: model.comment_written_account_id = comment.comment_written_account_id model.comment_reply_to = comment.comment_reply_to model.comment_content = comment.comment_content + model.is_deleted = comment.is_deleted + model.deleted_at = comment.deleted_at + model.edited_at = comment.edited_at self._session.commit() def get_by_id(self, comment_id: int) -> Comment | None: diff --git a/tests/test_domain_factories.py b/tests/test_domain_factories.py index 63db5bc..2d6d916 100644 --- a/tests/test_domain_factories.py +++ b/tests/test_domain_factories.py @@ -60,6 +60,9 @@ def create_test_comment( comment_reply_to: int | None = None, comment_content: str = "Test comment content.", comment_posted_at: datetime | None = None, + is_deleted: bool = False, + deleted_at: datetime | None = None, + edited_at: datetime | None = None, ) -> Comment: """Factory to create a test Comment entity with sensible defaults.""" if comment_posted_at is None: @@ -72,4 +75,7 @@ def create_test_comment( comment_reply_to=comment_reply_to, comment_content=comment_content, comment_posted_at=comment_posted_at, + is_deleted=is_deleted, + deleted_at=deleted_at, + edited_at=edited_at, ) diff --git a/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py b/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py index 6080f8e..9671311 100644 --- a/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py +++ b/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py @@ -12,7 +12,7 @@ def test_comment_response_from_domain_mapping(): comment_written_account_id=5, comment_reply_to=None, comment_content="Hello world", - comment_posted_at=posted_at + comment_posted_at=posted_at, ) response = CommentResponse.from_domain(domain_comment, author_username="johndoe") @@ -22,7 +22,9 @@ def test_comment_response_from_domain_mapping(): assert response.author_username == "johndoe" assert response.comment_reply_to is None assert response.comment_content == "Hello world" - assert response.comment_posted_at_formatted == "October 27, 2023 at 14:30" + assert response.comment_posted_at_formatted == "October 27, 2023 at 16:30" + assert response.is_deleted is False + assert response.edited_at is None def test_comment_response_from_domain_with_reply(): domain_comment = Comment( @@ -38,19 +40,87 @@ def test_comment_response_from_domain_with_reply(): assert response.comment_reply_to == 1 assert response.author_username == "Unknown" -def test_comment_response_from_domain_legacy_deleted_maps_to_anonymous(): +def test_comment_response_from_domain_deleted(): + posted_at = datetime(2023, 10, 27, 14, 30) domain_comment = Comment( comment_id=3, comment_article_id=10, comment_written_account_id=5, comment_reply_to=None, - comment_content="[deleted]", - comment_posted_at=datetime.now() + comment_content="Original content", + comment_posted_at=posted_at, + is_deleted=True, + ) + + response = CommentResponse.from_domain(domain_comment, author_username="johndoe") + assert response.author_username == "Anonymous" + assert response.comment_content == "Comment removed" + assert response.is_deleted is True + +def test_comment_response_from_domain_edited(): + posted_at = datetime(2023, 10, 27, 14, 30) + edited_at = datetime(2023, 10, 28, 10, 0) + domain_comment = Comment( + comment_id=4, + comment_article_id=10, + comment_written_account_id=5, + comment_reply_to=None, + comment_content="Edited content", + comment_posted_at=posted_at, + edited_at=edited_at, + ) + + response = CommentResponse.from_domain(domain_comment, author_username="johndoe") + assert response.comment_content == "Edited content" + assert response.is_deleted is False + assert response.edited_at == edited_at + assert response.edited_at_formatted == "12:00" + +def test_comment_response_from_domain_deleted_with_edited_at(): + posted_at = datetime(2023, 10, 27, 14, 30) + edited_at = datetime(2023, 10, 28, 10, 0) + domain_comment = Comment( + comment_id=5, + comment_article_id=10, + comment_written_account_id=5, + comment_reply_to=None, + comment_content="Edited then deleted", + comment_posted_at=posted_at, + is_deleted=True, + edited_at=edited_at, ) - response = CommentResponse.from_domain(domain_comment, author_username="original_user") + response = CommentResponse.from_domain(domain_comment, author_username="johndoe") assert response.author_username == "Anonymous" - assert response.comment_content == "[deleted]" + assert response.comment_content == "Comment removed" + assert response.is_deleted is True + assert response.edited_at == edited_at + assert response.edited_at_formatted == "12:00" + +def test_comment_response_from_domain_with_all_fields(): + posted_at = datetime(2023, 10, 27, 14, 30) + domain_comment = Comment( + comment_id=6, + comment_article_id=10, + comment_written_account_id=5, + comment_reply_to=1, + comment_content="Full data", + comment_posted_at=posted_at, + is_deleted=False, + edited_at=None, + ) + + response = CommentResponse.from_domain( + domain_comment, + author_username="johndoe", + author_avatar_file_id="avatar-uuid", + ) + assert response.comment_id == 6 + assert response.comment_reply_to == 1 + assert response.author_avatar_file_id == "avatar-uuid" + assert response.is_deleted is False + assert response.edited_at is None + assert response.edited_at_formatted == "" def test_map_nested_tree(): posted_at = datetime(2023, 10, 27, 14, 30) diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_comment_adapter.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_comment_adapter.py index 5ee3e63..f3dafbf 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_comment_adapter.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_comment_adapter.py @@ -3,7 +3,7 @@ from src.application.domain.account import AccountRole from src.application.input_ports.comment_management import CommentManagementPort from src.infrastructure.input_adapters.flask.flask_comment_adapter import CommentAdapter -from tests.test_domain_factories import create_test_account +from tests.test_domain_factories import create_test_account, create_test_comment from tests.tests_infrastructure.tests_input_adapters.tests_flask.flask_test_utils import ( FlaskInputAdapterTestBase, ) @@ -36,6 +36,20 @@ def setup_method(self): endpoint="comment.delete_comment" ) + self.app.add_url_rule( + "/articles//comments//edit", + view_func=self.adapter.edit_comment, + methods=["POST"], + endpoint="comment.edit_comment" + ) + + self.app.add_url_rule( + "/articles//comments//delete-permanent", + view_func=self.adapter.hard_delete_comment, + methods=["POST"], + endpoint="comment.hard_delete_comment" + ) + self._register_dummy_route("/login", "auth.login") self._register_dummy_route("/articles/", "article.read_article") @@ -142,7 +156,7 @@ def test_reply_rate_limited(self): class TestCommentDelete(CommentAdapterTestBase): def test_delete_comment_success(self): - user = create_test_account(account_id=1, account_role=AccountRole.ADMIN) + user = create_test_account(account_id=1, account_role=AccountRole.USER) self.set_current_user(user) self.mock_comment_service.delete_comment.return_value = True response = self.client.post("/articles/1/comments/99/delete") @@ -160,7 +174,7 @@ def test_delete_comment_requires_login(self): assert b"alert-error" in response.data def test_delete_comment_service_error_string(self): - user = create_test_account(account_id=1, account_role=AccountRole.ADMIN) + user = create_test_account(account_id=1, account_role=AccountRole.USER) self.set_current_user(user) self.mock_comment_service.delete_comment.return_value = "Comment not found" response = self.client.post("/articles/1/comments/99/delete", follow_redirects=True) @@ -168,9 +182,67 @@ def test_delete_comment_service_error_string(self): assert b"alert-error" in response.data def test_delete_comment_unauthorized_none_return(self): - user = create_test_account(account_id=1, account_role=AccountRole.ADMIN) + user = create_test_account(account_id=1, account_role=AccountRole.USER) self.set_current_user(user) self.mock_comment_service.delete_comment.return_value = None response = self.client.post("/articles/1/comments/99/delete", follow_redirects=True) assert b"Unauthorized or error" in response.data assert b"alert-error" in response.data + + +class TestCommentEdit(CommentAdapterTestBase): + def test_edit_comment_author_only(self): + user = create_test_account(account_id=1) + self.set_current_user(user) + self.mock_comment_service.edit_comment.return_value = create_test_comment( + comment_id=10, comment_written_account_id=1, + ) + response = self.client.post("/articles/1/comments/10/edit", data={"content": "Updated"}) + assert response.status_code == 302 + assert response.location.endswith("/articles/1") + self.mock_comment_service.edit_comment.assert_called_once_with( + comment_id=10, user_id=1, content="Updated", + ) + + def test_edit_comment_missing_content(self): + user = create_test_account(account_id=1) + self.set_current_user(user) + response = self.client.post("/articles/1/comments/10/edit", data={"content": ""}) + assert response.status_code == 302 + self.mock_comment_service.edit_comment.assert_called_once_with( + comment_id=10, user_id=1, content="", + ) + + def test_edit_comment_not_authenticated(self): + response = self.client.post("/articles/1/comments/10/edit", data={"content": "Hack"}, follow_redirects=True) + assert b"You must be signed in to edit comments" in response.data + assert b"alert-error" in response.data + self.mock_comment_service.edit_comment.assert_not_called() + + +class TestCommentHardDelete(CommentAdapterTestBase): + def test_hard_delete_comment_success(self): + user = create_test_account(account_id=1, account_role=AccountRole.USER) + self.set_current_user(user) + self.mock_comment_service.hard_delete_comment.return_value = True + response = self.client.post("/articles/1/comments/99/delete-permanent") + assert response.status_code == 302 + assert response.location.endswith("/articles/1") + self.mock_comment_service.hard_delete_comment.assert_called_once_with( + comment_id=99, + user_id=1, + ) + + def test_hard_delete_comment_not_authenticated(self): + response = self.client.post("/articles/1/comments/99/delete-permanent", follow_redirects=True) + assert b"You must be signed in to delete comments" in response.data + assert b"alert-error" in response.data + self.mock_comment_service.hard_delete_comment.assert_not_called() + + def test_hard_delete_comment_service_error_string(self): + user = create_test_account(account_id=1, account_role=AccountRole.USER) + self.set_current_user(user) + self.mock_comment_service.hard_delete_comment.return_value = "Comment not found" + response = self.client.post("/articles/1/comments/99/delete-permanent", follow_redirects=True) + assert b"Comment not found" in response.data + assert b"alert-error" in response.data diff --git a/tests/tests_integration/test_article_integration.py b/tests/tests_integration/test_article_integration.py index 039e3af..c2a969e 100644 --- a/tests/tests_integration/test_article_integration.py +++ b/tests/tests_integration/test_article_integration.py @@ -181,7 +181,9 @@ def test_orphan_comment_hard_delete_single_click_integ(self, client, db_session) assert resp.status_code == 302 db_session.expire_all() - assert db_session.get(CommentModel, comment_id) is None + soft = db_session.get(CommentModel, comment_id) + assert soft is not None + assert soft.is_deleted is True def test_article_delete_end_to_end_integ(self, client, db_session): """ diff --git a/tests/tests_integration/test_workflow_integration.py b/tests/tests_integration/test_workflow_integration.py index 5636058..0bd8f42 100644 --- a/tests/tests_integration/test_workflow_integration.py +++ b/tests/tests_integration/test_workflow_integration.py @@ -85,46 +85,83 @@ def test_honeypot_blocks_spam_bots(self, client, db_session): comments = db_session.query(CommentModel).filter_by(comment_article_id=aid).all() assert len(comments) == 0 - def test_two_click_delete_cascade(self, client, db_session): + def test_comment_soft_delete_integration(self, client, db_session): """ - Verifies two-click delete + cascade across the full stack. - First click soft-deletes (content → marker + Anonymous). - Second click hard-deletes parent + children from DB. + Verifies soft-delete across the full stack. + Author deletes own comment → is_deleted=True, content preserved in DB, + "[deleted]" shown on page, author name still visible. """ - admin = AccountModel( - account_username="del_admin", account_email="del@t.com", - account_password="p", account_role="admin" + author = AccountModel( + account_username="soft_author", account_email="soft@t.com", + account_password="p", account_role="author", ) - db_session.add(admin) + db_session.add(author) db_session.commit() - client.post("/login", data={"username": "del_admin", "password": "p"}, follow_redirects=True) - r = client.post("/api/articles", json={"title": "Del Test", "content": "Content"}) + client.post("/login", data={"username": "soft_author", "password": "p"}, follow_redirects=True) + r = client.post("/api/articles", json={"title": "Soft Del Test", "content": "Content"}) aid = r.get_json()["id"] - client.post(f"/articles/{aid}/comments", data={"content": "Root"}) + client.post(f"/articles/{aid}/comments", data={"content": "Root comment"}) db_session.commit() root = db_session.query(CommentModel).filter_by(comment_article_id=aid).first() rid = root.comment_id - client.post(f"/articles/{aid}/comments/{rid}/reply", data={"content": "Child"}) - first_click = client.post(f"/articles/{aid}/comments/{rid}/delete", follow_redirects=False) - assert first_click.status_code == 302 + delete_resp = client.post(f"/articles/{aid}/comments/{rid}/delete", follow_redirects=False) + assert delete_resp.status_code == 302 db_session.expire_all() soft = db_session.get(CommentModel, rid) - assert "" in soft.comment_content - assert "Comment removed" in soft.comment_content + assert soft.is_deleted is True + assert soft.deleted_at is not None + assert soft.comment_content == "Root comment" detail = client.get(f"/articles/{aid}") assert b"Anonymous" in detail.data - assert b">?<" in detail.data - assert b"comment-deleted" in detail.data + assert b"Comment removed" in detail.data + assert b"Root comment" not in detail.data + + def test_comment_edit_and_soft_delete_integration(self, client, db_session): + """ + Verifies comment edit then soft-delete across the full stack. + Author creates comment, edits it, then soft-deletes it. + """ + author = AccountModel( + account_username="edit_user", account_email="edit@t.com", + account_password="p", account_role="author", + ) + db_session.add(author) + db_session.commit() + client.post("/login", data={"username": "edit_user", "password": "p"}, follow_redirects=True) + r = client.post("/api/articles", json={"title": "Edit Test", "content": "Content"}) + aid = r.get_json()["id"] + + client.post(f"/articles/{aid}/comments", data={"content": "Original"}) + db_session.commit() + comment = db_session.query(CommentModel).filter_by(comment_article_id=aid).first() + cid = comment.comment_id + + edit_resp = client.post(f"/articles/{aid}/comments/{cid}/edit", data={"content": "Updated"}, follow_redirects=False) + assert edit_resp.status_code == 302 + db_session.expire_all() + edited = db_session.get(CommentModel, cid) + assert edited.comment_content == "Updated" + assert edited.edited_at is not None + + detail = client.get(f"/articles/{aid}") + assert b"Updated" in detail.data + assert b"edited at" in detail.data - second_click = client.post(f"/articles/{aid}/comments/{rid}/delete", follow_redirects=False) - assert second_click.status_code == 302 + delete_resp = client.post(f"/articles/{aid}/comments/{cid}/delete", follow_redirects=False) + assert delete_resp.status_code == 302 db_session.expire_all() - assert db_session.get(CommentModel, rid) is None - assert db_session.query(CommentModel).filter_by(comment_reply_to=rid).count() == 0 + deleted = db_session.get(CommentModel, cid) + assert deleted.is_deleted is True + assert deleted.comment_content == "Updated" + + detail_after_delete = client.get(f"/articles/{aid}") + assert b"Anonymous" in detail_after_delete.data + assert b"Updated" not in detail_after_delete.data + assert b"Comment removed" in detail_after_delete.data def test_deep_comment_threading_integ(self, client, db_session): """ @@ -538,3 +575,41 @@ def test_non_admin_change_role_returns_error_integ(self, client, db_session): ) assert response.status_code == 403 + + +class TestCommentHardDeleteIntegration: + def test_comment_hard_delete_integration(self, client, db_session): + admin = AccountModel( + account_username="hard_del_admin", account_email="hda@t.com", + account_password="p", account_role="admin", + ) + db_session.add(admin) + db_session.commit() + + article = ArticleModel( + article_title="Hard Delete Test", article_content="Content", + article_author_id=admin.account_id, + ) + db_session.add(article) + db_session.commit() + + comment = CommentModel( + comment_content="To be hard deleted", + comment_article_id=article.article_id, + comment_written_account_id=admin.account_id, + is_deleted=True, + deleted_at=datetime.now(UTC), + ) + db_session.add(comment) + db_session.commit() + + client.post("/login", data={"username": "hard_del_admin", "password": "p"}) + + cid = comment.comment_id + aid = article.article_id + resp = client.post(f"/articles/{aid}/comments/{cid}/delete-permanent", follow_redirects=True) + assert resp.status_code == 200 + + db_session.expire_all() + assert db_session.get(CommentModel, cid) is None + assert b"Comment permanently deleted" in resp.data diff --git a/tests/tests_services/test_comment_service.py b/tests/tests_services/test_comment_service.py index 6f5b6aa..a528595 100644 --- a/tests/tests_services/test_comment_service.py +++ b/tests/tests_services/test_comment_service.py @@ -179,21 +179,22 @@ def test_create_reply_parent_not_found(self): self.mock_comment_repo.save.assert_not_called() assert result == "Parent comment not found." - def test_create_reply_to_removed_comment_returns_error(self): + def test_create_reply_to_deleted_comment_returns_error(self): self.mock_account_repo.get_by_id.return_value = create_test_account( account_id=99, account_role=AccountRole.ADMIN ) - removed = create_test_comment( + deleted = create_test_comment( comment_id=5, - comment_content="Comment removed" + comment_content="Original content", + is_deleted=True, ) - self.mock_comment_repo.get_by_id.return_value = removed - result = self.service.create_reply(5, 99, "Reply to removed") + self.mock_comment_repo.get_by_id.return_value = deleted + result = self.service.create_reply(5, 99, "Reply to deleted") self.mock_comment_repo.save.assert_not_called() assert isinstance(result, str) - assert "removed" in result.lower() + assert "deleted" in result.lower() def test_create_reply_too_deep_returns_error(self): self.mock_account_repo.get_by_id.return_value = create_test_account( @@ -305,70 +306,66 @@ def test_get_comments_for_article_unknown_author(self): class TestDeleteComment(CommentServiceTestBase): - def test_delete_comment_normal_first_click_soft_delete(self): - admin_account = create_test_account(account_id=1, account_role=AccountRole.ADMIN) - self.mock_account_repo.get_by_id.return_value = admin_account + def test_delete_comment_soft_delete_by_author(self): + fake_account = create_test_account(account_id=1, account_role=AccountRole.USER) + self.mock_account_repo.get_by_id.return_value = fake_account comment_to_delete = create_test_comment( comment_id=10, - comment_written_account_id=2, + comment_written_account_id=1, comment_content="Original content", ) self.mock_comment_repo.get_by_id.return_value = comment_to_delete result = self.service.delete_comment( comment_id=comment_to_delete.comment_id, - user_id=admin_account.account_id + user_id=fake_account.account_id, ) - self.mock_account_repo.get_by_id.assert_called_once_with(admin_account.account_id) + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) self.mock_comment_repo.get_by_id.assert_called_once_with(comment_to_delete.comment_id) self.mock_comment_repo.save.assert_called_once() - self.mock_comment_repo.delete.assert_not_called() assert result is True - assert comment_to_delete.comment_content == "Comment removed" + assert comment_to_delete.is_deleted is True + assert comment_to_delete.deleted_at is not None + assert comment_to_delete.comment_content == "Original content" - def test_delete_comment_second_click_cascade(self): - admin_account = create_test_account(account_id=1, account_role=AccountRole.ADMIN) + def test_delete_comment_soft_delete_by_admin(self): + admin_account = create_test_account(account_id=2, account_role=AccountRole.ADMIN) self.mock_account_repo.get_by_id.return_value = admin_account comment_to_delete = create_test_comment( comment_id=10, - comment_content="Comment removed" + comment_written_account_id=1, + comment_content="Original content", ) self.mock_comment_repo.get_by_id.return_value = comment_to_delete - child = create_test_comment(comment_id=11, comment_reply_to=10) - grandchild = create_test_comment(comment_id=12, comment_reply_to=11) - self.mock_comment_repo.get_by_reply_to.side_effect = lambda cid: { - 10: [child], - 11: [grandchild], - 12: [], - }.get(cid, []) result = self.service.delete_comment( comment_id=comment_to_delete.comment_id, user_id=admin_account.account_id, ) + self.mock_comment_repo.save.assert_called_once() assert result is True - assert self.mock_comment_repo.delete.call_args_list[0].args[0] == 12 - assert self.mock_comment_repo.delete.call_args_list[1].args[0] == 11 - assert self.mock_comment_repo.delete.call_args_list[2].args[0] == 10 + assert comment_to_delete.is_deleted is True - def test_delete_comment_unauthorized_not_admin(self): + def test_delete_comment_unauthorized_not_author(self): fake_account = create_test_account(account_id=2, account_role=AccountRole.USER) self.mock_account_repo.get_by_id.return_value = fake_account + comment = create_test_comment(comment_id=10, comment_written_account_id=1) + self.mock_comment_repo.get_by_id.return_value = comment result = self.service.delete_comment(comment_id=10, user_id=fake_account.account_id) self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - self.mock_comment_repo.get_by_id.assert_not_called() - self.mock_comment_repo.delete.assert_not_called() - assert result == "Unauthorized : Only admins can delete comments." + self.mock_comment_repo.get_by_id.assert_called_once() + self.mock_comment_repo.save.assert_not_called() + assert result == "Unauthorized: You can only delete your own comments." def test_delete_comment_not_found(self): - fake_account = create_test_account(account_id=1, account_role=AccountRole.ADMIN) + fake_account = create_test_account(account_id=1, account_role=AccountRole.USER) self.mock_account_repo.get_by_id.return_value = fake_account self.mock_comment_repo.get_by_id.return_value = None result = self.service.delete_comment(comment_id=999, user_id=fake_account.account_id) self.mock_comment_repo.get_by_id.assert_called_once_with(999) - self.mock_comment_repo.delete.assert_not_called() + self.mock_comment_repo.save.assert_not_called() assert result == "Comment not found." def test_delete_comment_account_not_found(self): @@ -376,28 +373,80 @@ def test_delete_comment_account_not_found(self): result = self.service.delete_comment(comment_id=10, user_id=999) self.mock_account_repo.get_by_id.assert_called_once_with(999) self.mock_comment_repo.get_by_id.assert_not_called() - self.mock_comment_repo.delete.assert_not_called() + self.mock_comment_repo.save.assert_not_called() assert result == "Account not found." - def test_delete_comment_orphan_hard_delete(self): - admin_account = create_test_account(account_id=1, account_role=AccountRole.ADMIN) - self.mock_account_repo.get_by_id.return_value = admin_account - orphan_comment = create_test_comment( + def test_delete_comment_already_deleted_idempotent(self): + fake_account = create_test_account(account_id=1, account_role=AccountRole.USER) + self.mock_account_repo.get_by_id.return_value = fake_account + comment = create_test_comment( comment_id=10, - comment_written_account_id=None, - comment_content="Original content", + comment_written_account_id=1, + is_deleted=True, ) - self.mock_comment_repo.get_by_id.return_value = orphan_comment - self.mock_comment_repo.get_by_reply_to.return_value = [] + self.mock_comment_repo.get_by_id.return_value = comment + result = self.service.delete_comment(comment_id=10, user_id=fake_account.account_id) + self.mock_comment_repo.save.assert_not_called() + assert result is True - result = self.service.delete_comment( - comment_id=orphan_comment.comment_id, - user_id=admin_account.account_id, + +class TestEditComment(CommentServiceTestBase): + def test_edit_comment_success(self): + fake_account = create_test_account(account_id=1, account_role=AccountRole.USER) + self.mock_account_repo.get_by_id.return_value = fake_account + comment = create_test_comment( + comment_id=10, + comment_written_account_id=1, + comment_content="Original", + ) + self.mock_comment_repo.get_by_id.return_value = comment + + result = self.service.edit_comment( + comment_id=10, + user_id=fake_account.account_id, + content="Updated content", ) + self.mock_comment_repo.save.assert_called_once() + assert isinstance(result, type(comment)) + assert result.comment_content == "Updated content" + assert result.edited_at is not None + + def test_edit_comment_not_author(self): + fake_account = create_test_account(account_id=2, account_role=AccountRole.USER) + self.mock_account_repo.get_by_id.return_value = fake_account + comment = create_test_comment(comment_id=10, comment_written_account_id=1) + self.mock_comment_repo.get_by_id.return_value = comment + result = self.service.edit_comment(comment_id=10, user_id=2, content="Hack") self.mock_comment_repo.save.assert_not_called() - self.mock_comment_repo.delete.assert_called_once_with(10) - assert result is True + assert result == "Unauthorized: You can only edit your own comments." + + def test_edit_comment_deleted(self): + fake_account = create_test_account(account_id=1, account_role=AccountRole.USER) + self.mock_account_repo.get_by_id.return_value = fake_account + comment = create_test_comment( + comment_id=10, + comment_written_account_id=1, + is_deleted=True, + ) + self.mock_comment_repo.get_by_id.return_value = comment + result = self.service.edit_comment(comment_id=10, user_id=1, content="New") + self.mock_comment_repo.save.assert_not_called() + assert result == "Cannot edit a deleted comment." + + def test_edit_comment_not_found(self): + fake_account = create_test_account(account_id=1) + self.mock_account_repo.get_by_id.return_value = fake_account + self.mock_comment_repo.get_by_id.return_value = None + result = self.service.edit_comment(comment_id=999, user_id=1, content="X") + self.mock_comment_repo.save.assert_not_called() + assert result == "Comment not found." + + def test_edit_comment_account_not_found(self): + self.mock_account_repo.get_by_id.return_value = None + result = self.service.edit_comment(comment_id=10, user_id=999, content="X") + self.mock_comment_repo.save.assert_not_called() + assert result == "Account not found." class TestMaskCommentsByAccountId(CommentServiceTestBase): @@ -413,9 +462,65 @@ def test_mask_comments_success(self): assert self.mock_comment_repo.save.call_count == 2 assert c1.comment_content == "Comment removed" assert c2.comment_content == "Comment removed" + assert c1.is_deleted is True + assert c1.deleted_at is not None def test_mask_comments_no_comments(self): self.mock_comment_repo.get_by_account_id.return_value = [] self.service.mask_comments_by_account_id(999) self.mock_comment_repo.get_by_account_id.assert_called_once_with(999) self.mock_comment_repo.save.assert_not_called() + + +class TestHardDeleteComment(CommentServiceTestBase): + def test_hard_delete_comment_by_admin(self): + admin = create_test_account(account_id=2, account_role=AccountRole.ADMIN) + self.mock_account_repo.get_by_id.return_value = admin + comment = create_test_comment( + comment_id=10, + comment_written_account_id=1, + is_deleted=True, + ) + self.mock_comment_repo.get_by_id.return_value = comment + + result = self.service.hard_delete_comment( + comment_id=10, + user_id=admin.account_id, + ) + + self.mock_comment_repo.delete.assert_called_once_with(10) + assert result is True + + def test_hard_delete_comment_not_admin(self): + user = create_test_account(account_id=1, account_role=AccountRole.USER) + self.mock_account_repo.get_by_id.return_value = user + result = self.service.hard_delete_comment(comment_id=10, user_id=1) + self.mock_comment_repo.delete.assert_not_called() + assert result == "Unauthorized: Only admins can permanently delete comments." + + def test_hard_delete_comment_not_found(self): + admin = create_test_account(account_id=2, account_role=AccountRole.ADMIN) + self.mock_account_repo.get_by_id.return_value = admin + self.mock_comment_repo.get_by_id.return_value = None + result = self.service.hard_delete_comment(comment_id=999, user_id=2) + self.mock_comment_repo.delete.assert_not_called() + assert result == "Comment not found." + + def test_hard_delete_comment_not_soft_deleted(self): + admin = create_test_account(account_id=2, account_role=AccountRole.ADMIN) + self.mock_account_repo.get_by_id.return_value = admin + comment = create_test_comment( + comment_id=10, + comment_written_account_id=1, + is_deleted=False, + ) + self.mock_comment_repo.get_by_id.return_value = comment + result = self.service.hard_delete_comment(comment_id=10, user_id=2) + self.mock_comment_repo.delete.assert_not_called() + assert result == "Comment is not soft-deleted. Use soft-delete first." + + def test_hard_delete_comment_account_not_found(self): + self.mock_account_repo.get_by_id.return_value = None + result = self.service.hard_delete_comment(comment_id=10, user_id=999) + self.mock_comment_repo.delete.assert_not_called() + assert result == "Account not found." From b105cfda881da998f95a3aa9b6d0dbf14dc8f6ce Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 21 Jul 2026 17:34:52 +0200 Subject: [PATCH 2/5] Update comments : Update comments : Add circle letter avatars and separate meta links. When no avatar is present, a rounded fallback with the username initial is shown. Avatar and author name now link independently and comment avatar letters are centered inside their wrapper --- frontend/static/css/article.css | 22 +++++++++++++++++++++- frontend/templates/article_detail.html | 6 +++++- frontend/templates/article_list.html | 10 +++++++--- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 113288c..210029a 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -92,7 +92,25 @@ border-radius: 9999px; object-fit: cover; vertical-align: middle; - margin-right: var(--spacing-xs); +} + +.meta-author-initial { + width: 3rem; + height: 3rem; + border-radius: 9999px; + background: var(--primary-container); + color: var(--on-primary-container); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: var(--font-body-base); + flex-shrink: 0; +} + +.meta-author-avatar-link { + display: flex; + text-decoration: none; } a.meta-author:hover { @@ -611,6 +629,8 @@ input[type=number] { display: flex; width: 100%; height: 100%; + align-items: center; + justify-content: center; } .comment-avatar-initial { diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 80b348d..66829dd 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -54,10 +54,14 @@

{{ article.article_title }}

{% if article.author_username == 'Anonymous' %} {{ article.author_username }} {% else %} - + {% if article.author_avatar_file_id %} {{ article.author_username }} + {% else %} + {{ article.author_username[0]|upper }} {% endif %} + + {{ article.author_username }} {% endif %} diff --git a/frontend/templates/article_list.html b/frontend/templates/article_list.html index 92fe348..d631c20 100644 --- a/frontend/templates/article_list.html +++ b/frontend/templates/article_list.html @@ -69,12 +69,16 @@

{% if article.author_username == 'Anonymous' %} {{ article.author_username }} {% else %} - + {% if article.author_avatar_file_id %} {{ article.author_username }} + {% else %} + {{ article.author_username[0]|upper }} {% endif %} - {{ article.author_username }} - + + + {{ article.author_username }} + {% endif %} {% if article.article_published_at %} From 37f3ebf53b9fc5f0161069e60bf556efecd9dfe3 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 21 Jul 2026 17:44:40 +0200 Subject: [PATCH 3/5] =?UTF-8?q?Update=20comments=20:=20Show=20full=20edite?= =?UTF-8?q?d=5Fat=20timestamp=20and=20match=20its=20visual=20weight.=20The?= =?UTF-8?q?=20backend=20now=20formats=20edited=5Fat=20with=20full=20date+t?= =?UTF-8?q?ime,=20tests=20are=20updated=20and=20the=20edited=20line=20uses?= =?UTF-8?q?=20the=20smaller=20body=E2=80=91xs=20font=20to=20align=20with?= =?UTF-8?q?=20the=20main=20timestamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/css/article.css | 2 +- src/infrastructure/input_adapters/dto/comment_response.py | 2 +- .../tests_input_adapters/dto/test_comment_response.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 210029a..55e8f96 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -862,7 +862,7 @@ a.comment-author:hover { } .comment-edited-line { - font-size: var(--font-body-sm); + font-size: var(--font-body-xs); color: var(--on-surface-variant); margin-top: var(--spacing-xs); } diff --git a/src/infrastructure/input_adapters/dto/comment_response.py b/src/infrastructure/input_adapters/dto/comment_response.py index f615fb3..a528bba 100644 --- a/src/infrastructure/input_adapters/dto/comment_response.py +++ b/src/infrastructure/input_adapters/dto/comment_response.py @@ -108,7 +108,7 @@ def from_domain(cls, comment, author_username: str = "Unknown", author_avatar_fi edited_at_formatted = "" if comment.edited_at: - edited_at_formatted = cls._to_local_time(comment.edited_at) + edited_at_formatted = cls._to_local_full(comment.edited_at) return cls( comment_id=comment.comment_id, diff --git a/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py b/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py index 9671311..9b020e7 100644 --- a/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py +++ b/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py @@ -74,7 +74,7 @@ def test_comment_response_from_domain_edited(): assert response.comment_content == "Edited content" assert response.is_deleted is False assert response.edited_at == edited_at - assert response.edited_at_formatted == "12:00" + assert response.edited_at_formatted == "October 28, 2023 at 12:00" def test_comment_response_from_domain_deleted_with_edited_at(): posted_at = datetime(2023, 10, 27, 14, 30) @@ -95,7 +95,7 @@ def test_comment_response_from_domain_deleted_with_edited_at(): assert response.comment_content == "Comment removed" assert response.is_deleted is True assert response.edited_at == edited_at - assert response.edited_at_formatted == "12:00" + assert response.edited_at_formatted == "October 28, 2023 at 12:00" def test_comment_response_from_domain_with_all_fields(): posted_at = datetime(2023, 10, 27, 14, 30) From 1420268b9117e3084efa18311053e9220b58c6d4 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 21 Jul 2026 18:39:29 +0200 Subject: [PATCH 4/5] =?UTF-8?q?Update=20comments=20:=20Unify=20all=20no?= =?UTF-8?q?=E2=80=91photo=20avatars=20with=20the=20profile=20placeholder?= =?UTF-8?q?=20style.=20Letter=E2=80=91initial=20circles=20now=20use=20the?= =?UTF-8?q?=20same=20surface/primary=20color=20tokens=20as=20profile=20ava?= =?UTF-8?q?tars,=20with=20a=20visible=20border=20and=20bold=20initial.=20A?= =?UTF-8?q?pplied=20to=20meta=20author,=20comment=20avatars=20(normal=20an?= =?UTF-8?q?d=20deleted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/css/article.css | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 55e8f96..0cce6ff 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -97,14 +97,15 @@ .meta-author-initial { width: 3rem; height: 3rem; - border-radius: 9999px; - background: var(--primary-container); - color: var(--on-primary-container); + border-radius: var(--radius-full); + background: var(--surface-container-high); + color: var(--primary); + border: 1px solid var(--outline); display: inline-flex; align-items: center; justify-content: center; - font-weight: 600; - font-size: var(--font-body-base); + font-weight: bold; + font-size: 1.2rem; flex-shrink: 0; } @@ -618,8 +619,9 @@ input[type=number] { flex-shrink: 0; width: 3rem; height: 3rem; - border-radius: 9999px; - background: var(--primary-container); + border-radius: var(--radius-full); + background: var(--surface-container-high); + border: 1px solid var(--outline); display: flex; align-items: center; justify-content: center; @@ -634,9 +636,9 @@ input[type=number] { } .comment-avatar-initial { - font-size: var(--font-body-base); - font-weight: 600; - color: var(--on-primary-container); + font-size: 1.2rem; + font-weight: bold; + color: var(--primary); line-height: 1; } @@ -648,7 +650,8 @@ input[type=number] { } .comment-avatar-deleted { - background: var(--surface-container-high); + background: var(--surface); + border: none; } .comment-avatar-deleted .comment-avatar-initial { From ccb6c3e818f130b4537f1ee5d07354276f604e74 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 21 Jul 2026 18:45:54 +0200 Subject: [PATCH 5/5] =?UTF-8?q?Update=20comments=20:=20Deleted=E2=80=91com?= =?UTF-8?q?ment=20avatars=20no=20longer=20have=20a=20special=20look.=20The?= =?UTF-8?q?=20dedicated=20deleted=E2=80=91avatar=20rules=20are=20removed,?= =?UTF-8?q?=20so=20both=20deleted=20and=20no=E2=80=91avatar=20states=20use?= =?UTF-8?q?=20the=20same=20unified=20gray=20circle=20with=20violet=20initi?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/css/article.css | 9 --------- 1 file changed, 9 deletions(-) diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 0cce6ff..2835f0e 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -649,15 +649,6 @@ input[type=number] { border-radius: 9999px; } -.comment-avatar-deleted { - background: var(--surface); - border: none; -} - -.comment-avatar-deleted .comment-avatar-initial { - color: var(--on-surface-variant); -} - .comment-content { flex: 1; min-width: 0;