diff --git a/blog_comment_application.py b/blog_comment_application.py index 41ccfc7d..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 @@ -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"]), } @@ -171,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. @@ -192,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/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} diff --git a/flask_setup/middleware.py b/flask_setup/middleware.py index ec9aad07..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 @@ -175,6 +181,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 802ab653..8d179fab 100644 --- a/flask_setup/routes.py +++ b/flask_setup/routes.py @@ -73,12 +73,39 @@ 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") 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") + 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) + + app.add_url_rule( + "/profile/photo/delete", + view_func=acc.remove_profile_photo, + methods=["POST"], + 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: diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 3fdfa0ed..37f06767 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); } @@ -86,7 +85,25 @@ .meta-author { color: var(--on-surface); font-weight: 600; - font-style: italic; + 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 { + color: var(--primary); + text-decoration: underline; } .meta-divider { @@ -496,6 +513,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; @@ -503,6 +526,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); } @@ -559,6 +589,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 { @@ -571,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/static/css/base.css b/frontend/static/css/base.css index 73bbddf0..060474ec 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); @@ -574,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/static/css/profile.css b/frontend/static/css/profile.css index c205f479..626e1d54 100644 --- a/frontend/static/css/profile.css +++ b/frontend/static/css/profile.css @@ -20,6 +20,28 @@ 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); +} + +.remove-avatar-form { + display: inline-block; + margin-bottom: var(--spacing-sm); +} + +#upload-avatar-btn, +.remove-avatar-form .btn-sm { + min-width: 8rem; } .role-badge { @@ -60,25 +82,25 @@ font-weight: 600; } -.actions { +.logout-form { + margin: 0; + text-align: center; +} + +.profile-actions { display: flex; gap: var(--spacing-sm); + justify-content: center; 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); +@media (max-width: 22.5em) { + .profile-actions { + flex-direction: column; + } + .profile-actions .btn-secondary { + width: 100%; + } } .back-link { diff --git a/frontend/static/css/user-list.css b/frontend/static/css/user-list.css new file mode 100644 index 00000000..38ad49a3 --- /dev/null +++ b/frontend/static/css/user-list.css @@ -0,0 +1,99 @@ +.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-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); +} + +.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/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/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/article_detail.html b/frontend/templates/article_detail.html index 11cee389..dcee9ed6 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -48,7 +48,12 @@

{{ article.article_title }}

- {{ article.author_username }} + + {% if article.author_avatar_file_id %} + {{ article.author_username }} + {% endif %} + {{ article.author_username }} +
@@ -79,7 +84,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 %} @@ -87,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' %}
diff --git a/frontend/templates/article_list.html b/frontend/templates/article_list.html index fcfb868f..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/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/frontend/templates/profile.html b/frontend/templates/profile.html index 09597684..1903f0cf 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 %} @@ -11,17 +11,34 @@
- {{ user.account_username[0]|upper }} + {% if user.avatar_file_id %} + {{ user.account_username }} + {% else %} + {{ user.account_username[0]|upper }} + {% endif %}
+ {% if is_own_profile %} + + + {% if user.avatar_file_id %} + + + + + {% endif %} + {% endif %}

{{ user.account_username }}

{{ user.account_role }}
+ {% 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,19 +49,24 @@

{{ user.account_username }}

{% endif %}
+ {% endif %}
Status Active
-
- Go to Home -
+ {% if is_own_profile %} +
+ {% if current_user.account_role == 'admin' %} + Manage Users + {% endif %} +
+ {% endif %} {{ icon('home') }} @@ -52,3 +74,7 @@

{{ user.account_username }}

{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/frontend/templates/user_list.html b/frontend/templates/user_list.html new file mode 100644 index 00000000..fa817dbf --- /dev/null +++ b/frontend/templates/user_list.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}Users - DevJournal{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+

Manage Users

+
+ +
+
+ + + + + + + + + + + {% for user in users %} + + + + + + + {% endfor %} + +
UsernameEmailRoleMember Since
{{ user.account_username }}{{ user.account_email }}{{ user.account_role }}{{ user.account_created_at|date_format if user.account_created_at else 'N/A' }}
+
+ + {{ icon('home') }} + Return to home + +
+ +{% 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/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/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/input_ports/account_session_management.py b/src/application/input_ports/account_session_management.py index b604101e..1350a4d8 100644 --- a/src/application/input_ports/account_session_management.py +++ b/src/application/input_ports/account_session_management.py @@ -26,3 +26,41 @@ 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 + + @abstractmethod + def update_avatar(self, avatar_file_id: str | None) -> None: + """ + 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, 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 3e956d05..1621a9bd 100644 --- a/src/application/output_ports/account_repository.py +++ b/src/application/output_ports/account_repository.py @@ -73,3 +73,24 @@ 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 + + @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/article_service.py b/src/application/services/article_service.py index 4fc02305..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: """ @@ -304,7 +309,12 @@ 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) + 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/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/login_service.py b/src/application/services/login_service.py index 210d9d06..2885d7d9 100644 --- a/src/application/services/login_service.py +++ b/src/application/services/login_service.py @@ -80,3 +80,40 @@ 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) + + def update_avatar(self, avatar_file_id: str | None) -> None: + """ + 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, or None to clear. + """ + account = self.get_current_account() + 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/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/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/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/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/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py index 6a768e92..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,9 +1,16 @@ -from flask import flash, redirect, render_template, url_for +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 +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 +logger = logging.getLogger(__name__) + class AccountSessionAdapter(MethodView): """ @@ -17,14 +24,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.""" @@ -73,4 +86,146 @@ 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 + ), + ) + + 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 + + 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({ + "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")) + + 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/input_adapters/flask/flask_article_adapter.py b/src/infrastructure/input_adapters/flask/flask_article_adapter.py index cdad0c75..c4256b22 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 @@ -341,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/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..ae34df1a 100644 --- a/src/infrastructure/output_adapters/in_memory/account_repository.py +++ b/src/infrastructure/output_adapters/in_memory/account_repository.py @@ -80,3 +80,25 @@ 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 + + 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/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..89d7d9e1 100644 --- a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py +++ b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py @@ -153,3 +153,31 @@ 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() + + 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/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/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 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" 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..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 @@ -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", @@ -35,6 +40,39 @@ 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", + ) + + 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", + ) + + self.app.add_url_rule( + "/profile/photo/delete", + view_func=self.adapter.remove_profile_photo, + methods=["POST"], + 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 @@ -72,6 +110,149 @@ 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 + + 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") + + 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) + 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 + + 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): """ @@ -83,7 +264,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_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..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 @@ -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", @@ -250,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_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 7fbabaf5..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 @@ -119,3 +119,33 @@ 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") + + +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"} 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.""" diff --git a/tests/tests_integration/test_security_integration.py b/tests/tests_integration/test_security_integration.py index 0c46b38f..fba10d2c 100644 --- a/tests/tests_integration/test_security_integration.py +++ b/tests/tests_integration/test_security_integration.py @@ -364,6 +364,38 @@ 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" + + 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).""" 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