From 7496095b6fb4136ae4d48f2259d69549fc033bf9 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 21 Jul 2026 21:55:00 +0200 Subject: [PATCH] =?UTF-8?q?Update=20article=20date=20:=20Add=20article=20e?= =?UTF-8?q?dit=E2=80=91tracking=20with=20`article=5Fedited=5Fat`.=20Each?= =?UTF-8?q?=20update=20now=20records=20an=20UTC=20timestamp,=20exposed=20t?= =?UTF-8?q?hrough=20the=20domain,=20adapters=20and=20API,=20formatted=20to?= =?UTF-8?q?=20full=20Europe/Paris=20local=20time=20in=20responses.=20Detai?= =?UTF-8?q?l=20and=20list=20pages=20display=20an=20=E2=80=9Cedited=20at=20?= =?UTF-8?q?=E2=80=A6=E2=80=9D=20indicator,=20CSS=20adds=20the=20edited?= =?UTF-8?q?=E2=80=91line=20style,=20and=20all=20affected=20tests=20validat?= =?UTF-8?q?e=20the=20new=20field=20end=E2=80=91to=E2=80=91end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 4 +- frontend/static/css/article.css | 4 ++ frontend/templates/article_detail.html | 4 ++ frontend/templates/article_list.html | 4 ++ migrations/V15__add_edited_at_to_articles.sql | 1 + src/application/domain/article.py | 16 +---- src/application/services/article_service.py | 2 + .../input_adapters/dto/article_response.py | 62 ++++++++++++------- .../flask/flask_article_adapter.py | 1 + .../output_adapters/dto/article_record.py | 8 +-- .../models/sqlalchemy_article_model.py | 1 + .../sqlalchemy/sqlalchemy_article_adapter.py | 2 + .../dto/test_article_response.py | 24 ++++++- .../tests_flask/test_flask_article_adapter.py | 2 + .../dto/test_article_record.py | 1 + .../test_workflow_integration.py | 39 ++++++++++++ tests/tests_services/test_article_service.py | 22 ++++++- 17 files changed, 152 insertions(+), 45 deletions(-) create mode 100644 migrations/V15__add_edited_at_to_articles.sql diff --git a/docker-compose.yml b/docker-compose.yml index 4f48d12..6eafe4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: - pgdata_test:/var/lib/postgresql flyway_prod: - image: flyway/flyway:12 + image: flyway/flyway:13 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:12 + image: flyway/flyway:13 container_name: flyway_test command: > -url=jdbc:postgresql://db_test:5432/${TEST_POSTGRES_DB} diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 2835f0e..00af3cd 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -861,6 +861,10 @@ a.comment-author:hover { margin-top: var(--spacing-xs); } +.article-edited-line { + color: var(--on-surface-variant); +} + .comment-empty { color: var(--on-surface-variant); font-size: var(--font-body-base); diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 66829dd..4a2db57 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -67,6 +67,10 @@

{{ article.article_title }}

{% endif %} + {% if article.article_edited_at %} + + edited at {{ article.article_edited_at_formatted }} + {% endif %}
diff --git a/frontend/templates/article_list.html b/frontend/templates/article_list.html index d631c20..d94564d 100644 --- a/frontend/templates/article_list.html +++ b/frontend/templates/article_list.html @@ -83,6 +83,10 @@

{% if article.article_published_at %} + {% if article.article_edited_at %} + + edited at {{ article.article_edited_at_formatted }} + {% endif %} {% endif %}

