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