From 02b950007ca466e5813af8789272496429437ca7 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 14 Jul 2026 15:22:56 +0200 Subject: [PATCH 01/15] =?UTF-8?q?User=20profile=20photo=20and=20access=20:?= =?UTF-8?q?=20Remove=20the=20Home=20button=20from=20the=20profile=20page?= =?UTF-8?q?=20=E2=80=94=20`.actions`=20is=20removed=20in=20favor=20of=20a?= =?UTF-8?q?=20simple=20`.logout-form`,=20and=20`.back-link`=20remains=20th?= =?UTF-8?q?e=20only=20navigation=20link.=20HTML=20is=20simplified=20and=20?= =?UTF-8?q?CSS=20cleaned=20up=20and=20reduced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/css/profile.css | 20 ++------------------ frontend/templates/profile.html | 11 ++++------- 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/frontend/static/css/profile.css b/frontend/static/css/profile.css index c205f479..e1020710 100644 --- a/frontend/static/css/profile.css +++ b/frontend/static/css/profile.css @@ -60,25 +60,9 @@ font-weight: 600; } -.actions { - display: flex; - gap: var(--spacing-sm); +.logout-form { margin-top: var(--spacing-lg); -} - -.actions .btn { - flex: 1; - text-decoration: none; - color: var(--on-primary); -} - -.actions .btn.btn-secondary { - color: var(--primary); -} - -.actions .btn.btn-secondary:hover { - background: color-mix(in srgb, var(--primary) 8%, transparent); - border-color: var(--primary); + text-align: center; } .back-link { diff --git a/frontend/templates/profile.html b/frontend/templates/profile.html index 09597684..9796c605 100644 --- a/frontend/templates/profile.html +++ b/frontend/templates/profile.html @@ -38,13 +38,10 @@

{{ user.account_username }}

-
- Go to Home -
- - -
-
+
+ + +
{{ icon('home') }} From 45e80f93a4e310780ce2c1a474bcfa95392a663e Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 14 Jul 2026 15:54:37 +0200 Subject: [PATCH 02/15] User profile photo and access : Add public user profile pages at /users/. Author names link to these profiles, sensitive fields are restricted to owners or admins, backend adds username lookup and a profile view, templates update author links and visibility rules, CSS adjusts author styling and tests cover profile access and link presence --- flask_setup/routes.py | 5 ++ frontend/static/css/article.css | 19 +++++++- frontend/templates/article_detail.html | 6 +-- frontend/templates/article_list.html | 2 +- frontend/templates/profile.html | 8 +++- .../input_ports/account_session_management.py | 13 +++++ src/application/services/login_service.py | 11 +++++ .../flask/flask_account_session_adapter.py | 42 ++++++++++++++++- .../test_flask_account_session_adapter.py | 47 +++++++++++++++++++ .../tests_flask/test_flask_article_adapter.py | 1 + .../test_workflow_integration.py | 32 +++++++++++++ 11 files changed, 178 insertions(+), 8 deletions(-) diff --git a/flask_setup/routes.py b/flask_setup/routes.py index 802ab653..25104360 100644 --- a/flask_setup/routes.py +++ b/flask_setup/routes.py @@ -78,6 +78,11 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None: app.add_url_rule("/register", view_func=reg.render_registration_page, methods=["GET"], endpoint="registration.register") app.add_url_rule("/register", view_func=reg.register, methods=["POST"], endpoint="registration.register_action") app.add_url_rule("/profile", view_func=acc.display_profile, endpoint="auth.profile") + app.add_url_rule( + "/users/", + view_func=acc.display_user_profile, + endpoint="auth.user_profile", + ) app.add_url_rule("/logout", view_func=acc.logout, methods=["POST"], endpoint="auth.logout") diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 3fdfa0ed..2f7f38c4 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -86,7 +86,14 @@ .meta-author { color: var(--on-surface); font-weight: 600; - font-style: italic; + text-decoration: none; + position: relative; + z-index: 1; +} + +.meta-author:hover { + color: var(--primary); + text-decoration: underline; } .meta-divider { @@ -559,6 +566,16 @@ input[type=number] { font-size: var(--font-body-sm); font-weight: 600; color: var(--on-surface); + text-decoration: none; +} + +.comment-author:hover { + color: var(--primary); + text-decoration: underline; +} + +.comment-avatar-link { + text-decoration: none; } .comment-timestamp { diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 11cee389..46f7ee49 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -48,7 +48,7 @@

{{ article.article_title }}

- {{ article.author_username }} + {{ article.author_username }}
@@ -79,7 +79,7 @@

{{ article.article_title }}

{% if not is_deleted %} - {{ node.comment.author_username[0]|upper }} + {{ node.comment.author_username[0]|upper }} {% else %} ? {% endif %} @@ -87,7 +87,7 @@

{{ article.article_title }}

- {{ node.comment.author_username }} + {{ node.comment.author_username }} {{ node.comment.comment_posted_at_formatted }} {% if current_user and current_user.account_role == 'admin' %}
diff --git a/frontend/templates/article_list.html b/frontend/templates/article_list.html index fcfb868f..512fe843 100644 --- a/frontend/templates/article_list.html +++ b/frontend/templates/article_list.html @@ -93,7 +93,7 @@

{{ article.meta_description }}

- {{ article.author_username }} + {{ article.author_username }} {% if article.article_published_at %} diff --git a/frontend/templates/profile.html b/frontend/templates/profile.html index 9796c605..3c12cd58 100644 --- a/frontend/templates/profile.html +++ b/frontend/templates/profile.html @@ -1,7 +1,7 @@ {% from "macros/icons.html" import icon %} {% extends "base.html" %} -{% block title %}Profile - DevJournal{% endblock %} +{% block title %}{% if is_own_profile %}Profile{% else %}{{ user.account_username }}{% endif %} - DevJournal{% endblock %} {% block extra_css %} @@ -18,10 +18,13 @@

{{ user.account_username }}

+ {% if is_own_profile or (current_user and current_user.account_role == 'admin') %}
Email {{ user.account_email }}
+ {% endif %} + {% if is_own_profile or (current_user and current_user.account_role == 'admin') %}
Member Since @@ -32,16 +35,19 @@

{{ user.account_username }}

{% endif %}
+ {% endif %}
Status Active
+ {% if is_own_profile %} + {% endif %} {{ icon('home') }} diff --git a/src/application/input_ports/account_session_management.py b/src/application/input_ports/account_session_management.py index b604101e..ab92a33f 100644 --- a/src/application/input_ports/account_session_management.py +++ b/src/application/input_ports/account_session_management.py @@ -26,3 +26,16 @@ def terminate_session(self) -> None: Terminates the current active session, effectively logging the user out. """ pass + + @abstractmethod + def get_account_by_username(self, username: str) -> Account | None: + """ + Retrieves a domain Account by its unique username. + + Args: + username: The username to look up. + + Returns: + Account | None: The domain Account if found, None otherwise. + """ + pass diff --git a/src/application/services/login_service.py b/src/application/services/login_service.py index 210d9d06..92b5b75e 100644 --- a/src/application/services/login_service.py +++ b/src/application/services/login_service.py @@ -80,3 +80,14 @@ def terminate_session(self) -> None: """ self.session_repository.clear() + def get_account_by_username(self, username: str) -> Account | None: + """ + Retrieves a domain Account by its unique username via the repository. + + Args: + username: The username to look up. + + Returns: + Account | None: The domain Account if found, None otherwise. + """ + return self.account_repository.find_by_username(username) diff --git a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py index 6a768e92..509eada3 100644 --- a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py @@ -1,4 +1,4 @@ -from flask import flash, redirect, render_template, url_for +from flask import abort, flash, redirect, render_template, url_for from flask import g as global_request_context from flask.views import MethodView from src.application.input_ports.account_session_management import AccountSessionManagementPort @@ -73,4 +73,42 @@ def display_profile(self): return redirect(url_for("auth.login")) user_dto = AccountResponse.from_domain(account) - return render_template("profile.html", user=user_dto, current_user=user_dto) + return render_template("profile.html", user=user_dto, current_user=user_dto, is_own_profile=True) + + def display_user_profile(self, username: str): + """ + Renders a public user profile page for the given username. + + Accessible to all users (authenticated or anonymous). + Sensitive fields (email, member since) are shown only to + the profile owner or an admin viewer. + + Args: + username: The username of the profile to display. + + Returns: + str: The rendered profile HTML. + + Raises: + 404: If no account exists with the given username. + """ + account = self.session_service.get_account_by_username(username) + + if not account: + abort(404) + + user_dto = AccountResponse.from_domain(account) + + current_user_dto = None + current_account = getattr(global_request_context, "current_user", None) + if current_account: + current_user_dto = AccountResponse.from_domain(current_account) + + return render_template( + "profile.html", + user=user_dto, + current_user=current_user_dto, + is_own_profile=bool( + current_account and current_account.account_id == account.account_id + ), + ) diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py index 3e3c87a8..65641191 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py @@ -35,6 +35,12 @@ def setup_method(self): self._register_dummy_route("/register", "registration.register", "registration") self._register_dummy_route("/articles/new", "article.render_create_page", "new_article") + self.app.add_url_rule( + "/users/", + view_func=self.adapter.display_user_profile, + endpoint="auth.user_profile", + ) + def test_logout_clears_session(self): response = self.client.post("/logout", follow_redirects=True) assert b"You have been logged out." in response.data @@ -72,6 +78,47 @@ def test_get_profile_unauthenticated(self): assert b"alert-error" in response.data assert b"login" in response.data + def test_get_user_profile_found(self): + fake_user = create_test_account(account_username="yoda", account_email="yoda@dagobah.com") + self.mock_session_service.get_account_by_username.return_value = fake_user + response = self.client.get("/users/yoda") + assert response.status_code == 200 + assert b"yoda" in response.data + assert b"yoda@dagobah.com" not in response.data + self.mock_session_service.get_account_by_username.assert_called_once_with("yoda") + + def test_get_user_profile_not_found(self): + self.mock_session_service.get_account_by_username.return_value = None + response = self.client.get("/users/nobody") + assert response.status_code == 404 + + def test_get_user_profile_own_profile(self): + fake_user = create_test_account(account_username="luke", account_email="luke@tatooine.com") + self.set_current_user(fake_user) + self.mock_session_service.get_account_by_username.return_value = fake_user + response = self.client.get("/users/luke") + assert response.status_code == 200 + assert b"luke@tatooine.com" in response.data + assert b"Sign Out" in response.data + + def test_get_user_profile_admin_view(self): + profile_user = create_test_account(account_id=2, account_username="han", account_email="han@falcon.com") + admin_user = create_test_account(account_id=99, account_username="admin", account_role=AccountRole.ADMIN) + self.set_current_user(admin_user) + self.mock_session_service.get_account_by_username.return_value = profile_user + response = self.client.get("/users/han") + assert response.status_code == 200 + assert b"han@falcon.com" in response.data + assert b"Sign Out" not in response.data + + def test_get_user_profile_anonymous(self): + fake_user = create_test_account(account_username="leia", account_email="leia@galaxy.com") + self.mock_session_service.get_account_by_username.return_value = fake_user + response = self.client.get("/users/leia") + assert response.status_code == 200 + assert b"leia@galaxy.com" not in response.data + assert b"Sign Out" not in response.data + class TestAccountSessionBeforeRequestHook(FlaskInputAdapterTestBase): """ diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py index 566e224b..e378381c 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py @@ -82,6 +82,7 @@ def setup_method(self): self._register_dummy_route("/register", "registration.register", "registration") self._register_dummy_route("/logout", "auth.logout", "logout") self._register_dummy_route("/profile", "auth.profile", "profile") + self._register_dummy_route("/users/", "auth.user_profile", "user_profile") self._register_dummy_route("/articles//comments", "comment.create_comment", "comment") self._register_dummy_route( "/articles//comments//reply", diff --git a/tests/tests_integration/test_workflow_integration.py b/tests/tests_integration/test_workflow_integration.py index b1cc65b4..4e2a6c07 100644 --- a/tests/tests_integration/test_workflow_integration.py +++ b/tests/tests_integration/test_workflow_integration.py @@ -408,3 +408,35 @@ def test_comment_count_displays_total_with_nested(self, client, db_session): response = client.get(f"/articles/{article.article_id}") assert response.status_code == 200 assert b"Comments (3)" in response.data + + +class TestUserProfileLinks: + """Verifies author name links point to the public profile page.""" + + def test_article_list_author_link_points_to_profile(self, client, db_session): + """ + Regression: the author name in the article list should link + to /users/. If the wrapper is removed or the + overlay ::after intercepts clicks (as happened), this catches it. + """ + author = AccountModel( + account_username="link_test_author", + account_email="link@test.com", + account_password="p", + account_role="author", + ) + + db_session.add(author) + db_session.commit() + + article = ArticleModel( + article_title="Link Test", + article_content="...", + article_author_id=author.account_id, + ) + + db_session.add(article) + db_session.commit() + response = client.get("/") + assert response.status_code == 200 + assert b'href="/users/link_test_author"' in response.data From c2e3d108569741a1c070103829f3c3e7fa8628c1 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 09:26:31 +0200 Subject: [PATCH 03/15] User profile photo and access : Add avatar support to user profiles. Introduces `avatar_file_id` across the account layers, adds a POST /api/profile/photo upload endpoint with FileService, updates the profile template with avatar display and upload UI, includes a new migration, updates ORM and repository code and adds tests for upload handling and avatar persistence --- blog_comment_application.py | 5 +- flask_setup/routes.py | 8 +++ frontend/static/css/profile.css | 12 ++++ frontend/static/scripts/profile-avatar.js | 31 ++++++++++ frontend/templates/profile.html | 14 ++++- .../V10__add_avatar_file_id_to_accounts.sql | 2 + src/application/domain/account.py | 4 ++ .../input_ports/account_session_management.py | 10 ++++ .../output_ports/account_repository.py | 11 ++++ src/application/services/login_service.py | 15 +++++ .../input_adapters/dto/account_response.py | 8 ++- .../flask/flask_account_session_adapter.py | 47 ++++++++++++++- .../output_adapters/dto/account_record.py | 8 ++- .../in_memory/account_repository.py | 13 ++++ .../models/sqlalchemy_account_model.py | 3 + .../sqlalchemy/sqlalchemy_account_adapter.py | 18 ++++++ .../test_flask_account_session_adapter.py | 59 ++++++++++++++++++- .../test_sqlalchemy_account_adapter.py | 20 +++++++ 18 files changed, 278 insertions(+), 10 deletions(-) create mode 100644 frontend/static/scripts/profile-avatar.js create mode 100644 migrations/V10__add_avatar_file_id_to_accounts.sql diff --git a/blog_comment_application.py b/blog_comment_application.py index 41ccfc7d..c13e6913 100644 --- a/blog_comment_application.py +++ b/blog_comment_application.py @@ -120,7 +120,10 @@ def _init_web_adapters(services: dict) -> dict: "comment_adapter": CommentAdapter(services["comment_service"]), "login_adapter": LoginAdapter(services["login_service"]), "registration_adapter": RegistrationAdapter(services["registration_service"]), - "account_session_adapter": AccountSessionAdapter(services["login_service"]), + "account_session_adapter": AccountSessionAdapter( + services["login_service"], + services["file_service"], + ), "file_adapter": FlaskFileAdapter(services["file_service"]), } diff --git a/flask_setup/routes.py b/flask_setup/routes.py index 25104360..0e3b35d2 100644 --- a/flask_setup/routes.py +++ b/flask_setup/routes.py @@ -73,6 +73,7 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None: log = adapters["login_adapter"] reg = adapters["registration_adapter"] acc = adapters["account_session_adapter"] + csrf = app.extensions["csrf"] app.add_url_rule("/login", view_func=log.render_login_page, methods=["GET"], endpoint="auth.login") app.add_url_rule("/login", view_func=log.authenticate, methods=["POST"], endpoint="auth.authenticate") app.add_url_rule("/register", view_func=reg.render_registration_page, methods=["GET"], endpoint="registration.register") @@ -84,6 +85,13 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None: endpoint="auth.user_profile", ) app.add_url_rule("/logout", view_func=acc.logout, methods=["POST"], endpoint="auth.logout") + app.add_url_rule( + "/api/profile/photo", + view_func=acc.upload_profile_photo, + methods=["POST"], + endpoint="auth.upload_profile_photo", + ) + csrf.exempt(acc.upload_profile_photo) def _register_file_routes(app: Flask, adapters: dict) -> None: diff --git a/frontend/static/css/profile.css b/frontend/static/css/profile.css index e1020710..e7387b8f 100644 --- a/frontend/static/css/profile.css +++ b/frontend/static/css/profile.css @@ -20,6 +20,18 @@ font-size: 1.8rem; font-weight: bold; margin: 0 auto var(--spacing-sm); + overflow: hidden; +} + +.avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: var(--radius-full); +} + +#upload-avatar-btn { + margin-bottom: var(--spacing-sm); } .role-badge { diff --git a/frontend/static/scripts/profile-avatar.js b/frontend/static/scripts/profile-avatar.js new file mode 100644 index 00000000..1a9c6ed3 --- /dev/null +++ b/frontend/static/scripts/profile-avatar.js @@ -0,0 +1,31 @@ +"use strict"; +(() => { + const btn = document.getElementById("upload-avatar-btn"); + const input = document.getElementById("avatar-file-input"); + if (!btn || !input) return; + + btn.addEventListener("click", () => input.click()); + + input.addEventListener("change", async () => { + const file = input.files?.[0]; + if (!file) return; + + const formData = new FormData(); + formData.append("file", file); + + try { + const response = await fetch("/api/profile/photo", { + method: "POST", + body: formData, + }); + if (response.ok) { + location.reload(); + } else { + const data = await response.json(); + alert(data.error || "Upload failed."); + } + } catch { + alert("Network error during upload."); + } + }); +})(); diff --git a/frontend/templates/profile.html b/frontend/templates/profile.html index 3c12cd58..43a2fdf6 100644 --- a/frontend/templates/profile.html +++ b/frontend/templates/profile.html @@ -11,8 +11,16 @@ {% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/migrations/V10__add_avatar_file_id_to_accounts.sql b/migrations/V10__add_avatar_file_id_to_accounts.sql new file mode 100644 index 00000000..e06beabc --- /dev/null +++ b/migrations/V10__add_avatar_file_id_to_accounts.sql @@ -0,0 +1,2 @@ +ALTER TABLE accounts +ADD COLUMN avatar_file_id TEXT; diff --git a/src/application/domain/account.py b/src/application/domain/account.py index 4446718a..b1a65ea3 100644 --- a/src/application/domain/account.py +++ b/src/application/domain/account.py @@ -23,6 +23,7 @@ class Account: account_email (str): Unique email address for the user. account_role (AccountRole): Permissions role. account_created_at (datetime): Timestamp of account creation. + avatar_file_id (str | None): UUID of the avatar file in uploaded_files, or None. """ def __init__( @@ -33,6 +34,7 @@ def __init__( account_email: str, account_role: AccountRole, account_created_at: datetime | None, + avatar_file_id: str | None = None, ): """ Initialize a user account. @@ -44,6 +46,7 @@ def __init__( account_email (str): Unique email address for the user. account_role (AccountRole): Permissions role. account_created_at (datetime): Timestamp of account creation. + avatar_file_id (str | None): UUID of the avatar file in uploaded_files, or None. """ self.account_id = account_id self.account_username = account_username @@ -51,3 +54,4 @@ def __init__( self.account_email = account_email self.account_role = account_role self.account_created_at = account_created_at + self.avatar_file_id = avatar_file_id diff --git a/src/application/input_ports/account_session_management.py b/src/application/input_ports/account_session_management.py index ab92a33f..30b773b2 100644 --- a/src/application/input_ports/account_session_management.py +++ b/src/application/input_ports/account_session_management.py @@ -39,3 +39,13 @@ def get_account_by_username(self, username: str) -> Account | None: Account | None: The domain Account if found, None otherwise. """ pass + + @abstractmethod + def update_avatar(self, avatar_file_id: str) -> None: + """ + Updates the avatar_file_id for the currently authenticated account. + + Args: + avatar_file_id: The UUID of the uploaded avatar file. + """ + pass diff --git a/src/application/output_ports/account_repository.py b/src/application/output_ports/account_repository.py index 3e956d05..42000a7d 100644 --- a/src/application/output_ports/account_repository.py +++ b/src/application/output_ports/account_repository.py @@ -73,3 +73,14 @@ def save(self, account: Account) -> None: account (Account): The Account domain entity to save. """ pass + + @abstractmethod + def update_avatar(self, account_id: int, avatar_file_id: str | None) -> None: + """ + Updates the avatar_file_id for a given account. + + Args: + account_id: The ID of the account to update. + avatar_file_id: The new avatar file UUID, or None to remove. + """ + pass diff --git a/src/application/services/login_service.py b/src/application/services/login_service.py index 92b5b75e..54aa7df2 100644 --- a/src/application/services/login_service.py +++ b/src/application/services/login_service.py @@ -91,3 +91,18 @@ def get_account_by_username(self, username: str) -> Account | None: Account | None: The domain Account if found, None otherwise. """ return self.account_repository.find_by_username(username) + + def update_avatar(self, avatar_file_id: str) -> None: + """ + Updates the avatar_file_id for the currently authenticated account. + + Retrieves the current account from the session and delegates + the persistence update to the account repository. + + Args: + avatar_file_id: The UUID of the uploaded avatar file. + """ + account = self.get_current_account() + if account is None: + return + self.account_repository.update_avatar(account.account_id, avatar_file_id) diff --git a/src/infrastructure/input_adapters/dto/account_response.py b/src/infrastructure/input_adapters/dto/account_response.py index 821439ec..601790b0 100644 --- a/src/infrastructure/input_adapters/dto/account_response.py +++ b/src/infrastructure/input_adapters/dto/account_response.py @@ -10,7 +10,8 @@ class AccountResponse(BaseModel): Pydantic DTO (Data Transfer Object) for account Web responses. This class securely exposes account information to the client, - intentionally omitting the password field. + intentionally omitting the password field. It also includes the + optional avatar file reference for profile display. """ model_config = ConfigDict(from_attributes=True) @@ -20,6 +21,7 @@ class AccountResponse(BaseModel): account_email: str account_role: str account_created_at: datetime | None + avatar_file_id: str | None = None @classmethod def from_domain(cls, account: Account) -> "AccountResponse": @@ -30,7 +32,8 @@ def from_domain(cls, account: Account) -> "AccountResponse": account (Account): The domain entity. Returns: - AccountResponse: The safe representation for Web output. + AccountResponse: The safe representation for Web output, + including the optional avatar_file_id. """ return cls( account_id=account.account_id, @@ -38,4 +41,5 @@ def from_domain(cls, account: Account) -> "AccountResponse": account_email=account.account_email, account_role=account.account_role.value if account.account_role else "user", account_created_at=account.account_created_at, + avatar_file_id=account.avatar_file_id, ) diff --git a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py index 509eada3..bbbefa37 100644 --- a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py @@ -1,7 +1,9 @@ -from flask import abort, flash, redirect, render_template, url_for +from flask import abort, flash, jsonify, redirect, render_template, request, url_for from flask import g as global_request_context from flask.views import MethodView +from src.application.application_exceptions import FileTooLargeError, FileTypeError from src.application.input_ports.account_session_management import AccountSessionManagementPort +from src.application.input_ports.file_management import FileManagementPort from src.infrastructure.input_adapters.dto.account_response import AccountResponse @@ -17,14 +19,20 @@ class AccountSessionAdapter(MethodView): - User profile display. """ - def __init__(self, session_service: AccountSessionManagementPort): + def __init__( + self, + session_service: AccountSessionManagementPort, + file_service: FileManagementPort, + ): """ Initializes the AccountSessionAdapter with the required session service. Args: session_service (AccountSessionManagementPort): The input port for session management. + file_service (FileManagementPort): The input port for file management. """ self.session_service = session_service + self.file_service = file_service def _identify_user(self): """Injects the current user into the global request context.""" @@ -112,3 +120,38 @@ def display_user_profile(self, username: str): current_account and current_account.account_id == account.account_id ), ) + + def upload_profile_photo(self): + """ + Handles profile photo upload via multipart POST. + + Validates authentication, delegates file upload to the file service, + and persists the avatar reference on the current account. + + Returns: + Response: JSON with avatar_url on success (200), + or error message (400/401). + """ + current_account = getattr(global_request_context, "current_user", None) + if not current_account: + return jsonify({"error": "Authentication required."}), 401 + + uploaded_file = request.files.get("file") + if not uploaded_file or not uploaded_file.filename: + return jsonify({"error": "No file provided."}), 400 + + file_data = uploaded_file.read() + try: + file_record = self.file_service.upload_file( + filename=uploaded_file.filename, + data=file_data, + mime_type=uploaded_file.content_type or "application/octet-stream", + ) + except (FileTooLargeError, FileTypeError) as e: + return jsonify({"error": str(e)}), 400 + + self.session_service.update_avatar(file_record.file_id) + + return jsonify({ + "avatar_url": url_for("file.serve_file", file_id=file_record.file_id, filename="avatar"), + }), 200 diff --git a/src/infrastructure/output_adapters/dto/account_record.py b/src/infrastructure/output_adapters/dto/account_record.py index 913006d9..07bde311 100644 --- a/src/infrastructure/output_adapters/dto/account_record.py +++ b/src/infrastructure/output_adapters/dto/account_record.py @@ -10,7 +10,8 @@ class AccountRecord(BaseModel): Pydantic DTO (Data Transfer Object) for account database records. This class faithfully mirrors the 'accounts' table schema and provides - validation when loading data from the persistence layer. + validation when loading data from the persistence layer, including + the optional avatar file reference. """ model_config = ConfigDict(from_attributes=True) @@ -21,6 +22,7 @@ class AccountRecord(BaseModel): account_email: str account_role: str account_created_at: datetime | None + avatar_file_id: str | None = None def to_domain(self) -> Account: """ @@ -28,7 +30,8 @@ def to_domain(self) -> Account: Returns: Account: The corresponding domain entity, including the - conversion of the 'account_role' string to an AccountRole enum. + conversion of the 'account_role' string to an AccountRole enum + and the optional avatar_file_id reference. """ return Account( account_id=self.account_id, @@ -37,4 +40,5 @@ def to_domain(self) -> Account: account_email=self.account_email, account_role=AccountRole(self.account_role), account_created_at=self.account_created_at, + avatar_file_id=self.avatar_file_id, ) diff --git a/src/infrastructure/output_adapters/in_memory/account_repository.py b/src/infrastructure/output_adapters/in_memory/account_repository.py index ac8e1a70..16eb1542 100644 --- a/src/infrastructure/output_adapters/in_memory/account_repository.py +++ b/src/infrastructure/output_adapters/in_memory/account_repository.py @@ -80,3 +80,16 @@ def find_by_email(self, email: str) -> Account | None: if account.account_email == email: return account return None + + def update_avatar(self, account_id: int, avatar_file_id: str | None) -> None: + """ + Updates the avatar_file_id for the given account in memory. + + Args: + account_id: The ID of the account to update. + avatar_file_id: The new avatar file UUID, or None to remove. + """ + account = self._accounts.get(account_id) + if account is None: + return + account.avatar_file_id = avatar_file_id diff --git a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_account_model.py b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_account_model.py index e6ae2dd0..62ba4e79 100644 --- a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_account_model.py +++ b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_account_model.py @@ -12,6 +12,8 @@ class AccountModel(SqlAlchemyModel): This class defines the database schema for user profiles, including authentication credentials, contact information, and roles. + It also stores an optional reference to the user's avatar image + in the ``uploaded_files`` table via ``avatar_file_id``. """ __tablename__ = "accounts" @@ -22,3 +24,4 @@ class AccountModel(SqlAlchemyModel): account_email: Mapped[str] = mapped_column(Text, unique=True, nullable=False) account_role: Mapped[str] = mapped_column(Text, nullable=False) account_created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now()) + avatar_file_id: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) diff --git a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py index 05e6be0b..147c6560 100644 --- a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py +++ b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py @@ -153,3 +153,21 @@ def save(self, account: Account) -> None: f"Unexpected unique constraint violation: {constraint_name}" ) from None account.account_id = model.account_id + + def update_avatar(self, account_id: int, avatar_file_id: str | None) -> None: + """ + Updates the avatar_file_id for the given account directly in the database. + + Performs a targeted column update without loading or saving the full + Account entity, keeping the responsibility focused and avoiding + accidental overwrites of other fields. + + Args: + account_id: The ID of the account to update. + avatar_file_id: The new avatar file UUID, or None to remove. + """ + model = self._session.get(AccountModel, account_id) + if model is None: + return + model.avatar_file_id = avatar_file_id + self._session.commit() diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py index 65641191..03395a4f 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py @@ -4,6 +4,7 @@ from src.application.domain.account import Account, AccountRole from src.application.input_ports.account_session_management import AccountSessionManagementPort +from src.application.input_ports.file_management import FileManagementPort from src.infrastructure.input_adapters.flask.flask_account_session_adapter import AccountSessionAdapter from tests.test_domain_factories import create_test_account from tests.tests_infrastructure.tests_input_adapters.tests_flask.flask_test_utils import ( @@ -15,7 +16,11 @@ class TestAccountSessionAdapter(FlaskInputAdapterTestBase): def setup_method(self): super().setup_method() self.mock_session_service = Mock(spec=AccountSessionManagementPort, autospec=True) - self.adapter = AccountSessionAdapter(session_service=self.mock_session_service) + self.mock_file_service = Mock(spec=FileManagementPort, autospec=True) + self.adapter = AccountSessionAdapter( + session_service=self.mock_session_service, + file_service=self.mock_file_service, + ) self.app.add_url_rule( "/logout", @@ -41,6 +46,19 @@ def setup_method(self): endpoint="auth.user_profile", ) + self.app.add_url_rule( + "/api/profile/photo", + view_func=self.adapter.upload_profile_photo, + methods=["POST"], + endpoint="auth.upload_profile_photo", + ) + + self._register_dummy_route( + "/uploads//", + "file.serve_file", + "file_serve", + ) + def test_logout_clears_session(self): response = self.client.post("/logout", follow_redirects=True) assert b"You have been logged out." in response.data @@ -119,6 +137,39 @@ def test_get_user_profile_anonymous(self): assert b"leia@galaxy.com" not in response.data assert b"Sign Out" not in response.data + def test_upload_profile_photo_unauthenticated(self): + response = self.client.post("/api/profile/photo") + assert response.status_code == 401 + + def test_upload_profile_photo_success(self): + from datetime import datetime + from io import BytesIO + + from src.application.domain.file_record import FileRecord + + fake_user = create_test_account() + self.set_current_user(fake_user) + fake_file = FileRecord( + file_id="abc-123", + original_filename="avatar.jpg", + mime_type="image/jpeg", + size=1024, + data=b"fake-image-data", + created_at=datetime.now(), + ) + self.mock_file_service.upload_file.return_value = fake_file + + response = self.client.post( + "/api/profile/photo", + data={"file": (BytesIO(b"fake-image"), "avatar.jpg")}, + content_type="multipart/form-data", + ) + assert response.status_code == 200 + data = response.get_json() + assert data["avatar_url"] == "/uploads/abc-123/avatar" + self.mock_file_service.upload_file.assert_called_once() + self.mock_session_service.update_avatar.assert_called_once_with("abc-123") + class TestAccountSessionBeforeRequestHook(FlaskInputAdapterTestBase): """ @@ -130,7 +181,11 @@ class TestAccountSessionBeforeRequestHook(FlaskInputAdapterTestBase): def setup_method(self): super().setup_method() self.mock_session_service = Mock(spec=AccountSessionManagementPort, autospec=True) - self.adapter = AccountSessionAdapter(session_service=self.mock_session_service) + self.mock_file_service = Mock(spec=FileManagementPort, autospec=True) + self.adapter = AccountSessionAdapter( + session_service=self.mock_session_service, + file_service=self.mock_file_service, + ) def _capture_handler(self, **kwargs): """Route handler that captures the current_user from flask.g.""" diff --git a/tests/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py b/tests/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py index 7fbabaf5..400d83b6 100644 --- a/tests/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py +++ b/tests/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py @@ -119,3 +119,23 @@ def test_save_assigns_auto_generated_id(self): result = self.repository.find_by_username("auto_id") assert result is not None assert result.account_id > 0 + + +class TestAccountUpdateAvatar(SqlAlchemyAccountAdapterTestBase): + def test_update_avatar_sets_file_id(self): + account = self.account_builder.create(username="avatar_user") + self.repository.update_avatar(account.account_id, "abc-123") + result = self.repository.get_by_id(account.account_id) + assert result is not None + assert result.avatar_file_id == "abc-123" + + def test_update_avatar_removes_file_id(self): + account = self.account_builder.create(username="avatar_remove") + self.repository.update_avatar(account.account_id, "abc-123") + self.repository.update_avatar(account.account_id, None) + result = self.repository.get_by_id(account.account_id) + assert result is not None + assert result.avatar_file_id is None + + def test_update_avatar_nonexistent_account_does_not_raise(self): + self.repository.update_avatar(99999, "abc-123") From 64e557d183d940bced00d6b02bcb79d5946c7f33 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 09:52:11 +0200 Subject: [PATCH 04/15] =?UTF-8?q?User=20profile=20photo=20and=20access=20:?= =?UTF-8?q?=20Add=20comment=20avatars.=20Threads=20`avatar=5Ffile=5Fid`=20?= =?UTF-8?q?through=20the=20comment=20domain=20model,=20tree=E2=80=91buildi?= =?UTF-8?q?ng=20logic,=20DTOs=20and=20templates=20so=20each=20comment=20ca?= =?UTF-8?q?n=20display=20the=20author's=20profile=20photo,=20falling=20bac?= =?UTF-8?q?k=20to=20an=20initial=20when=20absent.=20CSS=20adds=20comment?= =?UTF-8?q?=E2=80=91avatar=20styles=20and=20tests=20cover=20avatar=20prese?= =?UTF-8?q?nce,=20absence=20and=20nested=20tree=20propagation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/css/article.css | 13 ++++++++ frontend/templates/article_detail.html | 8 ++++- src/application/domain/comment.py | 9 +++++- src/application/services/article_service.py | 3 +- src/application/services/comment_service.py | 3 +- src/application/services/service_utils.py | 18 ++++++++--- .../input_adapters/dto/comment_response.py | 10 +++++-- .../dto/test_comment_response.py | 30 +++++++++++++++++++ 8 files changed, 84 insertions(+), 10 deletions(-) diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 2f7f38c4..1455fe82 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -503,6 +503,12 @@ input[type=number] { justify-content: center; } +.comment-avatar a { + display: flex; + width: 100%; + height: 100%; +} + .comment-avatar-initial { font-size: var(--font-body-sm); font-weight: 600; @@ -510,6 +516,13 @@ input[type=number] { line-height: 1; } +.comment-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 9999px; +} + .comment-avatar-deleted { background: var(--surface-container-high); } diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 46f7ee49..2f906fad 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -79,7 +79,13 @@

{{ article.article_title }}

{% if not is_deleted %} - {{ node.comment.author_username[0]|upper }} + + {% if node.comment.author_avatar_file_id %} + {{ node.comment.author_username }} + {% else %} + {{ node.comment.author_username[0]|upper }} + {% endif %} + {% else %} ? {% endif %} diff --git a/src/application/domain/comment.py b/src/application/domain/comment.py index af15e4ec..9ca56f39 100644 --- a/src/application/domain/comment.py +++ b/src/application/domain/comment.py @@ -47,10 +47,17 @@ def __init__( @dataclass class CommentWithAuthor: """ - Read Model that combines a Comment domain entity with its author's username. + Read Model that combines a Comment domain entity with its author's username + and optional avatar file ID. + + Attributes: + comment (Comment): The underlying comment domain entity. + author_name (str): The display name of the comment author. + author_avatar_file_id (str | None): UUID of the author's avatar file, or None. """ comment: Comment author_name: str + author_avatar_file_id: str | None = None @dataclass class CommentNode: diff --git a/src/application/services/article_service.py b/src/application/services/article_service.py index 4fc02305..fcab7c87 100644 --- a/src/application/services/article_service.py +++ b/src/application/services/article_service.py @@ -304,7 +304,8 @@ def get_article_with_comments(self, article_id: int) -> ArticleDetailView | str: author_ids.update(c.comment_written_account_id for c in all_comments) authors = self.account_repository.get_by_ids(list(author_ids)) author_map = {acc.account_id: acc.account_username for acc in authors} - nested = build_comment_nested_tree(all_comments, author_map) + avatar_map = {acc.account_id: acc.avatar_file_id for acc in authors} + nested = build_comment_nested_tree(all_comments, author_map, avatar_map) author_name = author_map.get(article.article_author_id, "Unknown") article_with_author = ArticleWithAuthor(article=article, author_name=author_name) return ArticleDetailView(article_with_author=article_with_author, nested_comments=nested) diff --git a/src/application/services/comment_service.py b/src/application/services/comment_service.py index 375f5453..5f31b57f 100644 --- a/src/application/services/comment_service.py +++ b/src/application/services/comment_service.py @@ -186,7 +186,8 @@ def get_comments_for_article(self, article_id: int) -> list[CommentNode] | str: author_ids = {c.comment_written_account_id for c in all_comments} authors = self.account_repository.get_by_ids(list(author_ids)) author_map = {acc.account_id: acc.account_username for acc in authors} - return build_comment_nested_tree(all_comments, author_map) + avatar_map = {acc.account_id: acc.avatar_file_id for acc in authors} + return build_comment_nested_tree(all_comments, author_map, avatar_map) def delete_comment(self, comment_id: int, user_id: int, cascade: bool = True) -> bool | str: """ diff --git a/src/application/services/service_utils.py b/src/application/services/service_utils.py index e0bd906c..5772f23e 100644 --- a/src/application/services/service_utils.py +++ b/src/application/services/service_utils.py @@ -3,16 +3,22 @@ from src.application.domain.comment import Comment, CommentNode, CommentWithAuthor -def build_comment_nested_tree(all_comments: list[Comment], author_map: dict[int, str]) -> list[CommentNode]: +def build_comment_nested_tree( + all_comments: list[Comment], + author_map: dict[int, str], + avatar_map: dict[int, str | None] | None = None, +) -> list[CommentNode]: """ - Transforms a flat list of comment entities and an author mapping into a recursive N-level tree. + Transforms a flat list of comment entities and author/avatar mappings into a recursive N-level tree. Builds a parent_map from comment_reply_to, then recurses from roots (reply_to is None) to produce a nested CommentNode tree with sorted children. Args: all_comments (list[Comment]): The flat list of domain comment entities to structure. - author_map (dict[int, str]): A mapping of account IDs to their corresponding usernames. + author_map (dict[int, str]): A mapping of account IDs to their usernames. + avatar_map (dict[int, str | None] | None): Optional mapping of account IDs + to their avatar file IDs. Defaults to None. Returns: list[CommentNode]: The tree root nodes, sorted most recent first. @@ -27,7 +33,11 @@ def build_comment_nested_tree(all_comments: list[Comment], author_map: dict[int, def _build_node(comment: Comment, depth: int) -> CommentNode: cwa = CommentWithAuthor( comment=comment, - author_name=author_map.get(comment.comment_written_account_id, "Unknown") + author_name=author_map.get(comment.comment_written_account_id, "Unknown"), + author_avatar_file_id=( + avatar_map.get(comment.comment_written_account_id) + if avatar_map else None + ), ) children = parent_map.pop(comment.comment_id, []) children.sort(key=lambda c: c.comment_posted_at) diff --git a/src/infrastructure/input_adapters/dto/comment_response.py b/src/infrastructure/input_adapters/dto/comment_response.py index 835d9273..55db860c 100644 --- a/src/infrastructure/input_adapters/dto/comment_response.py +++ b/src/infrastructure/input_adapters/dto/comment_response.py @@ -10,6 +10,9 @@ class CommentResponse(BaseModel): Data Transfer Object used to send comment data to the UI. Protects the Domain entity from being exposed directly to the templates. Handles formatting of complex types like dates. + + Attributes: + author_avatar_file_id (str | None): UUID of the author's avatar file, or None. """ model_config = ConfigDict(from_attributes=True) @@ -17,12 +20,13 @@ class CommentResponse(BaseModel): comment_article_id: int comment_written_account_id: int author_username: str = "Unknown" + author_avatar_file_id: str | None = None comment_reply_to: int | None comment_content: str comment_posted_at_formatted: str = "" @classmethod - def from_domain(cls, comment, author_username: str = "Unknown"): + 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. @@ -31,6 +35,7 @@ def from_domain(cls, comment, author_username: str = "Unknown"): 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. Returns: CommentResponse: The initialized DTO. @@ -51,6 +56,7 @@ def from_domain(cls, comment, author_username: str = "Unknown"): comment_article_id=comment.comment_article_id, comment_written_account_id=comment.comment_written_account_id, author_username=author_username, + author_avatar_file_id=author_avatar_file_id, comment_reply_to=comment.comment_reply_to, comment_content=content, comment_posted_at_formatted=formatted_date @@ -70,7 +76,7 @@ def map_nested_tree(cls, nodes: list, depth_limit: int = 4) -> list[CommentNodeR """ def _map(node) -> CommentNodeResponse: display_depth = node.depth if node.depth < depth_limit else depth_limit - cr = cls.from_domain(node.comment.comment, node.comment.author_name) + cr = cls.from_domain(node.comment.comment, node.comment.author_name, node.comment.author_avatar_file_id) replies = [_map(child) for child in node.replies] return CommentNodeResponse(comment=cr, replies=replies, depth=display_depth) return [_map(n) for n in nodes] 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 b4a44ddd..65fc3e1b 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 @@ -80,3 +80,33 @@ def test_map_nested_tree(): assert result[0].replies[0].comment.author_username == "Author2" assert result[0].replies[0].comment.comment_reply_to == 1 assert result[0].replies[0].depth == 1 + +def test_from_domain_with_avatar_file_id(): + comment = Comment(1, 10, 5, None, "Hello", datetime(2023, 10, 27, 14, 30)) + result = CommentResponse.from_domain(comment, "yoda", "abc-123") + assert result.author_avatar_file_id == "abc-123" + +def test_from_domain_without_avatar_file_id(): + comment = Comment(1, 10, 5, None, "Hello", datetime(2023, 10, 27, 14, 30)) + result = CommentResponse.from_domain(comment, "yoda") + assert result.author_avatar_file_id is None + +def test_map_nested_tree_threads_avatar(): + posted_at = datetime(2023, 10, 27, 14, 30) + comment_1 = Comment(1, 10, 1, None, "Root", posted_at) + comment_2 = Comment(2, 10, 2, 1, "Reply", posted_at) + nodes = [ + CommentNode( + comment=CommentWithAuthor(comment_1, "Author1", "avatar-1"), + replies=[ + CommentNode( + comment=CommentWithAuthor(comment_2, "Author2", "avatar-2"), + depth=1, + ) + ], + depth=0, + ) + ] + result = CommentResponse.map_nested_tree(nodes) + assert result[0].comment.author_avatar_file_id == "avatar-1" + assert result[0].replies[0].comment.author_avatar_file_id == "avatar-2" From e99ee4ac8b00a2bcffaedad392402a7999fbf7a0 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 10:20:45 +0200 Subject: [PATCH 05/15] =?UTF-8?q?User=20profile=20photo=20and=20access=20:?= =?UTF-8?q?=20Add=20article=20author=20avatars.=20Threads=20`author=5Favat?= =?UTF-8?q?ar=5Ffile=5Fid`=20through=20the=20article=20stack=20so=20list?= =?UTF-8?q?=20and=20detail=20views=20can=20show=20the=20author=E2=80=99s?= =?UTF-8?q?=20profile=20photo,=20with=20an=20initial=20fallback.=20Templat?= =?UTF-8?q?es=20add=20avatar=20images,=20CSS=20updates=20author=20styling?= =?UTF-8?q?=20and=20tests=20cover=20both=20avatar=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/css/article.css | 12 +++++++++++- frontend/templates/article_detail.html | 7 ++++++- frontend/templates/article_list.html | 7 ++++++- src/application/domain/article.py | 9 ++++++++- src/application/services/article_service.py | 13 +++++++++++-- .../input_adapters/dto/article_response.py | 7 ++++++- .../flask/flask_article_adapter.py | 9 +++++++-- .../dto/test_article_response.py | 16 ++++++++++++++++ 8 files changed, 71 insertions(+), 9 deletions(-) diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 1455fe82..f942dad4 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -78,7 +78,6 @@ font-family: var(--font-mono); font-size: var(--font-body-sm); font-weight: 500; - text-transform: uppercase; letter-spacing: 0.05em; color: var(--on-surface-variant); } @@ -89,6 +88,17 @@ text-decoration: none; position: relative; z-index: 1; + display: inline-flex; + align-items: center; +} + +.meta-author-avatar { + width: 1.25rem; + height: 1.25rem; + border-radius: 9999px; + object-fit: cover; + vertical-align: middle; + margin-right: var(--spacing-xs); } .meta-author:hover { diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 2f906fad..6b9b01eb 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -48,7 +48,12 @@

{{ article.article_title }}

diff --git a/frontend/templates/article_list.html b/frontend/templates/article_list.html index 512fe843..92178092 100644 --- a/frontend/templates/article_list.html +++ b/frontend/templates/article_list.html @@ -93,7 +93,12 @@

{{ article.meta_description }}

- {{ article.author_username }} + + {% if article.author_avatar_file_id %} + {{ article.author_username }} + {% endif %} + {{ article.author_username }} + {% if article.article_published_at %} diff --git a/src/application/domain/article.py b/src/application/domain/article.py index f6b05e14..931d2d7a 100644 --- a/src/application/domain/article.py +++ b/src/application/domain/article.py @@ -43,10 +43,17 @@ def __init__( @dataclass class ArticleWithAuthor: """ - Read Model that combines an Article domain entity with its author's username. + Read Model that combines an Article domain entity with its author's username + and optional avatar file ID. + + Attributes: + article (Article): The underlying article domain entity. + author_name (str): The display name of the article author. + author_avatar_file_id (str | None): UUID of the author's avatar file, or None. """ article: Article author_name: str + author_avatar_file_id: str | None = None @dataclass class ArticleDetailView: diff --git a/src/application/services/article_service.py b/src/application/services/article_service.py index fcab7c87..5b674ee8 100644 --- a/src/application/services/article_service.py +++ b/src/application/services/article_service.py @@ -259,7 +259,12 @@ def get_paginated_articles(self, page: int = 1, per_page: int = 10) -> list[Arti author_ids = {a.article_author_id for a in domain_articles} authors = self.account_repository.get_by_ids(list(author_ids)) author_map = {acc.account_id: acc.account_username for acc in authors} - return [ArticleWithAuthor(article=a, author_name=author_map.get(a.article_author_id, "Unknown")) for a in domain_articles] + avatar_map = {acc.account_id: acc.avatar_file_id for acc in authors} + return [ArticleWithAuthor( + article=a, + author_name=author_map.get(a.article_author_id, "Unknown"), + author_avatar_file_id=avatar_map.get(a.article_author_id), + ) for a in domain_articles] def get_total_count(self) -> int: """ @@ -307,5 +312,9 @@ def get_article_with_comments(self, article_id: int) -> ArticleDetailView | str: avatar_map = {acc.account_id: acc.avatar_file_id for acc in authors} nested = build_comment_nested_tree(all_comments, author_map, avatar_map) author_name = author_map.get(article.article_author_id, "Unknown") - article_with_author = ArticleWithAuthor(article=article, author_name=author_name) + article_with_author = ArticleWithAuthor( + article=article, + author_name=author_name, + author_avatar_file_id=avatar_map.get(article.article_author_id), + ) return ArticleDetailView(article_with_author=article_with_author, nested_comments=nested) diff --git a/src/infrastructure/input_adapters/dto/article_response.py b/src/infrastructure/input_adapters/dto/article_response.py index be4bf3a6..f044cf9e 100644 --- a/src/infrastructure/input_adapters/dto/article_response.py +++ b/src/infrastructure/input_adapters/dto/article_response.py @@ -29,19 +29,23 @@ class ArticleResponse(BaseModel): """ Data Transfer Object used to send article data to the UI. Protects the Domain entity from being exposed directly to the templates. + + Attributes: + author_avatar_file_id (str | None): UUID of the author's avatar file, or None. """ model_config = ConfigDict(from_attributes=True) article_id: int article_author_id: int author_username: str = "Unknown" + author_avatar_file_id: str | None = None article_title: str article_content: str article_published_at: datetime | None = None meta_description: str = "" @classmethod - def from_domain(cls, article, author_username: str = "Unknown"): + def from_domain(cls, article, author_username: str = "Unknown", author_avatar_file_id: str | None = None): plain_text = _blocks_to_plain_text(article.article_content).replace("\n", " ").strip() limit_character = 155 if len(plain_text) > limit_character: @@ -53,6 +57,7 @@ def from_domain(cls, article, author_username: str = "Unknown"): article_id=article.article_id, article_author_id=article.article_author_id, author_username=author_username, + author_avatar_file_id=author_avatar_file_id, article_title=article.article_title, article_content=article.article_content, article_published_at=article.article_published_at, diff --git a/src/infrastructure/input_adapters/flask/flask_article_adapter.py b/src/infrastructure/input_adapters/flask/flask_article_adapter.py index cdad0c75..65c13750 100644 --- a/src/infrastructure/input_adapters/flask/flask_article_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_article_adapter.py @@ -64,7 +64,11 @@ def list_articles(self) -> str: articles = [] for item in domain_articles: - articles.append(ArticleResponse.from_domain(item.article, author_username=item.author_name)) + articles.append(ArticleResponse.from_domain( + item.article, + author_username=item.author_name, + author_avatar_file_id=item.author_avatar_file_id, + )) has_next = (page * 10) < total_count has_prev = page > 1 @@ -100,7 +104,8 @@ def read_article(self, article_id: int) -> str | Response: detail = result article = ArticleResponse.from_domain( detail.article_with_author.article, - author_username=detail.article_with_author.author_name + author_username=detail.article_with_author.author_name, + author_avatar_file_id=detail.article_with_author.author_avatar_file_id, ) content = article.article_content diff --git a/tests/tests_infrastructure/tests_input_adapters/dto/test_article_response.py b/tests/tests_infrastructure/tests_input_adapters/dto/test_article_response.py index 93649888..4d1c1c5d 100644 --- a/tests/tests_infrastructure/tests_input_adapters/dto/test_article_response.py +++ b/tests/tests_infrastructure/tests_input_adapters/dto/test_article_response.py @@ -34,3 +34,19 @@ def test_from_domain_with_null_date(self): response = ArticleResponse.from_domain(domain_article, author_username="Guest") assert response.author_username == "Guest" assert response.article_published_at is None + + def test_from_domain_with_avatar_file_id(self): + domain_article = Article( + article_id=1, article_author_id=10, article_title="Title", + article_content="Content", article_published_at=datetime.now() + ) + result = ArticleResponse.from_domain(domain_article, "yoda", "abc-123") + assert result.author_avatar_file_id == "abc-123" + + def test_from_domain_without_avatar_file_id(self): + domain_article = Article( + article_id=1, article_author_id=10, article_title="Title", + article_content="Content", article_published_at=datetime.now() + ) + result = ArticleResponse.from_domain(domain_article, "yoda") + assert result.author_avatar_file_id is None From 5d66223fc3887f94a0d161fedcaae93efda4bbc7 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 10:27:36 +0200 Subject: [PATCH 06/15] User profile photo and access : Anonymous comments show the author as plain text with no hover. The template replaces the link with a `` for deleted/anonymous authors and CSS removes hover effects inside `.comment-deleted` --- frontend/static/css/article.css | 6 ++++++ frontend/templates/article_detail.html | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index f942dad4..37f06767 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -611,6 +611,12 @@ input[type=number] { font-style: italic; } +.comment-deleted > .comment-content > .comment-header-section > .comment-header > .comment-author:hover { + color: inherit; + text-decoration: none; + cursor: default; +} + .comment-body-text { font-size: var(--font-body-base); color: var(--on-surface); diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 6b9b01eb..dcee9ed6 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -98,7 +98,11 @@

{{ article.article_title }}

- {{ node.comment.author_username }} + {% if not is_deleted %} + {{ node.comment.author_username }} + {% else %} + {{ node.comment.author_username }} + {% endif %} {{ node.comment.comment_posted_at_formatted }} {% if current_user and current_user.account_role == 'admin' %}
From 906fe5a6f59582544add467226dbf1164569281c Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 16:08:45 +0200 Subject: [PATCH 07/15] =?UTF-8?q?User=20profile=20photo=20and=20access=20:?= =?UTF-8?q?=20Add=20profile=20photo=20removal.=20Introduces=20a=20POST=20/?= =?UTF-8?q?profile/photo/delete=20endpoint=20with=20CSRF=E2=80=91protected?= =?UTF-8?q?=20form,=20updates=20backend=20to=20accept=20nullable=20`avatar?= =?UTF-8?q?=5Ffile=5Fid`,=20adds=20the=20Flask=20removal=20handler,=20upda?= =?UTF-8?q?tes=20the=20profile=20template=20and=20CSS=20for=20the=20remove?= =?UTF-8?q?=20button,=20extends=20JS=20for=20deletion=20and=20adds=20corre?= =?UTF-8?q?sponding=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flask_setup/middleware.py | 1 + flask_setup/routes.py | 7 ++++ frontend/static/css/profile.css | 5 +++ frontend/templates/profile.html | 6 ++++ .../input_ports/account_session_management.py | 8 +++-- src/application/services/login_service.py | 8 +++-- .../flask/flask_account_session_adapter.py | 32 +++++++++++++++++++ tests/test_domain_factories.py | 2 ++ .../test_flask_account_session_adapter.py | 29 +++++++++++++++++ 9 files changed, 92 insertions(+), 6 deletions(-) diff --git a/flask_setup/middleware.py b/flask_setup/middleware.py index ec9aad07..f15522db 100644 --- a/flask_setup/middleware.py +++ b/flask_setup/middleware.py @@ -175,6 +175,7 @@ def init_web_security(app: Flask) -> None: app: The Flask application instance to secure. """ app.session_interface = NonPersistentSessionInterface() + app.config["WTF_CSRF_TIME_LIMIT"] = None csrf_protect = CSRFProtect(app) csp = CSPConfig() app.after_request(csp.add_headers) diff --git a/flask_setup/routes.py b/flask_setup/routes.py index 0e3b35d2..8483b45b 100644 --- a/flask_setup/routes.py +++ b/flask_setup/routes.py @@ -93,6 +93,13 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None: ) csrf.exempt(acc.upload_profile_photo) + app.add_url_rule( + "/profile/photo/delete", + view_func=acc.remove_profile_photo, + methods=["POST"], + endpoint="auth.remove_profile_photo", + ) + def _register_file_routes(app: Flask, adapters: dict) -> None: fad = adapters["file_adapter"] diff --git a/frontend/static/css/profile.css b/frontend/static/css/profile.css index e7387b8f..c53fbd23 100644 --- a/frontend/static/css/profile.css +++ b/frontend/static/css/profile.css @@ -34,6 +34,11 @@ margin-bottom: var(--spacing-sm); } +.remove-avatar-form { + display: inline-block; + margin-bottom: var(--spacing-sm); +} + .role-badge { display: inline-block; padding: var(--spacing-xs) var(--spacing-sm); diff --git a/frontend/templates/profile.html b/frontend/templates/profile.html index 43a2fdf6..039b4cbd 100644 --- a/frontend/templates/profile.html +++ b/frontend/templates/profile.html @@ -20,6 +20,12 @@ {% if is_own_profile %} + {% if user.avatar_file_id %} + + + + + {% endif %} {% endif %}

{{ user.account_username }}

{{ user.account_role }} diff --git a/src/application/input_ports/account_session_management.py b/src/application/input_ports/account_session_management.py index 30b773b2..be6d56f1 100644 --- a/src/application/input_ports/account_session_management.py +++ b/src/application/input_ports/account_session_management.py @@ -41,11 +41,13 @@ def get_account_by_username(self, username: str) -> Account | None: pass @abstractmethod - def update_avatar(self, avatar_file_id: str) -> None: + def update_avatar(self, avatar_file_id: str | None) -> None: """ - Updates the avatar_file_id for the currently authenticated account. + Sets or clears the avatar_file_id for the currently authenticated account. + + Pass None to remove the avatar reference. Args: - avatar_file_id: The UUID of the uploaded avatar file. + avatar_file_id: The UUID of the uploaded avatar file, or None to clear. """ pass diff --git a/src/application/services/login_service.py b/src/application/services/login_service.py index 54aa7df2..0845639f 100644 --- a/src/application/services/login_service.py +++ b/src/application/services/login_service.py @@ -92,15 +92,17 @@ def get_account_by_username(self, username: str) -> Account | None: """ return self.account_repository.find_by_username(username) - def update_avatar(self, avatar_file_id: str) -> None: + def update_avatar(self, avatar_file_id: str | None) -> None: """ - Updates the avatar_file_id for the currently authenticated account. + Sets or clears the avatar_file_id for the currently authenticated account. Retrieves the current account from the session and delegates the persistence update to the account repository. + Pass None to remove the avatar reference. + Args: - avatar_file_id: The UUID of the uploaded avatar file. + avatar_file_id: The UUID of the uploaded avatar file, or None to clear. """ account = self.get_current_account() if account is None: diff --git a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py index bbbefa37..33876071 100644 --- a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py @@ -155,3 +155,35 @@ def upload_profile_photo(self): return jsonify({ "avatar_url": url_for("file.serve_file", file_id=file_record.file_id, filename="avatar"), }), 200 + + def remove_profile_photo(self): + """ + Removes the current user's profile photo. + + Deletes the uploaded file from storage via the file service, + then clears the avatar_file_id reference on the account. + + Redirects back to profile with a flash message on success or error. + + Returns: + Response: A Flask redirect response. + """ + account = self.session_service.get_current_account() + if not account: + flash("Please sign in.", "error") + return redirect(url_for("auth.login")) + + avatar_file_id = account.avatar_file_id + if not avatar_file_id: + flash("No avatar to remove.", "error") + return redirect(url_for("auth.profile")) + + try: + self.file_service.delete_file(avatar_file_id) + self.session_service.update_avatar(None) + except Exception: + flash("Failed to remove profile photo.", "error") + return redirect(url_for("auth.profile")) + + flash("Profile photo removed.", "success") + return redirect(url_for("auth.profile")) diff --git a/tests/test_domain_factories.py b/tests/test_domain_factories.py index 5fbf1e0f..24de3a9b 100644 --- a/tests/test_domain_factories.py +++ b/tests/test_domain_factories.py @@ -12,6 +12,7 @@ def create_test_account( account_email: str = "leia@galaxy.com", account_role: AccountRole = AccountRole.USER, account_created_at: datetime | None = None, + account_avatar_file_id: str | None = None, ) -> Account: """Factory to create a test Account entity with sensible defaults.""" if account_created_at is None: @@ -24,6 +25,7 @@ def create_test_account( account_email=account_email, account_role=account_role, account_created_at=account_created_at, + avatar_file_id=account_avatar_file_id, ) diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py index 03395a4f..501d86cc 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py @@ -59,6 +59,13 @@ def setup_method(self): "file_serve", ) + self.app.add_url_rule( + "/profile/photo/delete", + view_func=self.adapter.remove_profile_photo, + methods=["POST"], + endpoint="auth.remove_profile_photo", + ) + def test_logout_clears_session(self): response = self.client.post("/logout", follow_redirects=True) assert b"You have been logged out." in response.data @@ -170,6 +177,28 @@ def test_upload_profile_photo_success(self): self.mock_file_service.upload_file.assert_called_once() self.mock_session_service.update_avatar.assert_called_once_with("abc-123") + def test_remove_profile_photo_unauthenticated(self): + self.mock_session_service.get_current_account.return_value = None + response = self.client.post("/profile/photo/delete", follow_redirects=True) + assert b"Please sign in." in response.data + assert b"alert-error" in response.data + + def test_remove_profile_photo_success(self): + fake_user = create_test_account(account_avatar_file_id="abc-123") + self.mock_session_service.get_current_account.return_value = fake_user + response = self.client.post("/profile/photo/delete", follow_redirects=True) + assert b"Profile photo removed." in response.data + assert b"alert-success" in response.data + self.mock_file_service.delete_file.assert_called_once_with("abc-123") + self.mock_session_service.update_avatar.assert_called_once_with(None) + + def test_remove_profile_photo_no_avatar(self): + fake_user = create_test_account(account_avatar_file_id=None) + self.mock_session_service.get_current_account.return_value = fake_user + response = self.client.post("/profile/photo/delete", follow_redirects=True) + assert b"No avatar to remove." in response.data + assert b"alert-error" in response.data + class TestAccountSessionBeforeRequestHook(FlaskInputAdapterTestBase): """ From 5df9a9dbf9f312479b0af8fe9d4724d50e3a3144 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 17:24:07 +0200 Subject: [PATCH 08/15] =?UTF-8?q?User=20profile=20photo=20and=20access=20:?= =?UTF-8?q?=20Add=20admin=20user=20list=20and=20refine=20profile=20page=20?= =?UTF-8?q?mobile=20layout.=20Introduces=20an=20admin=E2=80=91only=20/admi?= =?UTF-8?q?n/users=20table=20with=20full=20hexagonal=20support,=20improves?= =?UTF-8?q?=20profile=20actions=20with=20a=20flex=20layout=20and=20mobile?= =?UTF-8?q?=20stacking=20and=20standardizes=20avatar=20button=20width=20an?= =?UTF-8?q?d=20line=E2=80=91height.=20Tests=20cover=20admin=20access=20and?= =?UTF-8?q?=20get=5Fall=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flask_setup/routes.py | 7 ++ frontend/static/css/base.css | 1 + frontend/static/css/profile.css | 23 +++++- frontend/static/css/user-list.css | 73 +++++++++++++++++++ frontend/templates/profile.html | 13 +++- frontend/templates/user_list.html | 39 ++++++++++ .../input_ports/account_session_management.py | 13 ++++ .../output_ports/account_repository.py | 10 +++ src/application/services/login_service.py | 9 +++ .../flask/flask_account_session_adapter.py | 27 +++++++ .../in_memory/account_repository.py | 9 +++ .../sqlalchemy/sqlalchemy_account_adapter.py | 10 +++ .../test_flask_account_session_adapter.py | 27 +++++++ .../test_in_memory_adapters.py | 11 +++ .../test_sqlalchemy_account_adapter.py | 10 +++ 15 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 frontend/static/css/user-list.css create mode 100644 frontend/templates/user_list.html diff --git a/flask_setup/routes.py b/flask_setup/routes.py index 8483b45b..8d179fab 100644 --- a/flask_setup/routes.py +++ b/flask_setup/routes.py @@ -100,6 +100,13 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None: endpoint="auth.remove_profile_photo", ) + app.add_url_rule( + "/admin/users", + view_func=acc.list_all_users, + methods=["GET"], + endpoint="auth.list_all_users", + ) + def _register_file_routes(app: Flask, adapters: dict) -> None: fad = adapters["file_adapter"] diff --git a/frontend/static/css/base.css b/frontend/static/css/base.css index 73bbddf0..12de82fb 100644 --- a/frontend/static/css/base.css +++ b/frontend/static/css/base.css @@ -461,6 +461,7 @@ button, /* Buttons */ button, .btn { + line-height: 1.5; padding: var(--spacing-sm) var(--spacing-xl); background: var(--primary); color: var(--on-primary); diff --git a/frontend/static/css/profile.css b/frontend/static/css/profile.css index c53fbd23..626e1d54 100644 --- a/frontend/static/css/profile.css +++ b/frontend/static/css/profile.css @@ -39,6 +39,11 @@ margin-bottom: var(--spacing-sm); } +#upload-avatar-btn, +.remove-avatar-form .btn-sm { + min-width: 8rem; +} + .role-badge { display: inline-block; padding: var(--spacing-xs) var(--spacing-sm); @@ -78,10 +83,26 @@ } .logout-form { - margin-top: var(--spacing-lg); + margin: 0; text-align: center; } +.profile-actions { + display: flex; + gap: var(--spacing-sm); + justify-content: center; + margin-top: var(--spacing-lg); +} + +@media (max-width: 22.5em) { + .profile-actions { + flex-direction: column; + } + .profile-actions .btn-secondary { + width: 100%; + } +} + .back-link { display: flex; align-items: center; diff --git a/frontend/static/css/user-list.css b/frontend/static/css/user-list.css new file mode 100644 index 00000000..7142e07b --- /dev/null +++ b/frontend/static/css/user-list.css @@ -0,0 +1,73 @@ +.user-table-wrapper { + overflow-x: auto; +} + +.user-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-body-base); +} + +.user-table th { + text-align: left; + padding: var(--spacing-sm); + font-size: var(--font-body-sm); + font-family: var(--font-mono); + color: var(--on-surface-variant); + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--outline); +} + +.user-table td { + padding: var(--spacing-sm); + border-bottom: 1px solid var(--outline-variant); + color: var(--on-surface); +} + +.user-table tbody tr:hover { + background: color-mix(in srgb, var(--primary) 8%, transparent); +} + +.user-table a { + color: var(--primary); + text-decoration: none; + font-weight: 600; +} + +.user-table a:hover { + text-decoration: underline; +} + +.user-card h1 { + text-align: center; + font-family: var(--font-mono); +} + +.user-card .back-link { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + margin-top: calc(var(--spacing-lg) * 2); + color: var(--on-surface); + font-size: var(--font-body-base); + text-decoration: none; + font-weight: 500; + transition: color 0.15s ease; +} + +.user-card .back-link:hover { + color: var(--primary); +} + +.user-card .back-link .icon { + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-headline-md); +} + +.user-card .back-link span:last-child { + transform: translateY(1.5px); +} diff --git a/frontend/templates/profile.html b/frontend/templates/profile.html index 039b4cbd..ee5b1249 100644 --- a/frontend/templates/profile.html +++ b/frontend/templates/profile.html @@ -57,10 +57,15 @@

{{ user.account_username }}

{% if is_own_profile %} -
- - -
+
+ {% if current_user.account_role == 'admin' %} + Manage Users + {% endif %} +
+ + +
+
{% endif %} diff --git a/frontend/templates/user_list.html b/frontend/templates/user_list.html new file mode 100644 index 00000000..f1936a77 --- /dev/null +++ b/frontend/templates/user_list.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}Users - DevJournal{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/src/application/input_ports/account_session_management.py b/src/application/input_ports/account_session_management.py index be6d56f1..1350a4d8 100644 --- a/src/application/input_ports/account_session_management.py +++ b/src/application/input_ports/account_session_management.py @@ -51,3 +51,16 @@ def update_avatar(self, avatar_file_id: str | None) -> None: avatar_file_id: The UUID of the uploaded avatar file, or None to clear. """ pass + + @abstractmethod + def get_all_accounts(self) -> list[Account]: + """ + Retrieves all registered accounts. + + Intended for admin use only. The calling adapter is responsible + for enforcing role-based access control. + + Returns: + list[Account]: A list of all Account domain entities. + """ + pass diff --git a/src/application/output_ports/account_repository.py b/src/application/output_ports/account_repository.py index 42000a7d..1621a9bd 100644 --- a/src/application/output_ports/account_repository.py +++ b/src/application/output_ports/account_repository.py @@ -84,3 +84,13 @@ def update_avatar(self, account_id: int, avatar_file_id: str | None) -> None: avatar_file_id: The new avatar file UUID, or None to remove. """ pass + + @abstractmethod + def get_all(self) -> list[Account]: + """ + Retrieves all accounts from the data store. + + Returns: + list[Account]: A list of all Account domain entities. + """ + pass diff --git a/src/application/services/login_service.py b/src/application/services/login_service.py index 0845639f..2885d7d9 100644 --- a/src/application/services/login_service.py +++ b/src/application/services/login_service.py @@ -108,3 +108,12 @@ def update_avatar(self, avatar_file_id: str | None) -> None: if account is None: return self.account_repository.update_avatar(account.account_id, avatar_file_id) + + def get_all_accounts(self) -> list[Account]: + """ + Retrieves all accounts via the account repository. + + Returns: + list[Account]: A list of all Account domain entities. + """ + return self.account_repository.get_all() diff --git a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py index 33876071..fb52bf9b 100644 --- a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py @@ -2,6 +2,7 @@ from flask import g as global_request_context from flask.views import MethodView from src.application.application_exceptions import FileTooLargeError, FileTypeError +from src.application.domain.account import AccountRole from src.application.input_ports.account_session_management import AccountSessionManagementPort from src.application.input_ports.file_management import FileManagementPort from src.infrastructure.input_adapters.dto.account_response import AccountResponse @@ -187,3 +188,29 @@ def remove_profile_photo(self): flash("Profile photo removed.", "success") return redirect(url_for("auth.profile")) + + def list_all_users(self): + """ + Renders the admin-only user list page. + + Access restricted to admin role. Non-admin users receive a 403. + Displays all registered users with username, email, role, and join date. + + Returns: + str: The rendered user_list.html template. + + Raises: + 403: If the current user is not authenticated or not an admin. + """ + current_account = self.session_service.get_current_account() + if not current_account or current_account.account_role != AccountRole.ADMIN: + abort(403) + + accounts = self.session_service.get_all_accounts() + users_dto = [AccountResponse.from_domain(acc) for acc in accounts] + + return render_template( + "user_list.html", + users=users_dto, + current_user=current_account, + ) diff --git a/src/infrastructure/output_adapters/in_memory/account_repository.py b/src/infrastructure/output_adapters/in_memory/account_repository.py index 16eb1542..ae34df1a 100644 --- a/src/infrastructure/output_adapters/in_memory/account_repository.py +++ b/src/infrastructure/output_adapters/in_memory/account_repository.py @@ -93,3 +93,12 @@ def update_avatar(self, account_id: int, avatar_file_id: str | None) -> None: if account is None: return account.avatar_file_id = avatar_file_id + + def get_all(self) -> list[Account]: + """ + Retrieves all accounts from the in-memory store. + + Returns: + list[Account]: A list of all Account domain entities. + """ + return list(self._accounts.values()) diff --git a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py index 147c6560..89d7d9e1 100644 --- a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py +++ b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py @@ -171,3 +171,13 @@ def update_avatar(self, account_id: int, avatar_file_id: str | None) -> None: return model.avatar_file_id = avatar_file_id self._session.commit() + + def get_all(self) -> list[Account]: + """ + Retrieves all accounts from the database. + + Returns: + list[Account]: A list of all Account domain entities. + """ + models = self._session.query(AccountModel).all() + return [self._to_domain(model) for model in models] diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py index 501d86cc..bc50fc43 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py @@ -66,6 +66,13 @@ def setup_method(self): endpoint="auth.remove_profile_photo", ) + self.app.add_url_rule( + "/admin/users", + view_func=self.adapter.list_all_users, + methods=["GET"], + endpoint="auth.list_all_users", + ) + def test_logout_clears_session(self): response = self.client.post("/logout", follow_redirects=True) assert b"You have been logged out." in response.data @@ -199,6 +206,26 @@ def test_remove_profile_photo_no_avatar(self): assert b"No avatar to remove." in response.data assert b"alert-error" in response.data + def test_list_all_users_as_admin(self): + fake_admin = create_test_account(account_role=AccountRole.ADMIN) + self.mock_session_service.get_current_account.return_value = fake_admin + fake_users = [ + create_test_account(account_id=1, account_username="alice"), + create_test_account(account_id=2, account_username="bob"), + ] + self.mock_session_service.get_all_accounts.return_value = fake_users + + response = self.client.get("/admin/users") + assert response.status_code == 200 + assert b"alice" in response.data + assert b"bob" in response.data + + def test_list_all_users_as_non_admin_returns_403(self): + fake_user = create_test_account(account_role=AccountRole.USER) + self.mock_session_service.get_current_account.return_value = fake_user + response = self.client.get("/admin/users") + assert response.status_code == 403 + class TestAccountSessionBeforeRequestHook(FlaskInputAdapterTestBase): """ diff --git a/tests/tests_infrastructure/tests_output_adapters/test_in_memory_adapters.py b/tests/tests_infrastructure/tests_output_adapters/test_in_memory_adapters.py index 83aa3fc3..0ff2beef 100644 --- a/tests/tests_infrastructure/tests_output_adapters/test_in_memory_adapters.py +++ b/tests/tests_infrastructure/tests_output_adapters/test_in_memory_adapters.py @@ -117,6 +117,17 @@ def test_get_by_ids_empty_list(self): repo.save(Account(1, "user", "pass", "em", AccountRole.USER, datetime.now())) assert repo.get_by_ids([]) == [] + def test_get_all_accounts(self): + repo = InMemoryAccountRepository() + a1 = Account(1, "user1", "pass", "em1", AccountRole.USER, datetime.now()) + a2 = Account(2, "user2", "pass", "em2", AccountRole.USER, datetime.now()) + repo.save(a1) + repo.save(a2) + results = repo.get_all() + assert len(results) == 2 + assert a1 in results + assert a2 in results + class TestInMemoryCommentRepository: def test_save_and_get(self): diff --git a/tests/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py b/tests/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py index 400d83b6..f4dc826c 100644 --- a/tests/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py +++ b/tests/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py @@ -139,3 +139,13 @@ def test_update_avatar_removes_file_id(self): def test_update_avatar_nonexistent_account_does_not_raise(self): self.repository.update_avatar(99999, "abc-123") + + +class TestAccountGetAll(SqlAlchemyAccountAdapterTestBase): + def test_get_all_returns_all_accounts(self): + self.account_builder.create(username="user_one", email="one@test.com") + self.account_builder.create(username="user_two", email="two@test.com") + results = self.repository.get_all() + assert len(results) == 2 + usernames = {r.account_username for r in results} + assert usernames == {"user_one", "user_two"} From a2aac8a9dd51560e73c7be84692b6b75f0333e11 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 17:40:51 +0200 Subject: [PATCH 09/15] User profile photo and access : Promote the profile photo upload button to primary action. The Upload Photo button loses its secondary style and becomes the main filled button, while Remove Photo stays as the secondary outline action --- frontend/templates/profile.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/templates/profile.html b/frontend/templates/profile.html index ee5b1249..1903f0cf 100644 --- a/frontend/templates/profile.html +++ b/frontend/templates/profile.html @@ -18,7 +18,7 @@ {% endif %}
{% if is_own_profile %} - + {% if user.avatar_file_id %}
From da47ec9a558519edfc47616395c1439b6954d2cb Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 17:51:46 +0200 Subject: [PATCH 10/15] =?UTF-8?q?User=20profile=20photo=20and=20access=20:?= =?UTF-8?q?=20Delete=20old=20avatar=20file=20on=20re=E2=80=91upload.=20Whe?= =?UTF-8?q?n=20a=20user=20uploads=20a=20new=20profile=20photo,=20the=20pre?= =?UTF-8?q?vious=20avatar=20file=20is=20removed=20from=20storage=20to=20av?= =?UTF-8?q?oid=20orphaned=20records.=20The=20new=20upload=20is=20unaffecte?= =?UTF-8?q?d,=20failures=20only=20trigger=20a=20logged=20warning.=20Tests?= =?UTF-8?q?=20verify=20that=20re=E2=80=91uploading=20replaces=20the=20old?= =?UTF-8?q?=20avatar=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flask/flask_account_session_adapter.py | 15 +++++++++++ .../test_flask_account_session_adapter.py | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py index fb52bf9b..a3d81650 100644 --- a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py @@ -1,3 +1,5 @@ +import logging + from flask import abort, flash, jsonify, redirect, render_template, request, url_for from flask import g as global_request_context from flask.views import MethodView @@ -7,6 +9,8 @@ from src.application.input_ports.file_management import FileManagementPort from src.infrastructure.input_adapters.dto.account_response import AccountResponse +logger = logging.getLogger(__name__) + class AccountSessionAdapter(MethodView): """ @@ -151,6 +155,17 @@ def upload_profile_photo(self): except (FileTooLargeError, FileTypeError) as e: return jsonify({"error": str(e)}), 400 + old_avatar_id = current_account.avatar_file_id + if old_avatar_id: + try: + self.file_service.delete_file(old_avatar_id) + except Exception: + logger.warning( + "Failed to delete old avatar %s for account %s", + old_avatar_id, + current_account.account_id, + ) + self.session_service.update_avatar(file_record.file_id) return jsonify({ diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py index bc50fc43..2fe6b717 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_account_session_adapter.py @@ -184,6 +184,33 @@ def test_upload_profile_photo_success(self): self.mock_file_service.upload_file.assert_called_once() self.mock_session_service.update_avatar.assert_called_once_with("abc-123") + def test_upload_profile_photo_replaces_old_avatar(self): + from datetime import datetime + from io import BytesIO + + from src.application.domain.file_record import FileRecord + + fake_user = create_test_account(account_avatar_file_id="old-avatar-id") + self.set_current_user(fake_user) + fake_file = FileRecord( + file_id="new-avatar-id", + original_filename="new_avatar.jpg", + mime_type="image/jpeg", + size=1024, + data=b"new-image-data", + created_at=datetime.now(), + ) + self.mock_file_service.upload_file.return_value = fake_file + + response = self.client.post( + "/api/profile/photo", + data={"file": (BytesIO(b"new-image"), "new_avatar.jpg")}, + content_type="multipart/form-data", + ) + assert response.status_code == 200 + self.mock_file_service.delete_file.assert_called_once_with("old-avatar-id") + self.mock_session_service.update_avatar.assert_called_once_with("new-avatar-id") + def test_remove_profile_photo_unauthenticated(self): self.mock_session_service.get_current_account.return_value = None response = self.client.post("/profile/photo/delete", follow_redirects=True) From d25affcfd30b0476ff5819294e447e66581cf615 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 17:56:57 +0200 Subject: [PATCH 11/15] User profile photo and access : Add full integration test for profile photo lifecycle. Covers uploading a new avatar (old file removed, account updated), then removing it (avatar_file_id cleared, file deleted). Implemented in `TestProfilePhoto` within `test_account_integration.py` --- .../test_account_integration.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/tests_integration/test_account_integration.py b/tests/tests_integration/test_account_integration.py index 08b1c4fa..cb8285a8 100644 --- a/tests/tests_integration/test_account_integration.py +++ b/tests/tests_integration/test_account_integration.py @@ -1,7 +1,9 @@ from concurrent.futures import ThreadPoolExecutor +from io import BytesIO from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_account_model import AccountModel from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_article_model import ArticleModel +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_uploaded_file_model import UploadedFileModel class TestRegistration: @@ -68,6 +70,63 @@ def test_profile_update_propagation_and_session_integ(self, client, db_session): assert b"new_legend" in response_3.data assert b"old_name" not in response_3.data + +class TestProfilePhoto: + """Tests for profile photo upload, replace, and remove via DB.""" + + OLD_FILE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + def _create_user_and_old_file(self, db_session): + old_file = UploadedFileModel( + file_id=self.OLD_FILE_ID, + original_filename="old_avatar.jpg", + mime_type="image/jpeg", + file_size=100, + file_data=b"old-image-data", + ) + db_session.add(old_file) + auth = AccountModel( + account_username="photo_user", + account_email="photo@test.com", + account_password="p", + account_role="user", + avatar_file_id=self.OLD_FILE_ID, + ) + db_session.add(auth) + db_session.commit() + return auth + + def test_upload_replaces_old_avatar_and_remove_clears_integ(self, client, db_session): + auth = self._create_user_and_old_file(db_session) + client.post("/login", data={"username": "photo_user", "password": "p"}, follow_redirects=True) + + upload_resp = client.post( + "/api/profile/photo", + data={"file": (BytesIO(b"new-image-data"), "new_avatar.jpg", "image/jpeg")}, + content_type="multipart/form-data", + ) + assert upload_resp.status_code == 200 + body = upload_resp.get_json() + assert body is not None + new_file_id = body["avatar_url"].split("/")[2] + + db_session.expire_all() + updated_account = db_session.query(AccountModel).filter_by(account_id=auth.account_id).first() + assert updated_account.avatar_file_id == new_file_id + old_file = db_session.query(UploadedFileModel).filter_by(file_id=self.OLD_FILE_ID).first() + assert old_file is None + + remove_resp = client.post("/profile/photo/delete", follow_redirects=True) + assert remove_resp.status_code == 200 + assert b"Profile photo removed." in remove_resp.data + + db_session.expire_all() + updated_account = db_session.query(AccountModel).filter_by(account_id=auth.account_id).first() + assert updated_account.avatar_file_id is None + deleted_new_file = db_session.query(UploadedFileModel).filter_by(file_id=new_file_id).first() + assert deleted_new_file is None + + class TestConcurrency: """Grouped tests for high-concurrency race condition scenarios.""" From daeaa19627ab4c85fc35574f97364ccd56495bc6 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 21:10:02 +0200 Subject: [PATCH 12/15] User profile photo and access : Upgrade Flyway from 11 to 12 in `docker-compose.yml`. Replaces deprecated `flyway/flyway:11` images with `flyway/flyway:12` for both prod and test services to restore availability and keep migrations functional --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 46e7bf05..4f48d128 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: - pgdata_test:/var/lib/postgresql flyway_prod: - image: flyway/flyway:11 + image: flyway/flyway:12 container_name: flyway_prod command: > -url=jdbc:postgresql://db_prod:5432/${POSTGRES_DB} @@ -36,7 +36,7 @@ services: - db_prod flyway_test: - image: flyway/flyway:11 + image: flyway/flyway:12 container_name: flyway_test command: > -url=jdbc:postgresql://db_test:5432/${TEST_POSTGRES_DB} From c50ec2cbdfbde1d5a54c8fac5f29abafabe6e899 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 22:03:58 +0200 Subject: [PATCH 13/15] =?UTF-8?q?User=20profile=20photo=20and=20access=20:?= =?UTF-8?q?=20Unauthorized=20access=20to=20the=20article=20edit=20page=20i?= =?UTF-8?q?s=20blocked=20by=20combining=20an=20ownership=20check=20(only?= =?UTF-8?q?=20admins=20or=20the=20article=E2=80=99s=20author=20can=20open?= =?UTF-8?q?=20the=20edit=20form)=20with=20`Cache=E2=80=91Control:=20no-sto?= =?UTF-8?q?re`=20on=20all=20HTML=20pages=20to=20prevent=20cached=20edit=20?= =?UTF-8?q?views=20from=20reappearing=20via=20the=20back=20button.=20Uploa?= =?UTF-8?q?ded=20files=20keep=20immutable=20caching.=20Tests=20validate=20?= =?UTF-8?q?access=20control=20and=20caching=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flask_setup/middleware.py | 16 ++++++++---- .../flask/flask_article_adapter.py | 4 +++ .../tests_flask/test_flask_article_adapter.py | 9 +++++++ .../test_security_integration.py | 25 +++++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/flask_setup/middleware.py b/flask_setup/middleware.py index f15522db..11c170a2 100644 --- a/flask_setup/middleware.py +++ b/flask_setup/middleware.py @@ -130,12 +130,13 @@ def _add_referrer_policy(response: Response) -> Response: def _add_cache_headers(response: Response) -> Response: - """Sets Cache-Control headers for fingerprinted static assets. + """Sets Cache-Control headers based on request path. - Applies ``public, max-age=31536000, immutable`` to all responses - served from ``/static/`` (Vite dist, CSS, JS, fonts). Fingerprinted - filenames never change, so the ``immutable`` directive eliminates - revalidation even on manual reload. + Fingerprinted Vite dist assets (static/dist/) get long-term immutable + cache. Non-fingerprinted static files (CSS, JS, fonts) get no-cache + to force revalidation. Uploaded files have UUID-based URLs so they + are also cached indefinitely. All other routes (HTML pages) get + no-store to prevent the browser from caching sensitive pages. Args: response: The Flask response object to modify. @@ -151,6 +152,11 @@ def _add_cache_headers(response: Response) -> Response: response.headers["Cache-Control"] = "public, max-age=31536000, immutable" else: response.headers["Cache-Control"] = "no-cache" + elif path.startswith("/uploads/"): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + else: + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" return response diff --git a/src/infrastructure/input_adapters/flask/flask_article_adapter.py b/src/infrastructure/input_adapters/flask/flask_article_adapter.py index 65c13750..c4256b22 100644 --- a/src/infrastructure/input_adapters/flask/flask_article_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_article_adapter.py @@ -346,6 +346,10 @@ def render_edit_page(self, article_id: int) -> str | Response: flash("Error: The requested article could not be found.", "error") return redirect(url_for("article.list_articles")) + if user.account_role != "admin" and domain_article.article_author_id != user.account_id: + flash("You do not have permission to edit this article.", "error") + return redirect(url_for("article.list_articles")) + username = self.article_service.get_author_name(domain_article.article_author_id) article = ArticleResponse.from_domain(domain_article, author_username=username) return render_template("article_edit.html", article=article, current_user=user, page_with_editor=True) diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py index e378381c..b6d95f64 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py @@ -251,6 +251,15 @@ def test_user_cannot_access_edit_page(self): assert b"Insufficient permissions" in response.data assert b"alert-error" in response.data + def test_author_cannot_edit_others_article(self): + author = create_test_account(account_id=10, account_role=AccountRole.AUTHOR) + self._prepare_user_context(author) + article = create_test_article(article_id=1, article_author_id=99) + self.mock_article_repo.get_by_id.return_value = article + response = self.client.get("/articles/1/edit", follow_redirects=True) + assert b"You do not have permission" in response.data + assert b"alert-error" in response.data + def test_user_cannot_update_article(self): user = create_test_account(account_id=10, account_role=AccountRole.USER) self._prepare_user_context(user) diff --git a/tests/tests_integration/test_security_integration.py b/tests/tests_integration/test_security_integration.py index 0c46b38f..66961621 100644 --- a/tests/tests_integration/test_security_integration.py +++ b/tests/tests_integration/test_security_integration.py @@ -364,6 +364,31 @@ def test_cache_header_on_dist_path(self, app_with_db): response = _add_cache_headers(Response()) assert response.headers.get("Cache-Control") == "public, max-age=31536000, immutable" + def test_cache_no_store_on_html_pages(self, client): + """Verifies HTML page responses prevent browser caching (no-store). + + Non-static routes must set Cache-Control: no-store to prevent the + browser from serving cached pages after logout via the back button. + """ + response = client.get("/login") + assert response.status_code == 200 + assert response.headers.get("Cache-Control") == "no-store" + assert response.headers.get("Pragma") == "no-cache" + + def test_cache_immutable_on_uploaded_files(self, app_with_db): + """Verifies uploaded file paths get immutable cache (UUID-based URLs). + + Uploaded files have unique UUIDs in their URLs, so they can be + cached indefinitely. Changing the uploaded file produces a new + UUID, invalidating the old URL. + """ + with app_with_db.test_request_context(path="/uploads/some-uuid/image.jpg"): + from flask import Response + + from flask_setup.middleware import _add_cache_headers + response = _add_cache_headers(Response()) + assert response.headers.get("Cache-Control") == "public, max-age=31536000, immutable" + class TestCompression: """Tests focused on HTTP response compression (flask-compress).""" From 92eb57162b4454b2ae5ec068fac83ddecfef2958 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 22:18:19 +0200 Subject: [PATCH 14/15] =?UTF-8?q?User=20profile=20photo=20and=20access=20:?= =?UTF-8?q?=20Add=20a=20client=E2=80=91side=20search=20bar=20to=20the=20ad?= =?UTF-8?q?min=20user=20list.=20It=20filters=20table=20rows=20instantly=20?= =?UTF-8?q?by=20username=20or=20email=20(case=E2=80=91insensitive)=20using?= =?UTF-8?q?=20a=20small=20JS=20script,=20with=20no=20server=20calls.=20Tem?= =?UTF-8?q?plate=20gains=20the=20search=20input=20and=20script=20tag,=20CS?= =?UTF-8?q?S=20adds=20search=20bar=20styling,=20and=20a=20new=20`user-sear?= =?UTF-8?q?ch.js`=20handles=20real=E2=80=91time=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/css/user-list.css | 26 ++++++++++++++++++++++++++ frontend/static/scripts/user-search.js | 22 ++++++++++++++++++++++ frontend/templates/user_list.html | 4 ++++ 3 files changed, 52 insertions(+) create mode 100644 frontend/static/scripts/user-search.js diff --git a/frontend/static/css/user-list.css b/frontend/static/css/user-list.css index 7142e07b..38ad49a3 100644 --- a/frontend/static/css/user-list.css +++ b/frontend/static/css/user-list.css @@ -39,6 +39,32 @@ text-decoration: underline; } +.user-search-wrapper { + margin-bottom: var(--spacing-md); +} + +.user-search-input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--outline); + border-radius: 0.5rem; + background: var(--surface); + color: var(--on-surface); + font-family: inherit; + font-size: var(--font-body-base); + box-sizing: border-box; + outline: none; +} + +.user-search-input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent); +} + +.user-search-input::placeholder { + color: var(--on-surface-variant); +} + .user-card h1 { text-align: center; font-family: var(--font-mono); diff --git a/frontend/static/scripts/user-search.js b/frontend/static/scripts/user-search.js new file mode 100644 index 00000000..4fba14dd --- /dev/null +++ b/frontend/static/scripts/user-search.js @@ -0,0 +1,22 @@ +"use strict"; + +(() => { + const SEARCH_INPUT_ID = 'user-search'; + const TABLE_ROWS_SELECTOR = '.user-table tbody tr'; + + document.addEventListener('DOMContentLoaded', () => { + const input = document.getElementById(SEARCH_INPUT_ID); + if (!input) return; + + input.addEventListener('input', () => { + const q = input.value.toLowerCase(); + const rows = document.querySelectorAll(TABLE_ROWS_SELECTOR); + + for (let i = 0; i < rows.length; i++) { + const username = rows[i].cells[0].textContent.toLowerCase(); + const email = rows[i].cells[1].textContent.toLowerCase(); + rows[i].style.display = (username.includes(q) || email.includes(q)) ? '' : 'none'; + } + }); + }); +})(); diff --git a/frontend/templates/user_list.html b/frontend/templates/user_list.html index f1936a77..fa817dbf 100644 --- a/frontend/templates/user_list.html +++ b/frontend/templates/user_list.html @@ -9,6 +9,9 @@ {% block content %}

Manage Users

+
+ +
@@ -36,4 +39,5 @@

Manage Users

Return to home + {% endblock %} From 3609211f9b085ed230eb31fc6b0503f5fb26736e Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 15 Jul 2026 22:35:53 +0200 Subject: [PATCH 15/15] User profile photo and access : Custom error pages added for 403, 404 and 500. A single `error.html` template renders the code, message and home link, with `.error-card` styling. Flask registers three handlers via `_error_page()`. Integration tests confirm the 403 page uses the custom layout --- blog_comment_application.py | 10 +++- frontend/static/css/base.css | 48 +++++++++++++++++++ frontend/templates/error.html | 14 ++++++ .../test_security_integration.py | 7 +++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 frontend/templates/error.html diff --git a/blog_comment_application.py b/blog_comment_application.py index c13e6913..3f1a4956 100644 --- a/blog_comment_application.py +++ b/blog_comment_application.py @@ -1,7 +1,7 @@ import os from datetime import timedelta -from flask import Flask +from flask import Flask, render_template from flask_compress import Compress from sqlalchemy.orm import Session @@ -174,6 +174,11 @@ def _init_template_utils(app: Flask) -> None: app.context_processor(inject_vite_assets) +def _error_page(code: int, message: str) -> tuple[str, int]: + """Render generic error page with given HTTP status code and message.""" + return render_template("error.html", code=code, message=message), code + + def create_app(db_session=None) -> Flask: """ Bootstrap function to initialize the hexagonal application. @@ -195,6 +200,9 @@ def create_app(db_session=None) -> Flask: web_adapters = _init_web_adapters(services) register_web_routes(app, web_adapters) web_adapters["account_session_adapter"].register_before_request_handler(app) + app.errorhandler(403)(lambda e: _error_page(403, "You do not have permission to access this page.")) + app.errorhandler(404)(lambda e: _error_page(404, "The page you are looking for does not exist.")) + app.errorhandler(500)(lambda e: _error_page(500, "An unexpected error occurred. Please try again later.")) return app diff --git a/frontend/static/css/base.css b/frontend/static/css/base.css index 12de82fb..060474ec 100644 --- a/frontend/static/css/base.css +++ b/frontend/static/css/base.css @@ -575,6 +575,54 @@ hr { width: 100%; } +/* Custom error pages (403, 404, 500) */ +.error-card { + max-width: 25rem; + margin: var(--spacing-xl) auto; + text-align: center; + padding: var(--spacing-xl) var(--spacing-lg); +} + +.error-card h1 { + font-size: var(--font-headline-xl); + color: var(--error); + margin-bottom: var(--spacing-md); + line-height: 1.1; + font-family: var(--font-mono); +} + +.error-card p { + color: var(--on-surface-variant); + margin-bottom: var(--spacing-lg); + font-size: var(--font-body-base); +} + +.error-card .back-link { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + color: var(--on-surface); + font-size: var(--font-body-base); + text-decoration: none; + font-weight: 500; + transition: color 0.15s ease; +} + +.error-card .back-link:hover { + color: var(--primary); +} + +.error-card .back-link .icon { + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-headline-md); +} + +.error-card .back-link span:last-child { + transform: translateY(1.5px); +} + /* Shared card for account/profile pages */ .user-card { background: var(--surface-container); diff --git a/frontend/templates/error.html b/frontend/templates/error.html new file mode 100644 index 00000000..71b83f20 --- /dev/null +++ b/frontend/templates/error.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}{{ code }} - DevJournal{% endblock %} + +{% block content %} +
+

{{ code }}

+

{{ message }}

+ + {{ icon('home') }} + Return to home + +
+{% endblock %} diff --git a/tests/tests_integration/test_security_integration.py b/tests/tests_integration/test_security_integration.py index 66961621..fba10d2c 100644 --- a/tests/tests_integration/test_security_integration.py +++ b/tests/tests_integration/test_security_integration.py @@ -389,6 +389,13 @@ def test_cache_immutable_on_uploaded_files(self, app_with_db): response = _add_cache_headers(Response()) assert response.headers.get("Cache-Control") == "public, max-age=31536000, immutable" + def test_forbidden_returns_custom_error_page(self, client): + """Verifies 403 errors render the custom error template.""" + response = client.get("/admin/users") + assert response.status_code == 403 + assert b"You do not have permission" in response.data + assert b"Return to home" in response.data + class TestCompression: """Tests focused on HTTP response compression (flask-compress)."""