diff --git a/migrations/V15__add_edited_at_to_articles.sql b/migrations/V15__add_edited_at_to_articles.sql new file mode 100644 index 0000000..ba62434 --- /dev/null +++ b/migrations/V15__add_edited_at_to_articles.sql @@ -0,0 +1 @@ +ALTER TABLE articles ADD COLUMN article_edited_at TIMESTAMP; diff --git a/src/application/domain/article.py b/src/application/domain/article.py index 5e9f402..086258a 100644 --- a/src/application/domain/article.py +++ b/src/application/domain/article.py @@ -16,6 +16,7 @@ class Article: article_description (str): Short description shown in article list. article_content (str): Full text content of the article. article_published_at (datetime): Timestamp of publication. + article_edited_at (datetime | None): Timestamp of last edit. None if never edited. """ def __init__( @@ -26,26 +27,15 @@ def __init__( article_content: str, article_published_at: datetime | None, article_description: str = "", + article_edited_at: datetime | None = None, ): - """ - Initialize a blog article. - - Args: - article_id (int): Unique identifier for the article. - article_author_id (int | None): Reference to the author's Account. - None when the author's account has been deleted. - article_title (str): Title of the article. - article_content (str): Full text content of the article. - article_published_at (datetime): Timestamp of publication. - article_description (str): Short description displayed in article - list. Optional, defaults to empty string. - """ self.article_id = article_id self.article_author_id = article_author_id self.article_title = article_title self.article_description = article_description self.article_content = article_content self.article_published_at = article_published_at + self.article_edited_at = article_edited_at @dataclass class ArticleWithAuthor: diff --git a/src/application/services/article_service.py b/src/application/services/article_service.py index 3bed999..0660442 100644 --- a/src/application/services/article_service.py +++ b/src/application/services/article_service.py @@ -1,5 +1,6 @@ import json import re +from datetime import UTC, datetime from src.application.domain.account import Account, AccountRole from src.application.domain.article import Article, ArticleDetailView, ArticleWithAuthor @@ -205,6 +206,7 @@ def update_article(self, article_id: int, user_id: int, title: str, content: str article.article_title = title article.article_description = description article.article_content = content + article.article_edited_at = datetime.now(UTC) self.article_repository.save(article) if self.file_service: diff --git a/src/infrastructure/input_adapters/dto/article_response.py b/src/infrastructure/input_adapters/dto/article_response.py index 11b9d35..9a62b92 100644 --- a/src/infrastructure/input_adapters/dto/article_response.py +++ b/src/infrastructure/input_adapters/dto/article_response.py @@ -1,30 +1,9 @@ -import json from datetime import datetime +from zoneinfo import ZoneInfo from pydantic import BaseModel, ConfigDict -def _blocks_to_plain_text(blocks_json: str) -> str: - try: - blocks = json.loads(blocks_json) - except (json.JSONDecodeError, TypeError): - return blocks_json - - if not isinstance(blocks, list): - return blocks_json - - texts = [] - for block in blocks: - content = block.get("content") - if isinstance(content, list): - for node in content: - if isinstance(node, dict) and "text" in node: - texts.append(node["text"]) - elif isinstance(content, str): - texts.append(content) - return " ".join(texts) - - class ArticleResponse(BaseModel): """ Data Transfer Object used to send article data to the UI. @@ -38,6 +17,9 @@ class ArticleResponse(BaseModel): Empty string when no description was provided. meta_description (str): Alias for article_description, used for and list view excerpt. + article_edited_at (datetime | None): Last edit timestamp. None if never edited. + article_edited_at_formatted (str): Human-readable edit time in Europe/Paris. + Empty string if never edited. """ model_config = ConfigDict(from_attributes=True) @@ -50,11 +32,45 @@ class ArticleResponse(BaseModel): article_content: str article_published_at: datetime | None = None meta_description: str = "" + article_edited_at: datetime | None = None + article_edited_at_formatted: str = "" + + @staticmethod + def _to_local_full(dt: datetime) -> str: + """Convert a UTC datetime to a Europe/Paris formatted string for display. + + Args: + dt (datetime): The datetime to convert (assumed UTC; naive treated as UTC). + + Returns: + str: Formatted string like "January 27, 2023 at 13:00". + """ + if dt.tzinfo is None: + dt = dt.replace(tzinfo=ZoneInfo("UTC")) + local = dt.astimezone(ZoneInfo("Europe/Paris")) + return local.strftime("%B %d, %Y at %H:%M") @classmethod def from_domain(cls, article, author_username: str = "Unknown", author_avatar_file_id: str | None = None): + """Build an ArticleResponse from a domain Article entity. + + Maps all fields from the domain object into the DTO. Formats + article_edited_at into a human-readable Europe/Paris string. + + Args: + article: The domain Article instance to convert. + author_username (str): The author's display name. Defaults to "Unknown". + author_avatar_file_id (str | None): UUID of the author's avatar file. + + Returns: + ArticleResponse: The populated response DTO. + """ description = article.article_description or "" + article_edited_at_formatted = "" + if article.article_edited_at: + article_edited_at_formatted = cls._to_local_full(article.article_edited_at) + return cls( article_id=article.article_id, article_author_id=article.article_author_id, @@ -65,4 +81,6 @@ def from_domain(cls, article, author_username: str = "Unknown", author_avatar_fi article_content=article.article_content, article_published_at=article.article_published_at, meta_description=description, + article_edited_at=article.article_edited_at, + article_edited_at_formatted=article_edited_at_formatted, ) diff --git a/src/infrastructure/input_adapters/flask/flask_article_adapter.py b/src/infrastructure/input_adapters/flask/flask_article_adapter.py index 74702d2..3968af8 100644 --- a/src/infrastructure/input_adapters/flask/flask_article_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_article_adapter.py @@ -197,6 +197,7 @@ def api_get_article(self, article_id: int) -> Response | tuple[Response, int]: "author_id": article.article_author_id, "author_username": username, "published_at": article.article_published_at, + "article_edited_at": article.article_edited_at, }) def api_create_article(self) -> Response | tuple[Response, int]: diff --git a/src/infrastructure/output_adapters/dto/article_record.py b/src/infrastructure/output_adapters/dto/article_record.py index 1a88493..0b745fb 100644 --- a/src/infrastructure/output_adapters/dto/article_record.py +++ b/src/infrastructure/output_adapters/dto/article_record.py @@ -24,14 +24,9 @@ class ArticleRecord(BaseModel): article_description: str = "" article_content: str article_published_at: datetime | None = None + article_edited_at: datetime | None = None def to_domain(self) -> Article: - """ - Converts the database record into a domain Article entity. - - Returns: - Article: The corresponding domain entity. - """ return Article( article_id=self.article_id, article_author_id=self.article_author_id, @@ -39,4 +34,5 @@ def to_domain(self) -> Article: article_description=self.article_description, article_content=self.article_content, article_published_at=self.article_published_at, + article_edited_at=self.article_edited_at, ) diff --git a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_article_model.py b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_article_model.py index 64a52c0..8236ad5 100644 --- a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_article_model.py +++ b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_article_model.py @@ -29,3 +29,4 @@ class ArticleModel(SqlAlchemyModel): ) article_content: Mapped[str] = mapped_column(Text, nullable=False) article_published_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now()) + article_edited_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True) diff --git a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_article_adapter.py b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_article_adapter.py index a79f330..492ed72 100644 --- a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_article_adapter.py +++ b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_article_adapter.py @@ -83,6 +83,7 @@ def save(self, article: Article) -> None: ArticleModel.article_title: article.article_title, ArticleModel.article_description: article.article_description, ArticleModel.article_content: article.article_content, + ArticleModel.article_edited_at: article.article_edited_at, }) self._session.commit() return @@ -92,6 +93,7 @@ def save(self, article: Article) -> None: model.article_title = article.article_title model.article_description = article.article_description model.article_content = article.article_content + model.article_edited_at = article.article_edited_at self._session.add(model) self._session.commit() article.article_id = model.article_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 277dba4..761d012 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 @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import UTC, datetime from src.application.domain.article import Article from src.infrastructure.input_adapters.dto.article_response import ArticleResponse @@ -72,3 +72,25 @@ def test_from_domain_with_description(self): result = ArticleResponse.from_domain(domain_article, "yoda") assert result.article_description == "My short description" assert result.meta_description == "My short description" + + def test_from_domain_with_article_edited_at(self): + """article_edited_at formaté en Europe/Paris (UTC+1 en janvier).""" + dt = datetime(2023, 1, 27, 12, 0, 0, tzinfo=UTC) + domain_article = Article( + article_id=1, article_author_id=10, article_title="Title", + article_content="Content", article_published_at=datetime.now(), + article_edited_at=dt, + ) + response = ArticleResponse.from_domain(domain_article, "user") + assert response.article_edited_at == dt + assert response.article_edited_at_formatted == "January 27, 2023 at 13:00" + + def test_from_domain_without_article_edited_at(self): + """article_edited_at_formatted vide si jamais édité.""" + domain_article = Article( + article_id=1, article_author_id=10, article_title="Title", + article_content="Content", article_published_at=datetime.now(), + ) + response = ArticleResponse.from_domain(domain_article, "user") + assert response.article_edited_at is None + assert response.article_edited_at_formatted == "" 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 b6d95f6..5b540be 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 @@ -444,6 +444,8 @@ def test_api_get_article_blocknote_content_passes_through(self): assert response.status_code == 200 data = response.get_json() assert data["content"] == bn_content + assert "article_edited_at" in data + assert data["article_edited_at"] is None class TestArticlePagination(ArticleAdapterTestBase): def test_pagination_multiple_pages(self): diff --git a/tests/tests_infrastructure/tests_output_adapters/dto/test_article_record.py b/tests/tests_infrastructure/tests_output_adapters/dto/test_article_record.py index 8824253..cfb3f2d 100644 --- a/tests/tests_infrastructure/tests_output_adapters/dto/test_article_record.py +++ b/tests/tests_infrastructure/tests_output_adapters/dto/test_article_record.py @@ -13,6 +13,7 @@ def __init__(self) -> None: self.article_title = "ORM Title" self.article_content = "ORM Content" self.article_published_at = datetime(2023, 1, 1, 12, 0, 0) + self.article_edited_at = None class TestArticleRecordCreation: diff --git a/tests/tests_integration/test_workflow_integration.py b/tests/tests_integration/test_workflow_integration.py index 0bd8f42..48ba475 100644 --- a/tests/tests_integration/test_workflow_integration.py +++ b/tests/tests_integration/test_workflow_integration.py @@ -613,3 +613,42 @@ def test_comment_hard_delete_integration(self, client, db_session): db_session.expire_all() assert db_session.get(CommentModel, cid) is None assert b"Comment permanently deleted" in resp.data + + +class TestArticleEditEditedAtIntegration: + def test_article_edit_sets_edited_at_integration(self, client, db_session): + """Création → édition → edited_at persiste en base. + + Vérifie le round-trip complet : après édition d'un article, + recharger depuis la DB donne un edited_at non-None. + """ + author = AccountModel( + account_username="edit_test_author", account_email="eta@t.com", + account_password="p", account_role="author", + ) + db_session.add(author) + db_session.commit() + + article = ArticleModel( + article_title="Original Title", article_content="Original Content", + article_author_id=author.account_id, + ) + db_session.add(article) + db_session.commit() + + art_id = article.article_id + assert article.article_edited_at is None + + client.post("/login", data={"username": "edit_test_author", "password": "p"}) + + resp = client.put(f"/api/articles/{art_id}", json={ + "title": "Updated Title", + "content": "Updated Content", + }) + assert resp.status_code == 200 + assert resp.get_json() == {"ok": True} + + db_session.expire_all() + updated = db_session.get(ArticleModel, art_id) + assert updated.article_title == "Updated Title" + assert updated.article_edited_at is not None diff --git a/tests/tests_services/test_article_service.py b/tests/tests_services/test_article_service.py index 2455c57..0052b4f 100644 --- a/tests/tests_services/test_article_service.py +++ b/tests/tests_services/test_article_service.py @@ -1,5 +1,5 @@ import json -from datetime import datetime +from datetime import UTC, datetime from unittest.mock import MagicMock from src.application.domain.account import AccountRole @@ -561,3 +561,23 @@ def test_update_article_no_file_service_does_not_crash(self): assert isinstance(result, Article) self.mock_article_repo.save.assert_called_once() + + def test_update_article_sets_article_edited_at(self): + """update_article() set article_edited_at=now(UTC) avant save. + + L'article doit avoir article_edited_at défini après mise à jour, + pas None comme initialement. + """ + fake_article = create_test_article(article_id=1, article_author_id=1) + fake_article.article_edited_at = None + self.mock_article_repo.get_by_id.return_value = fake_article + fake_account = create_test_account(account_id=1, account_role=AccountRole.AUTHOR) + self.mock_account_repo.get_by_id.return_value = fake_account + + result = self.service.update_article( + article_id=1, user_id=1, title="New", content="New Content", + ) + + assert isinstance(result, Article) + assert result.article_edited_at is not None + assert (datetime.now(UTC) - result.article_edited_at).total_seconds() < 5