Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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}
Expand Down
4 changes: 4 additions & 0 deletions frontend/static/css/article.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions frontend/templates/article_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ <h1 class="item-title detail-title">{{ article.article_title }}</h1>
{% endif %}
<span class="meta-divider">•</span>
<time datetime="{{ article.article_published_at|date_iso }}">{{ article.article_published_at|date_format }}</time>
{% if article.article_edited_at %}
<span class="meta-divider">•</span>
<span class="article-edited-line">edited at {{ article.article_edited_at_formatted }}</span>
{% endif %}
</div>

<div class="detail-actions">
Expand Down
4 changes: 4 additions & 0 deletions frontend/templates/article_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ <h2 class="item-title">
{% if article.article_published_at %}
<span class="meta-divider">•</span>
<time datetime="{{ article.article_published_at|date_iso }}">{{ article.article_published_at|date_format }}</time>
{% if article.article_edited_at %}
<span class="meta-divider">•</span>
<span class="article-edited-line">edited at {{ article.article_edited_at_formatted }}</span>
{% endif %}
{% endif %}
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions migrations/V15__add_edited_at_to_articles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE articles ADD COLUMN article_edited_at TIMESTAMP;
16 changes: 3 additions & 13 deletions src/application/domain/article.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/application/services/article_service.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down
62 changes: 40 additions & 22 deletions src/infrastructure/input_adapters/dto/article_response.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -38,6 +17,9 @@ class ArticleResponse(BaseModel):
Empty string when no description was provided.
meta_description (str): Alias for article_description, used for
<meta name="description"> 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)

Expand All @@ -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,
Expand All @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
8 changes: 2 additions & 6 deletions src/infrastructure/output_adapters/dto/article_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,15 @@ 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,
article_title=self.article_title,
article_description=self.article_description,
article_content=self.article_content,
article_published_at=self.article_published_at,
article_edited_at=self.article_edited_at,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 == ""
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions tests/tests_integration/test_workflow_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 21 additions & 1 deletion tests/tests_services/test_article_service.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading