diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 00000000..759e805a --- /dev/null +++ b/babel.cfg @@ -0,0 +1,2 @@ +[python: **.py] +[jinja2: **/templates/**.html] diff --git a/blog_comment_application.py b/blog_comment_application.py index dbcbfeb7..9eabe941 100644 --- a/blog_comment_application.py +++ b/blog_comment_application.py @@ -1,7 +1,10 @@ +import glob import os from datetime import timedelta -from flask import Flask, render_template +from flask import Flask, render_template, session +from flask_babel import Babel +from flask_babel import gettext as _ from flask_compress import Compress from sqlalchemy.orm import Session @@ -29,8 +32,8 @@ from utils.prosemirror_to_html import prosemirror_to_html from utils.template_helpers import ( ViteManifest, - date_format_filter, date_iso_filter, + format_datetime_locale, inject_current_year, inject_vite_assets, nl2br_filter, @@ -168,9 +171,9 @@ def _init_template_utils(app: Flask) -> None: ViteManifest.init(os.path.join(app.static_folder or "", "dist")) app.jinja_env.filters["nl2br"] = nl2br_filter - app.jinja_env.filters["date_format"] = date_format_filter app.jinja_env.filters["date_iso"] = date_iso_filter app.jinja_env.filters["prosemirror_to_html"] = prosemirror_to_html + app.jinja_env.filters["format_datetime_locale"] = format_datetime_locale app.context_processor(inject_current_year) app.context_processor(inject_vite_assets) @@ -196,17 +199,26 @@ def create_app(db_session=None) -> Flask: services = _create_services(repositories) app = _init_web_facade_flask() Compress(app) + Babel(app, locale_selector=lambda: session.get("lang", "fr")) + + @app.context_processor + def inject_get_locale(): + return {"get_locale": lambda: session.get("lang", "fr")} + init_web_security(app) _init_template_utils(app) 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.")) + 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 if __name__ == "__main__": application = create_app() - application.run(debug=os.getenv("FLASK_DEBUG", "false").lower() == "true") + application.run( + debug=os.getenv("FLASK_DEBUG", "false").lower() == "true", + extra_files=glob.glob("translations/**/*.mo", recursive=True), + ) diff --git a/flask_setup/routes.py b/flask_setup/routes.py index f74819c5..5bfc5591 100644 --- a/flask_setup/routes.py +++ b/flask_setup/routes.py @@ -161,6 +161,13 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None: endpoint="auth.unban_account", ) + app.add_url_rule( + "/lang/", + view_func=acc.set_lang, + methods=["POST"], + endpoint="auth.set_lang", + ) + def _register_file_routes(app: Flask, adapters: dict) -> None: fad = adapters["file_adapter"] diff --git a/frontend/core/__tests__/code-copy.test.js b/frontend/core/__tests__/code-copy.test.js index 622fa1ae..29644659 100644 --- a/frontend/core/__tests__/code-copy.test.js +++ b/frontend/core/__tests__/code-copy.test.js @@ -32,7 +32,6 @@ function createDOM(lang) { } describe('code-copy.js', () => { - let mockWrite; let mockWriteText; beforeAll(() => { @@ -42,13 +41,17 @@ describe('code-copy.js', () => { beforeEach(() => { document.body.innerHTML = ''; + document.execCommand = vi.fn().mockImplementation(() => { + const dt = new DataTransfer(); + const ev = new ClipboardEvent('copy', { clipboardData: dt }); + document.dispatchEvent(ev); + return true; + }); vi.useFakeTimers(); - mockWrite = vi.fn().mockResolvedValue(undefined); mockWriteText = vi.fn().mockResolvedValue(undefined); vi.stubGlobal('navigator', { - clipboard: { write: mockWrite, writeText: mockWriteText }, + clipboard: { writeText: mockWriteText }, }); - vi.stubGlobal('ClipboardItem', undefined); }); afterEach(() => { @@ -84,24 +87,20 @@ describe('code-copy.js', () => { expect(document.querySelector('.toast')).toBeNull(); }); - it('writes text/plain via writeText as fallback when ClipboardItem absent', () => { + it('writes text/plain via custom copy event handler', () => { const { btn } = createDOM(); btn.click(); - expect(mockWriteText).toHaveBeenCalledWith('print("hello")'); - expect(mockWrite).not.toHaveBeenCalled(); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(document.querySelector('.toast')).not.toBeNull(); }); - it('writes text/html + text/plain via ClipboardItem when available', () => { - const mockClipboardItem = vi.fn(); - vi.stubGlobal('ClipboardItem', mockClipboardItem); - + it('writes text/html + text/plain via custom copy event', () => { const { btn } = createDOM('python'); btn.click(); - expect(mockClipboardItem).toHaveBeenCalledOnce(); - expect(mockWrite).toHaveBeenCalledOnce(); - expect(mockWriteText).not.toHaveBeenCalled(); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(document.querySelector('.toast')).not.toBeNull(); }); it('replaces old toast on rapid clicks', () => { @@ -113,12 +112,12 @@ describe('code-copy.js', () => { expect(toasts.length).toBe(1); }); - it('does nothing when code block is empty', () => { + it('shows toast even for empty code block (copied zero-width space)', () => { const { btn, code } = createDOM('python'); code.textContent = ''; btn.click(); - expect(document.querySelector('.toast')).toBeNull(); - expect(mockWriteText).not.toHaveBeenCalled(); + expect(document.querySelector('.toast')).not.toBeNull(); + expect(document.execCommand).toHaveBeenCalledWith('copy'); }); }); diff --git a/frontend/core/__tests__/i18n.test.js b/frontend/core/__tests__/i18n.test.js new file mode 100644 index 00000000..b432078a --- /dev/null +++ b/frontend/core/__tests__/i18n.test.js @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { _ } from '../utils/i18n.js'; + +function setTranslations(dict) { + const el = document.createElement('script'); + el.id = 'app-translations'; + el.type = 'application/json'; + el.textContent = JSON.stringify(dict); + document.body.appendChild(el); +} + +describe('i18n _()', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('returns translation when key exists', () => { + setTranslations({ hello: 'bonjour', bye: 'au revoir' }); + expect(_('hello')).toBe('bonjour'); + expect(_('bye')).toBe('au revoir'); + }); + + it('returns key when key not in dictionary', () => { + setTranslations({ hello: 'bonjour' }); + expect(_('missing')).toBe('missing'); + }); + + it('returns key when no translations element exists', () => { + expect(document.getElementById('app-translations')).toBeNull(); + expect(_('hello')).toBe('hello'); + }); + + it('returns key when translations JSON is malformed', () => { + const el = document.createElement('script'); + el.id = 'app-translations'; + el.type = 'application/json'; + el.textContent = '{broken json}'; + document.body.appendChild(el); + expect(_('hello')).toBe('hello'); + }); + + it('returns key when dictionary is empty', () => { + setTranslations({}); + expect(_('anything')).toBe('anything'); + }); +}); diff --git a/frontend/core/components/ArticleForm.jsx b/frontend/core/components/ArticleForm.jsx index 6c4068ef..1ae1c80c 100644 --- a/frontend/core/components/ArticleForm.jsx +++ b/frontend/core/components/ArticleForm.jsx @@ -11,19 +11,21 @@ import createHighlighter from '../utils/shiki-highlighter-editor'; import SUPPORTED_LANGUAGES from '../utils/supported-languages'; import { createCustomCodeBlockSpec } from '../utils/custom-code-block-spec'; import { createYouTubeVideoSpec } from '../utils/video-override-spec'; +import { fr } from '@blocknote/core/locales'; +import { _ } from '../utils/i18n'; export function applyVideoDictOverrides(editor) { - editor.dictionary.slash_menu.video.title = 'YouTube'; - editor.dictionary.slash_menu.video.subtext = 'Paste a YouTube video URL'; + editor.dictionary.slash_menu.video.title = _('YouTube'); + editor.dictionary.slash_menu.video.subtext = _('Paste a YouTube video URL'); editor.dictionary.slash_menu.video.aliases = [ 'youtube', 'yt', 'video', 'videoUpload', 'upload', 'film', 'media', 'url', ]; - editor.dictionary.file_panel.embed.title = 'YouTube URL'; - editor.dictionary.file_panel.embed.url_placeholder = 'Paste YouTube video link'; - editor.dictionary.file_panel.embed.embed_button.video = 'Embed YouTube video'; - editor.dictionary.file_blocks.add_button_text.video = 'Add YouTube video URL'; + editor.dictionary.file_panel.embed.title = _('YouTube URL'); + editor.dictionary.file_panel.embed.url_placeholder = _('Paste YouTube video link'); + editor.dictionary.file_panel.embed.embed_button.video = _('Embed YouTube video'); + editor.dictionary.file_blocks.add_button_text.video = _('Add YouTube video URL'); } function CustomFilePanel({ blockId }) { @@ -80,7 +82,7 @@ function BlockNoteEditor({ initialContent, onReady }) { const res = await fetch('/api/upload/image', { method: 'POST', body: formData }); if (!res.ok) { const err = await res.json(); - throw new Error(err.error || 'Upload failed.'); + throw new Error(err.error || _('Upload failed.')); } const data = await res.json(); return data.url; @@ -89,6 +91,7 @@ function BlockNoteEditor({ initialContent, onReady }) { const { audio: _a, file: _f, video: defaultVideoSpec, ...keptSpecs } = defaultBlockSpecs; const editor = useCreateBlockNote({ + dictionary: fr, initialContent, uploadFile: uploadFn, disableExtensions: ['gapCursor'], @@ -117,7 +120,7 @@ function BlockNoteEditor({ initialContent, onReady }) { '[data-content-type="video"] .bn-add-file-button-text', ).forEach((el) => { if (el.textContent === 'Add video') { - el.textContent = 'Add YouTube video URL'; + el.textContent = _('Add YouTube video URL'); } }); } @@ -372,7 +375,7 @@ export default function ArticleForm() { const handleSubmit = async () => { if (!title.trim()) { - setError('Title is required.'); + setError(_('Title is required.')); return; } setSaving(true); @@ -394,17 +397,17 @@ export default function ArticleForm() { window.location.href = `/articles/${data.id || articleId}`; } else { const err = await res.json(); - setError(err.error || 'Failed to save.'); + setError(err.error || _('Failed to save.')); setSaving(false); } } catch { - setError('Network error.'); + setError(_('Network error.')); setSaving(false); } }; if (!loaded) { - return
Loading...
; + return
{_('Loading...')}
; } const displayError = error || loadError; @@ -413,7 +416,7 @@ export default function ArticleForm() { try { initialContent = contentStr ? JSON.parse(contentStr) : undefined; } catch { - return
Unable to parse article content.
; + return
{_('Unable to parse article content.')}
; } return ( @@ -422,22 +425,22 @@ export default function ArticleForm() { setTitle(e.target.value)} onClick={handleDoubleTapSelect} />
- Description + {_('Description')}
- Maximum 300 characters + {_('Maximum 300 characters')} {description.length}/300
-
- - -
- -
+
{{ _('edited at %(date)s', date=node.comment.edited_at|format_datetime_locale) }}
{% endif %} + {% if current_user and not is_deleted %}
{% if depth < 3 %} - + {% endif %} {% if node.replies %} - + {% endif %}
- {% if depth < 3 %} - - {% endif %} + {% elif node.replies %}
- +
{% endif %} @@ -200,33 +177,64 @@

{{ article.article_title }}

{% endmacro %}
-

Comments ({{ comment_count }})

+

{{ _('Comments (%(count)s)', count=comment_count) }}

{% for node in nested_comments %} {{ render_comment(node, 0) }} {% else %} -

No comments yet.

+

{{ _('No comments yet.') }}

{% endfor %} {% if current_user %}
-

Join the discussion

- Comment must be between 1 and 5000 characters -
+

{{ _('Join the discussion') }}

+ {{ _('Comment must be between 1 and 5000 characters') }} + - - + +
+ + +
{% else %}

- Please sign in to join the conversation. + {% set sign_in_url = url_for('auth.login') %} + {{ _('Please sign in to join the conversation.', url=sign_in_url) | safe }}

{% endif %}
+{% block translations %} + +{% endblock %} + {% block extra_js %} {% endblock %} diff --git a/frontend/templates/article_edit.html b/frontend/templates/article_edit.html index 9ecdaf18..e288c9a4 100644 --- a/frontend/templates/article_edit.html +++ b/frontend/templates/article_edit.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Edit Article - DevJournal{% endblock %} +{% block title %}{{ _('Edit Article - DevJournal') }}{% endblock %} {% from "macros/icons.html" import icon %} {% block extra_head %} @@ -37,7 +37,7 @@
{{ icon('arrow_back') }} - Back to articles + {{ _('Back to articles') }}
@@ -51,6 +51,45 @@ {% endblock %} +{% block translations %} + +{% endblock %} + {% block extra_js %} {% endblock %} diff --git a/frontend/templates/article_list.html b/frontend/templates/article_list.html index d94564da..58e70f98 100644 --- a/frontend/templates/article_list.html +++ b/frontend/templates/article_list.html @@ -2,9 +2,9 @@ {% from "macros/pagination.html" import render_pagination_info %} {% extends "base.html" %} -{% block title %}Latest Articles - DevJournal{% endblock %} +{% block title %}{{ _('Latest Articles - DevJournal') }}{% endblock %} {% block extra_head %} - + {% if vite_vendor_js_url %} {% endif %} @@ -23,12 +23,12 @@ {% block content %}
- {% if query %} × {% endif %} - +
@@ -37,7 +37,7 @@
{{ icon('arrow_back') }} - Previous + {{ _('Previous') }}
@@ -45,7 +45,7 @@
- Next + {{ _('Next') }} {{ icon('arrow_forward') }}
@@ -66,8 +66,8 @@

{% endif %} @@ -94,7 +94,7 @@

{% else %}
{{ icon('folder_open', 'empty-state-icon') }} -

{% if query %}No articles match "{{ query }}".{% else %}No articles found in the database.{% endif %}

+

{% if query %}{{ _('No articles match "%(query)s".', query=query) }}{% else %}{{ _('No articles found in the database.') }}{% endif %}

{% endfor %} @@ -104,7 +104,7 @@

@@ -112,7 +112,7 @@

@@ -121,18 +121,18 @@

diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 5ed5f2a9..0fce7323 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -40,43 +40,50 @@ DJ {% endblock %} diff --git a/frontend/templates/login.html b/frontend/templates/login.html index 540bf3be..7ba11457 100644 --- a/frontend/templates/login.html +++ b/frontend/templates/login.html @@ -1,7 +1,7 @@ {% from "macros/icons.html" import icon %} {% extends "base.html" %} -{% block title %}Sign In - DevJournal{% endblock %} +{% block title %}{{ _('Sign In - DevJournal') }}{% endblock %} {% block extra_css %} @@ -10,36 +10,36 @@ {% block content %} {% if is_own_profile %} - + {% if user.avatar_file_id %}
- +
{% endif %} {% endif %}

{{ user.account_username }}

- {{ user.account_role }} + {{ _(user.account_role) }} {% if current_user and current_user.account_role == 'admin' and not is_own_profile and user.account_role != 'admin' %}
- +
{% endif %}
@@ -46,11 +46,11 @@

{{ user.account_username }}

{% if is_own_profile or (current_user and current_user.account_role == 'admin') %}
- Email + {{ _('Email') }} {% if is_own_profile %}
{{ user.account_email }} - +
{% else %} {{ user.account_email }} @@ -59,61 +59,61 @@

{{ user.account_username }}

{% endif %} {% if is_own_profile or (current_user and current_user.account_role == 'admin') %}
- Member Since + {{ _('Member Since') }} {% if user.account_created_at %} - {{ user.account_created_at|date_format }} + {{ user.account_created_at|format_datetime_locale("d MMMM yyyy") }} {% else %} - N/A + {{ _('N/A') }} {% endif %}
{% endif %}
- Status - {% if user.is_banned %}Banned{% if user.ban_reason %}: {{ user.ban_reason }}{% endif %}{% else %}Active{% endif %} + {{ _('Status') }} + {% if user.is_banned %}{{ _('Banned') }}{% if user.ban_reason %}: {{ user.ban_reason }}{% endif %}{% else %}{{ _('Active') }}{% endif %}
{% if is_own_profile %}
@@ -121,19 +121,19 @@ {% if is_own_profile %}
{% if current_user.account_role == 'admin' %} - Manage Users + {{ _('Manage Users') }} {% endif %} - +
- +
{% if current_user.account_role != 'admin' %}
+ data-confirm-message="{{ _('Are you sure you want to delete your account ? This action is irreversible.') }}">{{ _('Delete Account') }}
{% endif %}
@@ -144,26 +144,26 @@ {% if user.is_banned %}
- +
{% else %} + data-username="{{ user.account_username }}">{{ _('Ban') }} {% endif %}
+ data-confirm-message="{{ _('Delete user \\\"%(username)s\\\" ? This action is irreversible.', username=user.account_username) }}">{{ _('Delete Account') }}
{% endif %} {{ icon('home') }} - Return to home + {{ _('Return to home') }} {% endblock %} diff --git a/frontend/templates/registration.html b/frontend/templates/registration.html index e2ddaace..104cfb18 100644 --- a/frontend/templates/registration.html +++ b/frontend/templates/registration.html @@ -1,7 +1,7 @@ {% from "macros/icons.html" import icon %} {% extends "base.html" %} -{% block title %}Sign Up - DevJournal{% endblock %} +{% block title %}{{ _('Sign Up - DevJournal') }}{% endblock %} {% block extra_css %} @@ -10,47 +10,47 @@ {% block content %}
- - + +
- - + +
- +
- - + +
- +

diff --git a/frontend/templates/user_list.html b/frontend/templates/user_list.html index 5bf9bcc3..d513c846 100644 --- a/frontend/templates/user_list.html +++ b/frontend/templates/user_list.html @@ -1,7 +1,7 @@ {% from "macros/pagination.html" import render_pagination_info %} {% extends "base.html" %} -{% block title %}Users - DevJournal{% endblock %} +{% block title %}{{ _('Users') }} - DevJournal{% endblock %} {% block extra_css %} @@ -13,16 +13,16 @@ {% block content %}
-

Manage Users ({{ total_count }} users)

+

{{ _('Manage Users (%(count)d users)', count=total_count) }}

{% if query %} × {% endif %} - +
{{ render_pagination_info(page, total_pages, 'auth.list_all_users', query) }} @@ -46,13 +46,13 @@

Manage Users ({{ total_count }} users)

- - - - - - - + + + + + + + @@ -60,10 +60,10 @@

Manage Users ({{ total_count }} users)

- - - - + + + +
UsernameEmailRoleStatusReasonMember SinceActions{{ _('Username') }}{{ _('Email') }}{{ _('Role') }}{{ _('Status') }}{{ _('Reason') }}{{ _('Member Since') }}{{ _('Actions') }}
{{ user.account_username }} {{ user.account_email }}{{ user.account_role }}{{ "Banned" if user.is_banned else "Active" }}{{ user.ban_reason or '—' }}{{ user.account_created_at|date_format if user.account_created_at else 'N/A' }}{{ _(user.account_role) }}{{ _('Banned') if user.is_banned else _('Active') }}{{ user.ban_reason or _('—') }}{{ user.account_created_at|format_datetime_locale("d MMMM yyyy") if user.account_created_at else _('N/A') }} {% if user.account_role == 'admin' %} @@ -72,27 +72,27 @@

Manage Users ({{ total_count }} users)

- +
{% if user.is_banned %}
- +
{% else %} + data-username="{{ user.account_username }}">{{ _('Ban') }} {% endif %}
+ data-confirm-message="{{ _('Delete user \\\"%(username)s\\\" ? This action is irreversible.', username=user.account_username) }}">{{ _('Delete') }}
{% endif %} @@ -107,32 +107,32 @@

Manage Users ({{ total_count }} users)

{{ icon('arrow_back') }} - Previous + {{ _('Previous') }} {{ render_pagination_info(page, total_pages, 'auth.list_all_users', query) }} {{ icon('home') }} - Return to home + {{ _('Return to home') }} {% endblock %} diff --git a/migrations/V16__cascade_comment_reply_fk.sql b/migrations/V16__cascade_comment_reply_fk.sql new file mode 100644 index 00000000..cf0ff628 --- /dev/null +++ b/migrations/V16__cascade_comment_reply_fk.sql @@ -0,0 +1,4 @@ +ALTER TABLE comments DROP CONSTRAINT comments_reply_fk; +ALTER TABLE comments ADD CONSTRAINT comments_reply_fk + FOREIGN KEY (comment_reply_to) REFERENCES comments(comment_id) + ON DELETE CASCADE; diff --git a/poetry.lock b/poetry.lock index e56caf3a..3428b0f5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -69,6 +69,21 @@ cffi = [ {version = ">=2.0.0b1", markers = "python_version >= \"3.14\""}, ] +[[package]] +name = "babel" +version = "2.18.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"}, + {file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"}, +] + +[package.extras] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] + [[package]] name = "backports-zstd" version = "1.6.0" @@ -694,6 +709,24 @@ werkzeug = ">=3.1.0" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] +[[package]] +name = "flask-babel" +version = "4.0.0" +description = "Adds i18n/l10n support for Flask applications." +optional = false +python-versions = ">=3.8,<4.0" +groups = ["main"] +files = [ + {file = "flask_babel-4.0.0-py3-none-any.whl", hash = "sha256:638194cf91f8b301380f36d70e2034c77ee25b98cb5d80a1626820df9a6d4625"}, + {file = "flask_babel-4.0.0.tar.gz", hash = "sha256:dbeab4027a3f4a87678a11686496e98e1492eb793cbdd77ab50f4e9a2602a593"}, +] + +[package.dependencies] +Babel = ">=2.12" +Flask = ">=2.0" +Jinja2 = ">=3.1" +pytz = ">=2022.7" + [[package]] name = "flask-compress" version = "1.24" @@ -1742,6 +1775,18 @@ files = [ [package.extras] dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] +[[package]] +name = "pytz" +version = "2026.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126"}, + {file = "pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a"}, +] + [[package]] name = "ruff" version = "0.14.14" @@ -1938,5 +1983,5 @@ email = ["email-validator"] [metadata] lock-version = "2.1" -python-versions = ">=3.12" -content-hash = "ceeebc4a9521c4473c1a22b28afc7bbbd93de57b76507e11956a61495427ec0a" +python-versions = ">=3.12,<4.0" +content-hash = "c96c454a76a96fd79957e5c99729b3911e596c3a90b9d543f47d6ba28a8fc11e" diff --git a/pyproject.toml b/pyproject.toml index 44df972f..ea09c2ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ {name = "LH_cat, Raydev"} ] readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.12,<4.0" dependencies = [ "flask (>=3.1.2,<4.0.0)", "flask-sqlalchemy (>=3.1.1,<4.0.0)", @@ -22,6 +22,7 @@ dependencies = [ "pyright (>=1.1.410,<2.0.0)", "flask-compress (>=1.24,<2.0)", "nh3 (>=0.2.21,<1.0.0)", + "flask-babel (>=4.0.0,<5.0.0)", ] diff --git a/src/application/services/comment_service.py b/src/application/services/comment_service.py index c97e3427..4bdf32bc 100644 --- a/src/application/services/comment_service.py +++ b/src/application/services/comment_service.py @@ -22,7 +22,7 @@ class CommentService(CommentManagementPort): """ ALLOWED_TAGS = frozenset({ - "b", "i", "u", "s", "a", "ul", "ol", "li", "br", "p", "em", "strong", + "b", "i", "u", "s", "strike", "del", "a", "ul", "ol", "li", "br", "p", "em", "strong", "blockquote", "pre", "code", "span", "sub", "sup", }) @@ -280,7 +280,7 @@ def hard_delete_comment(self, comment_id: int, user_id: int) -> bool | str: """ Permanently deletes a comment from the database. Admin only. Only allowed on already soft-deleted comments. - Children get comment_reply_to set to NULL via FK ON DELETE SET NULL. + Children are automatically deleted via FK ON DELETE CASCADE. Args: comment_id (int): ID of the comment to permanently delete. diff --git a/src/infrastructure/input_adapters/dto/article_response.py b/src/infrastructure/input_adapters/dto/article_response.py index 9a62b92c..fa97c7af 100644 --- a/src/infrastructure/input_adapters/dto/article_response.py +++ b/src/infrastructure/input_adapters/dto/article_response.py @@ -1,5 +1,4 @@ from datetime import datetime -from zoneinfo import ZoneInfo from pydantic import BaseModel, ConfigDict @@ -9,17 +8,24 @@ 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. + Dates are returned as raw UTC datetimes. + Formatting with locale-aware filters happens in the template layer. + Attributes: + 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. + author_username (str): Display name of the article author. author_avatar_file_id (str | None): UUID of the author's avatar file, or None. + article_title (str): Title of the article. article_description (str): Short description displayed in article list. Empty string when no description was provided. + article_content (str): JSON content for the BlockNote editor. + article_published_at (datetime | None): Publication timestamp in UTC. 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. + article_edited_at (datetime | None): Last edit timestamp in UTC. + None if never edited. """ model_config = ConfigDict(from_attributes=True) @@ -33,30 +39,11 @@ class ArticleResponse(BaseModel): 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". @@ -67,10 +54,6 @@ def from_domain(cls, article, author_username: str = "Unknown", author_avatar_fi """ 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, @@ -82,5 +65,4 @@ def from_domain(cls, article, author_username: str = "Unknown", author_avatar_fi 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/dto/comment_response.py b/src/infrastructure/input_adapters/dto/comment_response.py index a528bbaf..762ea68c 100644 --- a/src/infrastructure/input_adapters/dto/comment_response.py +++ b/src/infrastructure/input_adapters/dto/comment_response.py @@ -2,7 +2,6 @@ from dataclasses import dataclass, field from datetime import datetime -from zoneinfo import ZoneInfo from pydantic import BaseModel, ConfigDict @@ -11,7 +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. + + Dates are returned as raw UTC datetimes. + Formatting with locale-aware filters happens in the template layer. Attributes: comment_id (int): Unique identifier for the comment. @@ -21,10 +22,9 @@ class CommentResponse(BaseModel): author_avatar_file_id (str | None): UUID of the author's avatar file, or None. comment_reply_to (int | None): Reference to a parent comment (for replies). comment_content (str): Text content of the comment. - comment_posted_at_formatted (str): Human-readable posting date in local time. + comment_posted_at (datetime | None): Posting timestamp in UTC. is_deleted (bool): Soft-delete flag. - edited_at (datetime | None): Last edit timestamp. None if never edited. - edited_at_formatted (str): Human-readable edit time in local time. Empty if never edited. + edited_at (datetime | None): Last edit timestamp in UTC. None if never edited. """ model_config = ConfigDict(from_attributes=True) @@ -35,53 +35,19 @@ class CommentResponse(BaseModel): author_avatar_file_id: str | None = None comment_reply_to: int | None comment_content: str - comment_posted_at_formatted: str = "" + comment_posted_at: datetime | None = None is_deleted: bool = False edited_at: datetime | None = None - edited_at_formatted: str = "" - - @staticmethod - def _to_local_full(dt: datetime) -> str: - """ - Converts a UTC datetime to Europe/Paris and returns a full formatted string. - - Args: - dt (datetime): The datetime to convert (assumed UTC if naive). - - Returns: - str: Formatted date like "October 27, 2023 at 16:30". - """ - 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") - - @staticmethod - def _to_local_time(dt: datetime) -> str: - """ - Converts a UTC datetime to Europe/Paris and returns just the time. - - Args: - dt (datetime): The datetime to convert (assumed UTC if naive). - - Returns: - str: Formatted time like "16:30". - """ - if dt.tzinfo is None: - dt = dt.replace(tzinfo=ZoneInfo("UTC")) - local = dt.astimezone(ZoneInfo("Europe/Paris")) - return local.strftime("%H:%M") @classmethod 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 dates into reader-friendly strings in Europe/Paris local time. - Maps is_deleted, deleted_at, and edited_at from the domain entity. + If the comment's author account has been deleted (comment_written_account_id is None) or the comment has been soft-deleted (is_deleted is True), - the author is displayed as "Anonymous" and content shows as 'Comment removed'. + the author is set to "Anonymous". Content display is handled by the template. Args: comment: The domain Comment entity to convert. @@ -94,21 +60,8 @@ def from_domain(cls, comment, author_username: str = "Unknown", author_avatar_fi content = comment.comment_content is_deleted = comment.is_deleted - if comment.comment_written_account_id is None: - author_username = "Anonymous" - content = "Comment removed" - - elif is_deleted: + if comment.comment_written_account_id is None or is_deleted: author_username = "Anonymous" - content = "Comment removed" - - formatted_date = "" - if comment.comment_posted_at: - formatted_date = cls._to_local_full(comment.comment_posted_at) - - edited_at_formatted = "" - if comment.edited_at: - edited_at_formatted = cls._to_local_full(comment.edited_at) return cls( comment_id=comment.comment_id, @@ -118,10 +71,9 @@ def from_domain(cls, comment, author_username: str = "Unknown", author_avatar_fi author_avatar_file_id=author_avatar_file_id, comment_reply_to=comment.comment_reply_to, comment_content=content, - comment_posted_at_formatted=formatted_date, + comment_posted_at=comment.comment_posted_at, is_deleted=is_deleted, edited_at=comment.edited_at, - edited_at_formatted=edited_at_formatted, ) @classmethod 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 8e211160..b67bebb2 100644 --- a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py @@ -1,7 +1,9 @@ import logging import math -from flask import abort, flash, jsonify, redirect, render_template, request, url_for +from flask_babel import gettext as _ + +from flask import abort, flash, jsonify, redirect, render_template, request, session, url_for from flask import g as global_request_context from flask.views import MethodView from src.application.application_exceptions import FileTooLargeError, FileTypeError @@ -73,9 +75,27 @@ def logout(self): Response: A Flask redirect response. """ self.session_service.terminate_session() - flash("You have been logged out.", "info") + flash(_("You have been logged out."), "info") return redirect(url_for("article.list_articles")) + def set_lang(self, locale: str): + """ + Flask view that sets the user's language preference in the session. + + Accepts 'fr' or 'en'. Invalid values are silently ignored. + Redirects to the previous page (via Referer header) or the article + list as fallback. + + Args: + locale (str): The target locale, extracted from the URL path. + + Returns: + Response: A redirect response. + """ + if locale in ("fr", "en"): + session["lang"] = locale + return redirect(request.referrer or url_for("article.list_articles")) + def display_profile(self): """ Renders the current user's profile if an active session is found. @@ -87,7 +107,7 @@ def display_profile(self): account = self.session_service.get_current_account() if not account: - flash("Please sign in to view your profile.", "error") + flash(_("Please sign in to view your profile."), "error") return redirect(url_for("auth.login")) user_dto = AccountResponse.from_domain(account) @@ -144,11 +164,11 @@ def upload_profile_photo(self): """ current_account = getattr(global_request_context, "current_user", None) if not current_account: - return jsonify({"error": "Authentication required."}), 401 + 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 + return jsonify({"error": _("No file provided.")}), 400 file_data = uploaded_file.read() try: @@ -191,22 +211,22 @@ def remove_profile_photo(self): """ account = self.session_service.get_current_account() if not account: - flash("Please sign in.", "error") + 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") + 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") + flash(_("Failed to remove profile photo."), "error") return redirect(url_for("auth.profile")) - flash("Profile photo removed.", "success") + flash(_("Profile photo removed."), "success") return redirect(url_for("auth.profile")) def update_email(self): @@ -222,19 +242,19 @@ def update_email(self): """ account = self.session_service.get_current_account() if not account: - flash("Please sign in.", "error") + flash(_("Please sign in."), "error") return redirect(url_for("auth.login")) new_email = request.form.get("email", "").strip() if not new_email: - flash("Email is required.", "error") + flash(_("Email is required."), "error") return redirect(url_for("auth.profile")) result = self.session_service.update_email(new_email) if result is not None: - flash(result, "error") + flash(_(result), "error") else: - flash("Email updated.", "success") + flash(_("Email updated."), "success") return redirect(url_for("auth.profile")) def update_password(self): @@ -250,19 +270,19 @@ def update_password(self): """ account = self.session_service.get_current_account() if not account: - flash("Please sign in.", "error") + flash(_("Please sign in."), "error") return redirect(url_for("auth.login")) new_password = request.form.get("new_password", "") if not new_password: - flash("Password is required.", "error") + flash(_("Password is required."), "error") return redirect(url_for("auth.profile")) result = self.session_service.update_password(new_password) if result is not None: - flash(result, "error") + flash(_(result), "error") else: - flash("Password updated.", "success") + flash(_("Password updated."), "success") return redirect(url_for("auth.profile")) def list_all_users(self): @@ -339,7 +359,7 @@ def delete_account(self): """ current_account = self.session_service.get_current_account() if not current_account: - flash("Please sign in.", "error") + flash(_("Please sign in."), "error") return redirect(url_for("auth.login")) target_id = request.form.get("account_id", type=int) or current_account.account_id @@ -353,7 +373,7 @@ def delete_account(self): abort(403) target = self.session_service.get_account_by_id(target_id) if not target: - flash("Account not found.", "error") + flash(_("Account not found."), "error") return redirect(url_for("auth.list_all_users")) if target.account_role == AccountRole.ADMIN: abort(403) @@ -370,10 +390,10 @@ def delete_account(self): if is_self: self.session_service.terminate_session() - flash("Account deleted.", "success") + flash(_("Account deleted."), "success") return redirect(url_for("article.list_articles")) - flash("Account deleted.", "success") + flash(_("Account deleted."), "success") return redirect(url_for("auth.list_all_users")) def change_role(self, account_id: int): @@ -407,12 +427,12 @@ def change_role(self, account_id: int): target = self.session_service.get_account_by_id(account_id) if result is not None: - flash(result, "error") + flash(_(result), "error") if target: return redirect(url_for("auth.user_profile", username=target.account_username)) return redirect(url_for("auth.list_all_users")) - flash("Role updated.", "success") + flash(_("Role updated."), "success") if target: return redirect(url_for("auth.user_profile", username=target.account_username)) return redirect(url_for("auth.list_all_users")) @@ -445,9 +465,9 @@ def ban_account(self, account_id: int): ) if result is not None: - flash(result, "error") + flash(_(result), "error") else: - flash("Account banned.", "success") + flash(_("Account banned."), "success") return redirect(url_for("auth.list_all_users")) @@ -476,8 +496,8 @@ def unban_account(self, account_id: int): ) if result is not None: - flash(result, "error") + flash(_(result), "error") else: - flash("Account unbanned.", "success") + flash(_("Account unbanned."), "success") return redirect(url_for("auth.list_all_users")) diff --git a/src/infrastructure/input_adapters/flask/flask_article_adapter.py b/src/infrastructure/input_adapters/flask/flask_article_adapter.py index 3968af8c..51845954 100644 --- a/src/infrastructure/input_adapters/flask/flask_article_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_article_adapter.py @@ -1,6 +1,7 @@ import json import math +from flask_babel import gettext as _ from pydantic import ValidationError from werkzeug.wrappers.response import Response @@ -110,7 +111,7 @@ def read_article(self, article_id: int) -> str | Response: """ result = self.article_service.get_article_with_comments(article_id) if isinstance(result, str): - flash(f"Error: {result}", "error") + flash(_("Error: %(error)s", error=result), "error") return redirect(url_for("article.list_articles")) detail = result @@ -152,11 +153,11 @@ def render_create_page(self) -> str | Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to author an article.", "error") + flash(_("You must be signed in to author an article."), "error") return redirect(url_for("auth.login")) if user.account_role not in ["admin", "author"]: - flash("Insufficient permissions: Only authors or admins can create articles.", "error") + flash(_("Insufficient permissions: Only authors or admins can create articles."), "error") return redirect(url_for("article.list_articles")) return render_template("article_create.html", current_user=user, page_with_editor=True) @@ -177,7 +178,7 @@ def api_get_article(self, article_id: int) -> Response | tuple[Response, int]: """ article = self.article_service.get_by_id(article_id) if not article: - return jsonify({"error": "Article not found."}), 404 + return jsonify({"error": _("Article not found.")}), 404 content = article.article_content try: @@ -213,13 +214,13 @@ def api_create_article(self) -> Response | tuple[Response, int]: """ user = global_request_context.get("current_user") if not user: - return jsonify({"error": "Unauthorized."}), 401 + return jsonify({"error": _("Unauthorized.")}), 401 if user.account_role not in ["admin", "author"]: - return jsonify({"error": "Insufficient permissions."}), 403 + return jsonify({"error": _("Insufficient permissions.")}), 403 data = request.get_json(silent=True) if not data: - return jsonify({"error": "Invalid JSON body."}), 400 + return jsonify({"error": _("Invalid JSON body.")}), 400 try: req_data = ArticleRequest( @@ -230,7 +231,7 @@ def api_create_article(self) -> Response | tuple[Response, int]: except ValidationError as e: for error in e.errors(): return jsonify({"error": f"({error['loc'][0]}): {error['msg']}"}), 400 - return jsonify({"error": "Validation error."}), 400 + return jsonify({"error": _("Validation error.")}), 400 result = self.article_service.create_article( title=req_data.title, content=req_data.content, @@ -258,13 +259,13 @@ def api_update_article(self, article_id: int) -> Response | tuple[Response, int] """ user = global_request_context.get("current_user") if not user: - return jsonify({"error": "Unauthorized."}), 401 + return jsonify({"error": _("Unauthorized.")}), 401 if user.account_role not in ["admin", "author"]: - return jsonify({"error": "Insufficient permissions."}), 403 + return jsonify({"error": _("Insufficient permissions.")}), 403 data = request.get_json(silent=True) if not data: - return jsonify({"error": "Invalid JSON body."}), 400 + return jsonify({"error": _("Invalid JSON body.")}), 400 try: req_data = ArticleRequest( @@ -275,7 +276,7 @@ def api_update_article(self, article_id: int) -> Response | tuple[Response, int] except ValidationError as e: for error in e.errors(): return jsonify({"error": f"({error['loc'][0]}): {error['msg']}"}), 400 - return jsonify({"error": "Validation error."}), 400 + return jsonify({"error": _("Validation error.")}), 400 result = self.article_service.update_article( article_id=article_id, user_id=user.account_id, @@ -303,9 +304,9 @@ def _api_delete_article(self, article_id: int) -> Response | tuple[Response, int """ user = global_request_context.get("current_user") if not user: - return jsonify({"error": "Unauthorized."}), 401 + return jsonify({"error": _("Unauthorized.")}), 401 if user.account_role not in ["admin", "author"]: - return jsonify({"error": "Insufficient permissions."}), 403 + return jsonify({"error": _("Insufficient permissions.")}), 403 result = self.article_service.delete_article( article_id=article_id, user_id=user.account_id, @@ -329,15 +330,15 @@ def delete_article_html(self, article_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be logged in to delete articles.", "error") + flash(_("You must be logged in to delete articles."), "error") return redirect(url_for("auth.login")) result = self._api_delete_article(article_id) if isinstance(result, tuple): - flash(result[0].get_json()["error"], "error") + flash(_(result[0].get_json()["error"]), "error") else: - flash("Article deleted successfully.", "success") + flash(_("Article deleted successfully."), "success") return redirect(url_for("article.list_articles")) @@ -354,20 +355,20 @@ def render_edit_page(self, article_id: int) -> str | Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to edit an article.", "error") + flash(_("You must be signed in to edit an article."), "error") return redirect(url_for("auth.login")) if user.account_role not in ["admin", "author"]: - flash("Insufficient permissions: Only authors or admins can create articles.", "error") + flash(_("Insufficient permissions: Only authors or admins can create articles."), "error") return redirect(url_for("article.list_articles")) domain_article = self.article_service.get_by_id(article_id) if not domain_article: - flash("Error: The requested article could not be found.", "error") + 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") + 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) diff --git a/src/infrastructure/input_adapters/flask/flask_comment_adapter.py b/src/infrastructure/input_adapters/flask/flask_comment_adapter.py index 116fcec0..8ade8c83 100644 --- a/src/infrastructure/input_adapters/flask/flask_comment_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_comment_adapter.py @@ -1,5 +1,6 @@ import time +from flask_babel import gettext as _ from pydantic import ValidationError from werkzeug.wrappers.response import Response @@ -60,7 +61,7 @@ def create_comment(self, article_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to post a comment.", "error") + flash(_("You must be signed in to post a comment."), "error") return redirect(url_for("auth.login")) if request.form.get("hp_comment"): @@ -71,12 +72,12 @@ def create_comment(self, article_id: int) -> Response: except ValidationError as e: for error in e.errors(): msg = error["msg"].removeprefix("Value error, ") - flash(msg, "error") + flash(_(msg), "error") return redirect(url_for("article.read_article", article_id=article_id)) remaining = self._check_comment_rate_limit(user.account_id) if remaining is not None: - flash(f"You're posting too fast. Please wait {remaining}s before posting again.", "warning") + flash(_("You're posting too fast. Please wait %(remaining)ss before posting again.", remaining=remaining), "warning") return redirect(url_for("article.read_article", article_id=article_id)) result = self.comment_service.create_comment( @@ -86,9 +87,9 @@ def create_comment(self, article_id: int) -> Response: ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") else: - flash("Comment added.", "success") + flash(_("Comment added."), "success") return redirect(url_for("article.read_article", article_id=article_id)) @@ -105,7 +106,7 @@ def reply_to_comment(self, article_id: int, parent_comment_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to reply.", "error") + flash(_("You must be signed in to reply."), "error") return redirect(url_for("auth.login")) if request.form.get("hp_comment"): @@ -116,12 +117,12 @@ def reply_to_comment(self, article_id: int, parent_comment_id: int) -> Response: except ValidationError as e: for error in e.errors(): msg = error["msg"].removeprefix("Value error, ") - flash(msg, "error") + flash(_(msg), "error") return redirect(url_for("article.read_article", article_id=article_id)) remaining = self._check_comment_rate_limit(user.account_id) if remaining is not None: - flash(f"You're posting too fast. Please wait {remaining}s before posting again.", "warning") + flash(_("You're posting too fast. Please wait %(remaining)ss before posting again.", remaining=remaining), "warning") return redirect(url_for("article.read_article", article_id=article_id)) result = self.comment_service.create_reply( @@ -131,9 +132,9 @@ def reply_to_comment(self, article_id: int, parent_comment_id: int) -> Response: ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") else: - flash("Reply added.", "success") + flash(_("Reply added."), "success") return redirect(url_for("article.read_article", article_id=article_id)) @@ -150,7 +151,7 @@ def delete_comment(self, article_id: int, comment_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to delete comments.", "error") + flash(_("You must be signed in to delete comments."), "error") return redirect(url_for("auth.login")) result = self.comment_service.delete_comment( @@ -159,11 +160,11 @@ def delete_comment(self, article_id: int, comment_id: int) -> Response: ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") elif result is True: - flash("Comment deleted.", "success") + flash(_("Comment deleted."), "success") else: - flash("Unauthorized or error.", "error") + flash(_("Unauthorized or error."), "error") return redirect(url_for("article.read_article", article_id=article_id)) @@ -181,7 +182,7 @@ def edit_comment(self, article_id: int, comment_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to edit comments.", "error") + flash(_("You must be signed in to edit comments."), "error") return redirect(url_for("auth.login")) content = request.form.get("content", "") @@ -192,9 +193,9 @@ def edit_comment(self, article_id: int, comment_id: int) -> Response: ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") else: - flash("Comment updated.", "success") + flash(_("Comment updated."), "success") return redirect(url_for("article.read_article", article_id=article_id)) @@ -212,7 +213,7 @@ def hard_delete_comment(self, article_id: int, comment_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to delete comments.", "error") + flash(_("You must be signed in to delete comments."), "error") return redirect(url_for("auth.login")) result = self.comment_service.hard_delete_comment( @@ -221,10 +222,10 @@ def hard_delete_comment(self, article_id: int, comment_id: int) -> Response: ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") elif result is True: - flash("Comment permanently deleted.", "success") + flash(_("Comment permanently deleted."), "success") else: - flash("Unauthorized or error.", "error") + flash(_("Unauthorized or error."), "error") return redirect(url_for("article.read_article", article_id=article_id)) diff --git a/src/infrastructure/input_adapters/flask/flask_file_adapter.py b/src/infrastructure/input_adapters/flask/flask_file_adapter.py index 886a0cce..5e744afb 100644 --- a/src/infrastructure/input_adapters/flask/flask_file_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_file_adapter.py @@ -1,5 +1,7 @@ from io import BytesIO +from flask_babel import gettext as _ + from flask import jsonify, request, send_file from src.application.application_exceptions import FileTooLargeError, FileTypeError from src.application.input_ports.file_management import FileManagementPort @@ -28,7 +30,7 @@ def upload_image(self): """ uploaded_file = request.files.get("file") if uploaded_file is None or not uploaded_file.filename: - return jsonify({"error": "No file provided"}), 400 + return jsonify({"error": _("No file provided")}), 400 file_data = uploaded_file.read() @@ -68,7 +70,7 @@ def serve_file(self, file_id: str, filename: str): """ file_record = self.file_service.get_file(file_id) if file_record is None: - return jsonify({"error": "File not found"}), 404 + return jsonify({"error": _("File not found")}), 404 return send_file( BytesIO(file_record.data), diff --git a/src/infrastructure/input_adapters/flask/flask_login_adapter.py b/src/infrastructure/input_adapters/flask/flask_login_adapter.py index afabf812..5bef17cc 100644 --- a/src/infrastructure/input_adapters/flask/flask_login_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_login_adapter.py @@ -1,3 +1,4 @@ +from flask_babel import gettext as _ from pydantic import ValidationError from flask import flash, redirect, render_template, request, url_for @@ -51,7 +52,7 @@ def authenticate(self): except ValidationError as e: for error in e.errors(): location = str(error["loc"][0]) if error["loc"] else "Request" - flash(f"Validation Error ({location}): {error['msg']}", "error") + flash(_("Validation Error (%(location)s): %(message)s", location=location, message=error["msg"]), "error") return render_template("login.html", current_user=user, username=submitted_username) result = self.login_service.authenticate_user( @@ -63,7 +64,7 @@ def authenticate(self): return redirect(url_for("article.list_articles")) if result == "This account has been banned.": - flash(result, "error") + flash(_(result), "error") else: - flash("Invalid username or password.", "error") + flash(_("Invalid username or password."), "error") return render_template("login.html", current_user=user, username=login_data.username) diff --git a/src/infrastructure/input_adapters/flask/flask_registration_adapter.py b/src/infrastructure/input_adapters/flask/flask_registration_adapter.py index 3cf00bb7..7e457164 100644 --- a/src/infrastructure/input_adapters/flask/flask_registration_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_registration_adapter.py @@ -1,3 +1,4 @@ +from flask_babel import gettext as _ from pydantic import ValidationError from flask import flash, redirect, render_template, request, url_for @@ -54,7 +55,7 @@ def register(self): except ValidationError as e: for error in e.errors(): location = str(error["loc"][0]) if error["loc"] else "Request" - flash(f"{location}: {error['msg']}", "error") + flash(_("%(location)s: %(message)s", location=location, message=error["msg"]), "error") return render_template("registration.html", current_user=user, username=submitted_username, email=submitted_email) result = self.registration_service.create_account( @@ -64,8 +65,8 @@ def register(self): ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") return render_template("registration.html", current_user=user, username=reg_data.username, email=reg_data.email) - flash("Registration successful. Please sign in.", "success") + flash(_("Registration successful. Please sign in."), "success") return redirect(url_for("auth.login")) diff --git a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py index 662d46c3..d4736404 100644 --- a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py +++ b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py @@ -15,10 +15,10 @@ class CommentModel(SqlAlchemyModel): """ - SQLAlchemy ORM model for comments used in the new architecture. + SQLAlchemy ORM model for comments. - comment_reply_to uses ON DELETE SET NULL so that replies to a deleted - comment become top-level comments instead of being cascaded away. + comment_reply_to uses ON DELETE CASCADE so that hard-deleting a parent + comment automatically removes all its replies. """ __tablename__ = "comments" @@ -40,7 +40,7 @@ class CommentModel(SqlAlchemyModel): nullable=True, ) comment_reply_to: Mapped[int | None] = mapped_column( - ForeignKey("comments.comment_id", ondelete="SET NULL"), + ForeignKey("comments.comment_id", ondelete="CASCADE"), name="comment_reply_to", type_=Integer, nullable=True, diff --git a/tests/conftest.py b/tests/conftest.py index fce3dd4c..8f9f3949 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,6 +43,7 @@ def app_with_db(db_session): app = create_app(db_session=db_session) app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False + app.extensions["babel"].locale_selector = lambda: "en" return app @pytest.fixture(scope="function") diff --git a/tests/test_template_helpers.py b/tests/test_template_helpers.py index 3b7d602c..5ccf9a15 100644 --- a/tests/test_template_helpers.py +++ b/tests/test_template_helpers.py @@ -1,11 +1,11 @@ -from datetime import datetime +from datetime import UTC, datetime import pytest from flask import render_template_string from jinja2.exceptions import TemplateNotFound from markupsafe import Markup -from utils.template_helpers import date_format_filter, date_iso_filter, nl2br_filter +from utils.template_helpers import date_iso_filter, nl2br_filter class TestIconMacro: @@ -88,21 +88,6 @@ def test_mixed_content_with_newlines_and_html(self): assert "" not in str(result) -class TestDateFormatFilter: - """Unit tests for the date_format Jinja2 filter.""" - - def test_formats_date_with_default_format(self): - result = date_format_filter(datetime(2026, 4, 29)) - assert result == "Apr 29, 2026" - - def test_returns_recent_for_none_date(self): - result = date_format_filter(None) - assert result == "RECENT" - - def test_uses_custom_format(self): - result = date_format_filter(datetime(2026, 4, 29), "%Y-%m-%d") - assert result == "2026-04-29" - class TestDateIsoFilter: """Tests for the date_iso Jinja2 filter.""" @@ -118,3 +103,52 @@ def test_pads_single_digit_month_and_day(self): def test_returns_empty_string_for_none(self): result = date_iso_filter(None) assert result == "" + + +class TestFormatDatetimeLocaleFilter: + """Tests for the format_datetime_locale Jinja2 filter.""" + + def test_formats_datetime_in_default_locale(self, app_with_db): + from flask import render_template_string + dt = datetime(2023, 1, 27, 12, 0, 0) + with app_with_db.app_context(): + result = render_template_string( + "{{ dt|format_datetime_locale }}", dt=dt + ) + assert result == "27 January 2023 à 13:00" + + def test_returns_empty_string_for_none(self, app_with_db): + from flask import render_template_string + with app_with_db.app_context(): + result = render_template_string( + "{{ dt|format_datetime_locale }}", dt=None + ) + assert result == "" + + def test_formats_datetime_with_custom_format(self, app_with_db): + from flask import render_template_string + dt = datetime(2023, 6, 15, 8, 30) + with app_with_db.app_context(): + result = render_template_string( + '{{ dt|format_datetime_locale("d MMMM yyyy") }}', dt=dt + ) + assert result == "15 June 2023" + + def test_converts_utc_to_paris_timezone(self, app_with_db): + from flask import render_template_string + dt = datetime(2023, 1, 27, 12, 0, 0, tzinfo=UTC) + with app_with_db.app_context(): + result = render_template_string( + "{{ dt|format_datetime_locale }}", dt=dt + ) + assert result == "27 January 2023 à 13:00" + + def test_formats_in_french_locale(self, app_with_db): + from flask import render_template_string + dt = datetime(2023, 1, 27, 12, 0, 0) + with app_with_db.app_context(): + app_with_db.extensions["babel"].locale_selector = lambda: "fr" + result = render_template_string( + "{{ dt|format_datetime_locale }}", dt=dt + ) + assert result == "27 janvier 2023 à 13:00" 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 761d0128..eb67ddd7 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 UTC, datetime +from datetime import datetime from src.application.domain.article import Article from src.infrastructure.input_adapters.dto.article_response import ArticleResponse @@ -74,8 +74,8 @@ def test_from_domain_with_description(self): 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) + """article_edited_at conservé tel quel (datetime brut).""" + dt = datetime(2023, 1, 27, 12, 0, 0) domain_article = Article( article_id=1, article_author_id=10, article_title="Title", article_content="Content", article_published_at=datetime.now(), @@ -83,14 +83,12 @@ def test_from_domain_with_article_edited_at(self): ) 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é.""" + """article_edited_at None 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/dto/test_comment_response.py b/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py index 9b020e73..b804df43 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 @@ -22,7 +22,7 @@ def test_comment_response_from_domain_mapping(): assert response.author_username == "johndoe" assert response.comment_reply_to is None assert response.comment_content == "Hello world" - assert response.comment_posted_at_formatted == "October 27, 2023 at 16:30" + assert response.comment_posted_at == posted_at assert response.is_deleted is False assert response.edited_at is None @@ -54,7 +54,7 @@ def test_comment_response_from_domain_deleted(): response = CommentResponse.from_domain(domain_comment, author_username="johndoe") assert response.author_username == "Anonymous" - assert response.comment_content == "Comment removed" + assert response.comment_content == "Original content" assert response.is_deleted is True def test_comment_response_from_domain_edited(): @@ -74,7 +74,6 @@ def test_comment_response_from_domain_edited(): assert response.comment_content == "Edited content" assert response.is_deleted is False assert response.edited_at == edited_at - assert response.edited_at_formatted == "October 28, 2023 at 12:00" def test_comment_response_from_domain_deleted_with_edited_at(): posted_at = datetime(2023, 10, 27, 14, 30) @@ -92,10 +91,9 @@ def test_comment_response_from_domain_deleted_with_edited_at(): response = CommentResponse.from_domain(domain_comment, author_username="johndoe") assert response.author_username == "Anonymous" - assert response.comment_content == "Comment removed" + assert response.comment_content == "Edited then deleted" assert response.is_deleted is True assert response.edited_at == edited_at - assert response.edited_at_formatted == "October 28, 2023 at 12:00" def test_comment_response_from_domain_with_all_fields(): posted_at = datetime(2023, 10, 27, 14, 30) @@ -120,7 +118,6 @@ def test_comment_response_from_domain_with_all_fields(): assert response.author_avatar_file_id == "avatar-uuid" assert response.is_deleted is False assert response.edited_at is None - assert response.edited_at_formatted == "" def test_map_nested_tree(): posted_at = datetime(2023, 10, 27, 14, 30) @@ -173,7 +170,7 @@ def test_from_domain_with_none_author_id_maps_to_removed(): result = CommentResponse.from_domain(comment, "some_user") assert result.author_username == "Anonymous" assert result.comment_written_account_id is None - assert result.comment_content == "Comment removed" + assert result.comment_content == "Original content" def test_map_nested_tree_threads_avatar(): posted_at = datetime(2023, 10, 27, 14, 30) diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py index a9d1d718..c232a5c5 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py @@ -2,10 +2,11 @@ from flask import Flask, get_flashed_messages, request from flask import g as global_request_context +from flask_babel import Babel from flask_wtf.csrf import CSRFProtect from utils.prosemirror_to_html import prosemirror_to_html -from utils.template_helpers import date_format_filter, date_iso_filter, nl2br_filter +from utils.template_helpers import date_iso_filter, format_datetime_locale, nl2br_filter class FlaskInputAdapterTestBase: @@ -62,16 +63,20 @@ def setup_method(self) -> None: self.app = Flask(__name__, template_folder=self.TEMPLATE_DIR, static_folder=static_dir) self.app.jinja_env.filters["nl2br"] = nl2br_filter - self.app.jinja_env.filters["date_format"] = date_format_filter self.app.jinja_env.filters["date_iso"] = date_iso_filter self.app.jinja_env.filters["prosemirror_to_html"] = prosemirror_to_html + self.app.jinja_env.filters["format_datetime_locale"] = format_datetime_locale self.app.config["SECRET_KEY"] = "test_secret" self.app.config["SERVER_NAME"] = "localhost" self.app.config["TESTING"] = True self.app.config["WTF_CSRF_ENABLED"] = False + Babel(self.app) + self.app.extensions["babel"].locale_selector = lambda: "en" + self.app.context_processor(lambda: {"get_locale": lambda: self.app.extensions["babel"].locale_selector()}) CSRFProtect(self.app) self._test_user = None self._dummy_labels = {} + self._register_dummy_route("/lang/", "auth.set_lang", "set_lang") self.app.before_request(self._inject_test_user_hook) self.client = self.app.test_client() self.app_context = self.app.test_request_context() 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 5b540be8..42432ff5 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 @@ -135,7 +135,7 @@ def test_list_articles_date_rendering(self): self.mock_account_repo.get_by_ids.return_value = [create_test_account(account_id=1, account_username="Author")] response = self.client.get("/") assert response.status_code == 200 - assert b"Apr 29, 2026" in response.data + assert b"29 April 2026" in response.data assert b'datetime="2026-04-29"' in response.data def test_list_articles_no_date_hides_meta(self): diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_file_adapter.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_file_adapter.py new file mode 100644 index 00000000..8450e65d --- /dev/null +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_file_adapter.py @@ -0,0 +1,109 @@ +from io import BytesIO +from unittest.mock import Mock + +from src.application.domain.file_record import FileRecord +from src.application.input_ports.file_management import FileManagementPort +from src.infrastructure.input_adapters.flask.flask_file_adapter import FlaskFileAdapter +from tests.tests_infrastructure.tests_input_adapters.tests_flask.flask_test_utils import ( + FlaskInputAdapterTestBase, +) + + +class FlaskFileAdapterTest(FlaskInputAdapterTestBase): + def setup_method(self): + super().setup_method() + self.mock_file_service = Mock(spec=FileManagementPort, autospec=True) + self.adapter = FlaskFileAdapter(file_service=self.mock_file_service) + + self.app.add_url_rule( + "/api/upload/image", + view_func=self.adapter.upload_image, + methods=["POST"], + endpoint="file.upload_image", + ) + + self.app.add_url_rule( + "/uploads//", + view_func=self.adapter.serve_file, + methods=["GET"], + endpoint="file.serve_file", + ) + + +class TestFileUpload(FlaskFileAdapterTest): + def test_upload_image_success(self): + record = FileRecord( + file_id="uuid-123", + original_filename="photo.jpg", + mime_type="image/jpeg", + data=b"fake_image_data", + size=len(b"fake_image_data"), + ) + self.mock_file_service.upload_file.return_value = record + + data = {"file": (BytesIO(b"fake_image_data"), "photo.jpg")} + response = self.client.post( + "/api/upload/image", + data=data, + content_type="multipart/form-data", + ) + + assert response.status_code == 201 + assert response.json is not None + assert response.json["url"] == "/uploads/uuid-123/photo.jpg" + self.mock_file_service.upload_file.assert_called_once_with( + filename="photo.jpg", + data=b"fake_image_data", + mime_type="image/jpeg", + ) + + def test_upload_image_no_file_provided(self): + response = self.client.post( + "/api/upload/image", + data={}, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + assert b"No file provided" in response.data + self.mock_file_service.upload_file.assert_not_called() + + def test_upload_image_without_filename(self): + data = {"file": (BytesIO(b"data"), "")} + response = self.client.post( + "/api/upload/image", + data=data, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + assert b"No file provided" in response.data + self.mock_file_service.upload_file.assert_not_called() + + +class TestFileServe(FlaskFileAdapterTest): + def test_serve_file_success(self): + record = FileRecord( + file_id="uuid-456", + original_filename="photo.jpg", + mime_type="image/jpeg", + data=b"fake_image_data", + size=len(b"fake_image_data"), + ) + self.mock_file_service.get_file.return_value = record + + response = self.client.get("/uploads/uuid-456/photo.jpg") + + assert response.status_code == 200 + assert response.mimetype == "image/jpeg" + assert response.data == b"fake_image_data" + self.mock_file_service.get_file.assert_called_once_with("uuid-456") + + def test_serve_file_not_found(self): + self.mock_file_service.get_file.return_value = None + + response = self.client.get("/uploads/uuid-999/missing.jpg") + + assert response.status_code == 404 + assert b"File not found" in response.data + self.mock_file_service.get_file.assert_called_once_with("uuid-999") diff --git a/tests/tests_integration/test_lang_integration.py b/tests/tests_integration/test_lang_integration.py new file mode 100644 index 00000000..3c09d76c --- /dev/null +++ b/tests/tests_integration/test_lang_integration.py @@ -0,0 +1,30 @@ +import pytest + + +class TestLangSwitching: + """Tests verifying language switching behaviour via POST /lang/.""" + + @pytest.mark.parametrize("locale,expected_status,expected_lang", [ + ("fr", 302, "fr"), + ("en", 302, "en"), + ("de", 302, None), + ]) + def test_set_lang( + self, client, db_session, locale, expected_status, expected_lang + ): + """ + Verifies that POST /lang/ sets the 'lang' session key + for valid locales ('fr', 'en'), and silently ignores invalid ones. + + Steps: + 1. POST to /lang/. + 2. Assert redirect (302). + 3. Inspect session for 'lang' key. + """ + resp = client.post(f"/lang/{locale}") + assert resp.status_code == expected_status + with client.session_transaction() as sess: + if expected_lang is None: + assert "lang" not in sess + else: + assert sess.get("lang") == expected_lang diff --git a/tests/tests_integration/test_workflow_integration.py b/tests/tests_integration/test_workflow_integration.py index 48ba475c..58166e32 100644 --- a/tests/tests_integration/test_workflow_integration.py +++ b/tests/tests_integration/test_workflow_integration.py @@ -614,6 +614,110 @@ def test_comment_hard_delete_integration(self, client, db_session): assert db_session.get(CommentModel, cid) is None assert b"Comment permanently deleted" in resp.data + def test_comment_hard_delete_cascades_to_children_integ( + self, client, db_session, + ): + """ + Verifies that hard-deleting a parent comment cascades to ALL + descendants via FK ON DELETE CASCADE. + + Creates a tree: P(parent) -> A(child of P) -> C(child of A), + B(child of P). Hard-deletes P, then asserts P, A, B, C are all + removed from the database. An unrelated comment must remain intact. + """ + admin = AccountModel( + account_username="cascade_admin", account_email="ca@t.com", + account_password="p", account_role="admin", + ) + db_session.add(admin) + db_session.commit() + + article = ArticleModel( + article_title="Cascade Test", article_content="C", + article_author_id=admin.account_id, + ) + db_session.add(article) + db_session.commit() + + parent = CommentModel( + comment_content="Parent", + comment_article_id=article.article_id, + comment_written_account_id=admin.account_id, + is_deleted=True, + deleted_at=datetime.now(UTC), + ) + db_session.add(parent) + db_session.commit() + + child_a = CommentModel( + comment_content="Child A", + comment_article_id=article.article_id, + comment_written_account_id=admin.account_id, + comment_reply_to=parent.comment_id, + is_deleted=True, + deleted_at=datetime.now(UTC), + ) + db_session.add(child_a) + db_session.commit() + + child_b = CommentModel( + comment_content="Child B", + comment_article_id=article.article_id, + comment_written_account_id=admin.account_id, + comment_reply_to=parent.comment_id, + is_deleted=True, + deleted_at=datetime.now(UTC), + ) + db_session.add(child_b) + db_session.commit() + + grandchild = CommentModel( + comment_content="Grandchild", + comment_article_id=article.article_id, + comment_written_account_id=admin.account_id, + comment_reply_to=child_a.comment_id, + is_deleted=True, + deleted_at=datetime.now(UTC), + ) + db_session.add(grandchild) + db_session.commit() + + unrelated = CommentModel( + comment_content="Unrelated", + comment_article_id=article.article_id, + comment_written_account_id=admin.account_id, + is_deleted=True, + deleted_at=datetime.now(UTC), + ) + db_session.add(unrelated) + db_session.commit() + + pid = parent.comment_id + aid = article.article_id + ca_id = child_a.comment_id + cb_id = child_b.comment_id + gc_id = grandchild.comment_id + un_id = unrelated.comment_id + + client.post("/login", data={ + "username": "cascade_admin", "password": "p", + }) + + resp = client.post( + f"/articles/{aid}/comments/{pid}/delete-permanent", + follow_redirects=True, + ) + assert resp.status_code == 200 + assert b"Comment permanently deleted" in resp.data + + db_session.expire_all() + + assert db_session.get(CommentModel, pid) is None + assert db_session.get(CommentModel, ca_id) is None + assert db_session.get(CommentModel, cb_id) is None + assert db_session.get(CommentModel, gc_id) is None + assert db_session.get(CommentModel, un_id) is not None + class TestArticleEditEditedAtIntegration: def test_article_edit_sets_edited_at_integration(self, client, db_session): diff --git a/translations/fr/LC_MESSAGES/messages.mo b/translations/fr/LC_MESSAGES/messages.mo new file mode 100644 index 00000000..1aa312c3 Binary files /dev/null and b/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/translations/fr/LC_MESSAGES/messages.po b/translations/fr/LC_MESSAGES/messages.po new file mode 100644 index 00000000..c85a8908 --- /dev/null +++ b/translations/fr/LC_MESSAGES/messages.po @@ -0,0 +1,929 @@ +# French translations for PROJECT. +# Copyright (C) 2026 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-07-23 20:31+0200\n" +"PO-Revision-Date: 2026-07-22 14:15+0200\n" +"Last-Translator: FULL NAME \n" +"Language: fr\n" +"Language-Team: fr \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.10.3\n" + +#: blog_comment_application.py:212 +msgid "You do not have permission to access this page." +msgstr "Vous n'avez pas la permission d'accéder à cette page." + +#: blog_comment_application.py:213 +msgid "The page you are looking for does not exist." +msgstr "La page que vous cherchez n'existe pas." + +#: blog_comment_application.py:214 +msgid "An unexpected error occurred. Please try again later." +msgstr "Une erreur inattendue s'est produite. Veuillez réessayer plus tard." + +#: frontend/templates/article_create.html:3 +msgid "Write an Article - DevJournal" +msgstr "Écrire un article - DevJournal" + +#: frontend/templates/article_create.html:15 +#: frontend/templates/article_detail.html:47 +#: frontend/templates/article_edit.html:40 +msgid "Back to articles" +msgstr "Retour aux articles" + +#: frontend/templates/article_create.html:28 +#: frontend/templates/article_edit.html:57 +msgid "Title is required." +msgstr "Le titre est requis." + +#: frontend/templates/article_create.html:29 +#: frontend/templates/article_edit.html:58 +msgid "Failed to save." +msgstr "Échec de la sauvegarde." + +#: frontend/templates/article_create.html:30 +#: frontend/templates/article_edit.html:59 +msgid "Network error." +msgstr "Erreur réseau." + +#: frontend/templates/article_create.html:31 +#: frontend/templates/article_detail.html:218 +#: frontend/templates/article_edit.html:60 +msgid "Loading..." +msgstr "Chargement..." + +#: frontend/templates/article_create.html:32 +#: frontend/templates/article_edit.html:61 +msgid "Unable to parse article content." +msgstr "Impossible de lire le contenu de l'article." + +#: frontend/templates/article_create.html:33 +#: frontend/templates/article_edit.html:62 +msgid "Article title" +msgstr "Titre de l'article" + +#: frontend/templates/article_create.html:34 +#: frontend/templates/article_edit.html:63 +msgid "Description" +msgstr "Description" + +#: frontend/templates/article_create.html:35 +#: frontend/templates/article_edit.html:64 +msgid "Maximum 300 characters" +msgstr "Maximum 300 caractères" + +#: frontend/templates/article_create.html:36 +#: frontend/templates/article_edit.html:65 +msgid "Short description (optional)" +msgstr "Description courte (optionnelle)" + +#: frontend/templates/article_create.html:37 +#: frontend/templates/article_edit.html:66 +msgid "Content" +msgstr "Contenu" + +#: frontend/templates/article_create.html:38 +#: frontend/templates/article_edit.html:67 +msgid "Saving..." +msgstr "Sauvegarde..." + +#: frontend/templates/article_create.html:39 +#: frontend/templates/article_edit.html:68 +msgid "Publish" +msgstr "Publier" + +#: frontend/templates/article_create.html:40 +#: frontend/templates/article_detail.html:228 +#: frontend/templates/article_edit.html:69 frontend/templates/profile.html:94 +#: frontend/templates/profile.html:116 +msgid "Save" +msgstr "Enregistrer" + +#: frontend/templates/article_create.html:41 +#: frontend/templates/article_edit.html:70 +msgid "YouTube" +msgstr "YouTube" + +#: frontend/templates/article_create.html:42 +#: frontend/templates/article_edit.html:71 +msgid "Paste a YouTube video URL" +msgstr "Collez une URL YouTube" + +#: frontend/templates/article_create.html:43 +#: frontend/templates/article_edit.html:72 +msgid "YouTube URL" +msgstr "URL YouTube" + +#: frontend/templates/article_create.html:44 +#: frontend/templates/article_edit.html:73 +msgid "Paste YouTube video link" +msgstr "Collez un lien YouTube" + +#: frontend/templates/article_create.html:45 +#: frontend/templates/article_edit.html:74 +msgid "Embed YouTube video" +msgstr "Intégrer la vidéo YouTube" + +#: frontend/templates/article_create.html:46 +#: frontend/templates/article_edit.html:75 +msgid "Add YouTube video URL" +msgstr "Ajouter une URL YouTube" + +#: frontend/templates/article_create.html:47 +#: frontend/templates/article_edit.html:76 +msgid "Only YouTube links are supported" +msgstr "Seules les liens YouTube sont supportés" + +#: frontend/templates/article_create.html:48 +#: frontend/templates/article_detail.html:222 +#: frontend/templates/article_edit.html:77 +msgid "Failed to load article." +msgstr "Échec du chargement de l'article." + +#: frontend/templates/article_create.html:49 +#: frontend/templates/article_detail.html:224 +#: frontend/templates/article_edit.html:78 +msgid "Copy" +msgstr "Copier" + +#: frontend/templates/article_create.html:50 +#: frontend/templates/article_edit.html:79 +msgid "Copy Block" +msgstr "Copier le bloc" + +#: frontend/templates/article_create.html:51 +#: frontend/templates/article_detail.html:220 +#: frontend/templates/article_edit.html:80 +msgid "Copied to clipboard" +msgstr "Copié dans le presse-papier" + +#: frontend/templates/article_create.html:52 +#: frontend/templates/article_edit.html:81 +msgid "Move Up" +msgstr "Monter" + +#: frontend/templates/article_create.html:53 +#: frontend/templates/article_edit.html:82 +msgid "Move Down" +msgstr "Descendre" + +#: frontend/templates/article_create.html:54 +#: frontend/templates/article_detail.html:79 +#: frontend/templates/article_edit.html:83 frontend/templates/base.html:116 +#: frontend/templates/user_list.html:95 +msgid "Delete" +msgstr "Supprimer" + +#: frontend/templates/article_create.html:55 +#: frontend/templates/article_edit.html:84 +msgid "Align text justify" +msgstr "Justifier le texte" + +#: frontend/templates/article_create.html:56 +#: frontend/templates/article_detail.html:223 +#: frontend/templates/article_edit.html:85 +msgid "Plain Text" +msgstr "Texte simple" + +#: frontend/templates/article_create.html:57 +#: frontend/templates/article_edit.html:86 +msgid "Search languages..." +msgstr "Rechercher un langage..." + +#: frontend/templates/article_create.html:58 +#: frontend/templates/article_edit.html:87 +msgid "No languages found" +msgstr "Aucun langage trouvé" + +#: frontend/templates/article_create.html:59 +#: frontend/templates/article_detail.html:226 +#: frontend/templates/article_edit.html:88 +msgid "Something went wrong. Please reload the page." +msgstr "Une erreur est survenue. Veuillez recharger la page." + +#: frontend/templates/article_detail.html:55 +#: frontend/templates/article_detail.html:120 +#: frontend/templates/article_list.html:70 +msgid "Anonymous" +msgstr "Anonyme" + +#: frontend/templates/article_detail.html:72 +#: frontend/templates/article_detail.html:149 +#: frontend/templates/article_list.html:88 +#, python-format +msgid "edited at %(date)s" +msgstr "édité le %(date)s" + +#: frontend/templates/article_detail.html:78 +msgid "Edit" +msgstr "Modifier" + +#: frontend/templates/article_detail.html:79 +msgid "Delete this article permanently ?" +msgstr "Supprimer définitivement cet article ?" + +#: frontend/templates/article_detail.html:126 +#: frontend/templates/profile.html:53 +msgid "[Edit]" +msgstr "[Modifier]" + +#: frontend/templates/article_detail.html:131 +#: frontend/templates/article_detail.html:137 +msgid "[Delete]" +msgstr "[Supprimer]" + +#: frontend/templates/article_detail.html:135 +msgid "Permanently delete this comment? This cannot be undone." +msgstr "Supprimer définitivement ce commentaire ? Action irréversible." + +#: frontend/templates/article_detail.html:143 +msgid "Comment removed" +msgstr "Commentaire supprimé." + +#: frontend/templates/article_detail.html:155 +#: frontend/templates/article_detail.html:227 +msgid "Reply" +msgstr "Répondre" + +#: frontend/templates/article_detail.html:158 +#: frontend/templates/article_detail.html:164 +msgid "View replies" +msgstr "Voir les réponses" + +#: frontend/templates/article_detail.html:158 +#: frontend/templates/article_detail.html:164 +msgid "Hide replies" +msgstr "Masquer les réponses" + +#: frontend/templates/article_detail.html:180 +#, python-format +msgid "Comments (%(count)s)" +msgstr "Commentaires (%(count)s)" + +#: frontend/templates/article_detail.html:184 +msgid "No comments yet." +msgstr "Aucun commentaire pour l'instant." + +#: frontend/templates/article_detail.html:189 +#: frontend/templates/article_detail.html:233 +msgid "Join the discussion" +msgstr "Participer à la discussion" + +#: frontend/templates/article_detail.html:190 +msgid "Comment must be between 1 and 5000 characters" +msgstr "Le commentaire doit contenir entre 1 et 5 000 caractères" + +#: frontend/templates/article_detail.html:201 +#: frontend/templates/article_detail.html:232 +msgid "Post Comment" +msgstr "Publier le commentaire" + +#: frontend/templates/article_detail.html:202 +#: frontend/templates/article_detail.html:231 +#: frontend/templates/article_list.html:143 frontend/templates/base.html:115 +#: frontend/templates/base.html:139 frontend/templates/profile.html:93 +#: frontend/templates/profile.html:115 frontend/templates/user_list.html:143 +msgid "Cancel" +msgstr "Annuler" + +#: frontend/templates/article_detail.html:209 +#, python-format +msgid "" +"Please sign in to " +"join the conversation." +msgstr "" +"Veuillez vous connecter pour participer à la conversation." + +#: frontend/templates/article_detail.html:219 +msgid "Unable to render article content." +msgstr "Impossible d'afficher le contenu de l'article." + +#: frontend/templates/article_detail.html:221 +msgid "Comment cannot be empty" +msgstr "Le commentaire ne peut pas être vide." + +#: frontend/templates/article_detail.html:225 +msgid "Insert Emoji" +msgstr "Insérer un émoticône" + +#: frontend/templates/article_detail.html:229 +msgid "Reply to" +msgstr "Répondre à" + +#: frontend/templates/article_detail.html:230 +msgid "Editing comment" +msgstr "Modifier le commentaire" + +#: frontend/templates/article_edit.html:3 +msgid "Edit Article - DevJournal" +msgstr "Modifier l'article - DevJournal" + +#: frontend/templates/article_list.html:5 +msgid "Latest Articles - DevJournal" +msgstr "Derniers articles - DevJournal" + +#: frontend/templates/article_list.html:7 +msgid "" +"DevJournal - Personal thoughts on software development, programming and " +"technology." +msgstr "DevJournal - Réflexions personnelles sur le développement logiciel, la programmation et la technologie." +msgid "Search by title, description or author..." +msgstr "Rechercher par titre, description ou auteur..." + +#: frontend/templates/article_list.html:31 frontend/templates/user_list.html:25 +msgid "Search" +msgstr "Rechercher" + +#: frontend/templates/article_list.html:40 +#: frontend/templates/article_list.html:107 +#: frontend/templates/user_list.html:33 frontend/templates/user_list.html:110 +msgid "Previous" +msgstr "Précédent" + +#: frontend/templates/article_list.html:48 +#: frontend/templates/article_list.html:115 +#: frontend/templates/user_list.html:40 frontend/templates/user_list.html:117 +msgid "Next" +msgstr "Suivant" + +#: frontend/templates/article_list.html:97 +#, python-format +msgid "No articles match \"%(query)s\"." +msgstr "Aucun article ne correspond à \"%(query)s\"." + +#: frontend/templates/article_list.html:97 +msgid "No articles found in the database." +msgstr "Aucun article trouvé dans la base de données." + +#: frontend/templates/article_list.html:124 +#: frontend/templates/macros/pagination.html:20 +#: frontend/templates/macros/pagination.html:26 +#: frontend/templates/user_list.html:124 +msgid "Jump to page" +msgstr "Aller à la page" + +#: frontend/templates/article_list.html:128 +#, python-format +msgid "Enter page number (1 - %(max)s)" +msgstr "Entrez le numéro de page (1 - %(max)s)" + +#: frontend/templates/article_list.html:132 +#: frontend/templates/user_list.html:132 +msgid "Increase page" +msgstr "Augmenter la page" + +#: frontend/templates/article_list.html:135 +#: frontend/templates/user_list.html:135 +msgid "Decrease page" +msgstr "Diminuer la page" + +#: frontend/templates/article_list.html:144 +#: frontend/templates/user_list.html:144 +msgid "Go" +msgstr "Aller" + +#: frontend/templates/base.html:43 +msgid "Toggle Theme" +msgstr "Changer de thème" + +#: frontend/templates/base.html:49 +msgid "Switch language" +msgstr "Changer de langue" + +#: frontend/templates/base.html:55 frontend/templates/base.html:57 +msgid "Home" +msgstr "Accueil" + +#: frontend/templates/base.html:62 +msgid "New Article" +msgstr "Nouvel article" + +#: frontend/templates/base.html:64 +msgid "New article" +msgstr "Nouvel article" + +#: frontend/templates/base.html:68 frontend/templates/base.html:70 +#: frontend/templates/profile.html:4 +msgid "Profile" +msgstr "Profil" + +#: frontend/templates/base.html:74 frontend/templates/base.html:76 +msgid "Logout" +msgstr "Déconnexion" + +#: frontend/templates/base.html:80 frontend/templates/base.html:82 +#: frontend/templates/login.html:30 +msgid "Sign In" +msgstr "Connexion" + +#: frontend/templates/base.html:84 frontend/templates/base.html:86 +msgid "Sign Up" +msgstr "Inscription" + +#: frontend/templates/base.html:111 +msgid "Confirm" +msgstr "Confirmer" + +#: frontend/templates/base.html:122 frontend/templates/base.html:140 +#: frontend/templates/profile.html:152 frontend/templates/user_list.html:88 +msgid "Ban" +msgstr "Bannir" + +#: frontend/templates/base.html:128 +msgid "Reason (optional)" +msgstr "Raison (optionnelle)" + +#: frontend/templates/base.html:130 +msgid "Maximum 150 characters" +msgstr "Maximum 150 caractères" + +#: frontend/templates/base.html:134 +msgid "e.g., Spam, harassment..." +msgstr "Ex. : Spam, harcèlement..." + +#: frontend/templates/base.html:151 +#, python-format +msgid "© %(year)s DevJournal. All rights reserved." +msgstr "© %(year)s DevJournal. Tous droits réservés." + +#: frontend/templates/error.html:11 frontend/templates/login.html:42 +#: frontend/templates/profile.html:166 frontend/templates/registration.html:53 +#: frontend/templates/user_list.html:149 +msgid "Return to home" +msgstr "Retour à l'accueil" + +#: frontend/templates/login.html:4 +msgid "Sign In - DevJournal" +msgstr "Connexion - DevJournal" + +#: frontend/templates/login.html:13 +msgid "Welcome Back" +msgstr "Bon retour" + +#: frontend/templates/login.html:14 +msgid "Access your developer journal accounts." +msgstr "Accédez à vos comptes de développeur." + +#: frontend/templates/login.html:20 frontend/templates/user_list.html:49 +msgid "Username" +msgstr "Nom d'utilisateur" + +#: frontend/templates/login.html:21 frontend/templates/registration.html:21 +msgid "e.g., dev_journey" +msgstr "Ex. : dev_journey" + +#: frontend/templates/login.html:24 frontend/templates/registration.html:28 +msgid "Password" +msgstr "Mot de passe" + +#: frontend/templates/login.html:26 frontend/templates/profile.html:109 +#: frontend/templates/registration.html:30 +#: frontend/templates/registration.html:37 +msgid "e.g., MyP@ssw0rd!" +msgstr "Ex. : MyP@ssw0rd!" + +#: frontend/templates/login.html:27 frontend/templates/profile.html:110 +#: frontend/templates/registration.html:31 +#: frontend/templates/registration.html:38 +msgid "Toggle password visibility" +msgstr "Afficher/masquer le mot de passe" + +#: frontend/templates/login.html:36 +msgid "New to the system ?" +msgstr "Nouveau sur le site ?" + +#: frontend/templates/login.html:37 +msgid "Create an account" +msgstr "Créer un compte" + +#: frontend/templates/profile.html:21 +msgid "Upload Photo" +msgstr "Télécharger une photo" + +#: frontend/templates/profile.html:26 +msgid "Remove Photo" +msgstr "Supprimer la photo" + +#: frontend/templates/profile.html:37 frontend/templates/user_list.html:75 +msgid "User" +msgstr "Utilisateur" + +#: frontend/templates/profile.html:38 frontend/templates/user_list.html:76 +msgid "Author" +msgstr "Auteur" + +#: frontend/templates/profile.html:40 frontend/templates/user_list.html:78 +msgid "Update" +msgstr "Mettre à jour" + +#: frontend/templates/profile.html:49 frontend/templates/profile.html:87 +#: frontend/templates/user_list.html:50 +msgid "Email" +msgstr "Email" + +#: frontend/templates/profile.html:62 frontend/templates/user_list.html:54 +msgid "Member Since" +msgstr "Membre depuis" + +#: frontend/templates/profile.html:67 frontend/templates/user_list.html:66 +msgid "N/A" +msgstr "N/R" + +#: frontend/templates/profile.html:73 frontend/templates/user_list.html:52 +msgid "Status" +msgstr "Statut" + +#: frontend/templates/profile.html:74 frontend/templates/user_list.html:64 +msgid "Banned" +msgstr "Banni" + +#: frontend/templates/profile.html:74 frontend/templates/user_list.html:64 +msgid "Active" +msgstr "Actif" + +#: frontend/templates/profile.html:81 +msgid "Update Email" +msgstr "Modifier l'email" + +#: frontend/templates/profile.html:89 frontend/templates/registration.html:25 +msgid "e.g., user@example.com" +msgstr "Ex. : user@example.com" + +#: frontend/templates/profile.html:101 +msgid "Update Password" +msgstr "Modifier le mot de passe" + +#: frontend/templates/profile.html:107 +msgid "New Password" +msgstr "Nouveau mot de passe" + +#: frontend/templates/profile.html:124 +msgid "Manage Users" +msgstr "Gérer les utilisateurs" + +#: frontend/templates/profile.html:126 +msgid "Change Password" +msgstr "Changer le mot de passe" + +#: frontend/templates/profile.html:129 +msgid "Sign Out" +msgstr "Déconnexion" + +#: frontend/templates/profile.html:136 +msgid "" +"Are you sure you want to delete your account ? This action is " +"irreversible." +msgstr "" +"Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est " +"irréversible." + +#: frontend/templates/profile.html:136 frontend/templates/profile.html:159 +msgid "Delete Account" +msgstr "Supprimer le compte" + +#: frontend/templates/profile.html:147 frontend/templates/user_list.html:83 +msgid "Unban" +msgstr "Débannir" + +#: frontend/templates/profile.html:159 frontend/templates/user_list.html:95 +#, python-format +msgid "Delete user \\\"%(username)s\\\" ? This action is irreversible." +msgstr "Supprimer l'utilisateur \\\"%(username)s\\\" ? Action irréversible." + +#: frontend/templates/registration.html:4 +msgid "Sign Up - DevJournal" +msgstr "Inscription - DevJournal" + +#: frontend/templates/registration.html:13 +msgid "Join DevJournal" +msgstr "Rejoindre DevJournal" + +#: frontend/templates/registration.html:14 +msgid "Start your technical blog in seconds." +msgstr "Créez votre blog technique en quelques secondes." + +#: frontend/templates/registration.html:20 +msgid "Choose Username" +msgstr "Choisir un nom d'utilisateur" + +#: frontend/templates/registration.html:24 +msgid "Email Address" +msgstr "Adresse email" + +#: frontend/templates/registration.html:35 +msgid "Confirm Password" +msgstr "Confirmer le mot de passe" + +#: frontend/templates/registration.html:41 +msgid "Create Account" +msgstr "Créer un compte" + +#: frontend/templates/registration.html:47 +msgid "Already have an account ?" +msgstr "Déjà un compte ?" + +#: frontend/templates/registration.html:48 +msgid "Sign In instead" +msgstr "Connectez-vous" + +#: frontend/templates/user_list.html:4 +msgid "Users" +msgstr "Utilisateurs" + +#: frontend/templates/user_list.html:16 +#, python-format +msgid "Manage Users (%(count)d users)" +msgstr "Gérer les utilisateurs (%(count)d utilisateurs)" + +#: frontend/templates/user_list.html:20 +msgid "Filter by username or email..." +msgstr "Filtrer par nom d'utilisateur ou email..." + +#: frontend/templates/user_list.html:51 +msgid "Role" +msgstr "Rôle" + +#: frontend/templates/user_list.html:53 +msgid "Reason" +msgstr "Raison" + +#: frontend/templates/user_list.html:55 +msgid "Actions" +msgstr "Actions" + +#: frontend/templates/user_list.html:65 +msgid "—" +msgstr "—" + +#: frontend/templates/user_list.html:128 +#, python-format +msgid "Enter page number (1 - %(max)d)" +msgstr "Entrez le numéro de page (1 - %(max)d)" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:78 +msgid "You have been logged out." +msgstr "Vous avez été déconnecté." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:110 +msgid "Please sign in to view your profile." +msgstr "Veuillez vous connecter pour voir votre profil." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:167 +msgid "Authentication required." +msgstr "Authentification requise." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:171 +msgid "No file provided." +msgstr "Aucun fichier fourni." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:214 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:245 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:273 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:362 +msgid "Please sign in." +msgstr "Veuillez vous connecter." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:219 +msgid "No avatar to remove." +msgstr "Aucun avatar à supprimer." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:226 +msgid "Failed to remove profile photo." +msgstr "Échec de la suppression de la photo de profil." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:229 +msgid "Profile photo removed." +msgstr "Photo de profil supprimée." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:250 +msgid "Email is required." +msgstr "L'email est requis." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:257 +msgid "Email updated." +msgstr "Email mis à jour." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:278 +msgid "Password is required." +msgstr "Le mot de passe est requis." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:285 +msgid "Password updated." +msgstr "Mot de passe mis à jour." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:376 +msgid "Account not found." +msgstr "Compte introuvable." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:393 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:396 +msgid "Account deleted." +msgstr "Compte supprimé." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:435 +msgid "Role updated." +msgstr "Rôle mis à jour." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:470 +msgid "Account banned." +msgstr "Compte banni." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:501 +msgid "Account unbanned." +msgstr "Compte débanni." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:114 +#, python-format +msgid "Error: %(error)s" +msgstr "Erreur : %(error)s" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:156 +msgid "You must be signed in to author an article." +msgstr "Vous devez être connecté pour écrire un article." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:160 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:362 +msgid "Insufficient permissions: Only authors or admins can create articles." +msgstr "" +"Permissions insuffisantes : seuls les auteurs ou administrateurs peuvent " +"créer des articles." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:181 +msgid "Article not found." +msgstr "Article introuvable." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:217 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:262 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:307 +msgid "Unauthorized." +msgstr "Non autorisé." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:219 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:264 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:309 +msgid "Insufficient permissions." +msgstr "Permissions insuffisantes." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:223 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:268 +msgid "Invalid JSON body." +msgstr "Corps JSON invalide." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:234 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:279 +msgid "Validation error." +msgstr "Erreur de validation." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:333 +msgid "You must be logged in to delete articles." +msgstr "Vous devez être connecté pour supprimer des articles." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:339 +msgid "error" +msgstr "erreur" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:341 +msgid "Article deleted successfully." +msgstr "Article supprimé avec succès." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:358 +msgid "You must be signed in to edit an article." +msgstr "Vous devez être connecté pour modifier un article." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:367 +msgid "Error: The requested article could not be found." +msgstr "Erreur : l'article demandé est introuvable." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:371 +msgid "You do not have permission to edit this article." +msgstr "Vous n'avez pas la permission de modifier cet article." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:64 +msgid "You must be signed in to post a comment." +msgstr "Vous devez être connecté pour poster un commentaire." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:80 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:125 +#, python-format +msgid "You're posting too fast. Please wait %(remaining)ss before posting again." +msgstr "" +"Vous publiez trop vite. Veuillez patienter %(remaining)s seconde(s) avant" +" de publier à nouveau." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:92 +msgid "Comment added." +msgstr "Commentaire ajouté." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:109 +msgid "You must be signed in to reply." +msgstr "Vous devez être connecté pour répondre." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:137 +msgid "Reply added." +msgstr "Réponse ajoutée." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:154 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:216 +msgid "You must be signed in to delete comments." +msgstr "Vous devez être connecté pour supprimer des commentaires." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:165 +msgid "Comment deleted." +msgstr "Commentaire supprimé." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:167 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:229 +msgid "Unauthorized or error." +msgstr "Non autorisé ou erreur." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:185 +msgid "You must be signed in to edit comments." +msgstr "Vous devez être connecté pour modifier des commentaires." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:198 +msgid "Comment updated." +msgstr "Commentaire mis à jour." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:227 +msgid "Comment permanently deleted." +msgstr "Commentaire définitivement supprimé." + +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:33 +msgid "No file provided" +msgstr "Aucun fichier fourni" + +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:73 +msgid "File not found" +msgstr "Fichier introuvable" + +#: src/infrastructure/input_adapters/flask/flask_login_adapter.py:55 +#, python-format +msgid "Validation Error (%(location)s): %(message)s" +msgstr "Erreur de validation (%(location)s) : %(message)s" + +#: src/infrastructure/input_adapters/flask/flask_login_adapter.py:69 +msgid "Invalid username or password." +msgstr "Nom d'utilisateur ou mot de passe invalide." + +#: src/infrastructure/input_adapters/flask/flask_registration_adapter.py:58 +#, python-format +msgid "%(location)s: %(message)s" +msgstr "%(location)s : %(message)s" + +#: src/infrastructure/input_adapters/flask/flask_registration_adapter.py:71 +msgid "Registration successful. Please sign in." +msgstr "Inscription réussie. Veuillez vous connecter." + +#~ msgid "" +#~ "Are you sure you want to delete" +#~ " your account ? This action is " +#~ "irreversible." +#~ msgstr "" +#~ "Êtes-vous sûr de vouloir supprimer " +#~ "votre compte ? Cette action est " +#~ "irréversible." + +#~ msgid "Delete user \\\"%(username)s\\\" ? This action is irreversible." +#~ msgstr "Supprimer l'utilisateur \\\"%(username)s\\\" ? Action irréversible." + +#~ msgid "Insufficient permissions: Only authors or admins can create articles." +#~ msgstr "" +#~ "Permissions insuffisantes : seuls les " +#~ "auteurs et administrateurs peuvent créer " +#~ "des articles." + +#~ msgid "" +#~ "You're posting too fast. Please wait " +#~ "%(remaining)ss before posting again." +#~ msgstr "" +#~ "Vous postez trop vite. Veuillez attendre" +#~ " %(remaining)ss avant de reposter." + +#~ msgid "Upload failed." +#~ msgstr "Échec du téléchargement." + +#~ msgid "Failed to copy" +#~ msgstr "Échec de la copie" + +#~ msgid "Post Reply" +#~ msgstr "Publier la réponse" + +#~ msgid "admin" +#~ msgstr "Administrateur" + +#~ msgid "author" +#~ msgstr "Auteur" + +#~ msgid "user" +#~ msgstr "Utilisateur" + diff --git a/translations/messages.pot b/translations/messages.pot new file mode 100644 index 00000000..63c1bb5e --- /dev/null +++ b/translations/messages.pot @@ -0,0 +1,879 @@ +# Translations template for PROJECT. +# Copyright (C) 2026 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-07-23 20:31+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.10.3\n" + +#: blog_comment_application.py:212 +msgid "You do not have permission to access this page." +msgstr "" + +#: blog_comment_application.py:213 +msgid "The page you are looking for does not exist." +msgstr "" + +#: blog_comment_application.py:214 +msgid "An unexpected error occurred. Please try again later." +msgstr "" + +#: frontend/templates/article_create.html:3 +msgid "Write an Article - DevJournal" +msgstr "" + +#: frontend/templates/article_create.html:15 +#: frontend/templates/article_detail.html:47 +#: frontend/templates/article_edit.html:40 +msgid "Back to articles" +msgstr "" + +#: frontend/templates/article_create.html:28 +#: frontend/templates/article_edit.html:57 +msgid "Title is required." +msgstr "" + +#: frontend/templates/article_create.html:29 +#: frontend/templates/article_edit.html:58 +msgid "Failed to save." +msgstr "" + +#: frontend/templates/article_create.html:30 +#: frontend/templates/article_edit.html:59 +msgid "Network error." +msgstr "" + +#: frontend/templates/article_create.html:31 +#: frontend/templates/article_detail.html:218 +#: frontend/templates/article_edit.html:60 +msgid "Loading..." +msgstr "" + +#: frontend/templates/article_create.html:32 +#: frontend/templates/article_edit.html:61 +msgid "Unable to parse article content." +msgstr "" + +#: frontend/templates/article_create.html:33 +#: frontend/templates/article_edit.html:62 +msgid "Article title" +msgstr "" + +#: frontend/templates/article_create.html:34 +#: frontend/templates/article_edit.html:63 +msgid "Description" +msgstr "" + +#: frontend/templates/article_create.html:35 +#: frontend/templates/article_edit.html:64 +msgid "Maximum 300 characters" +msgstr "" + +#: frontend/templates/article_create.html:36 +#: frontend/templates/article_edit.html:65 +msgid "Short description (optional)" +msgstr "" + +#: frontend/templates/article_create.html:37 +#: frontend/templates/article_edit.html:66 +msgid "Content" +msgstr "" + +#: frontend/templates/article_create.html:38 +#: frontend/templates/article_edit.html:67 +msgid "Saving..." +msgstr "" + +#: frontend/templates/article_create.html:39 +#: frontend/templates/article_edit.html:68 +msgid "Publish" +msgstr "" + +#: frontend/templates/article_create.html:40 +#: frontend/templates/article_detail.html:228 +#: frontend/templates/article_edit.html:69 frontend/templates/profile.html:94 +#: frontend/templates/profile.html:116 +msgid "Save" +msgstr "" + +#: frontend/templates/article_create.html:41 +#: frontend/templates/article_edit.html:70 +msgid "YouTube" +msgstr "" + +#: frontend/templates/article_create.html:42 +#: frontend/templates/article_edit.html:71 +msgid "Paste a YouTube video URL" +msgstr "" + +#: frontend/templates/article_create.html:43 +#: frontend/templates/article_edit.html:72 +msgid "YouTube URL" +msgstr "" + +#: frontend/templates/article_create.html:44 +#: frontend/templates/article_edit.html:73 +msgid "Paste YouTube video link" +msgstr "" + +#: frontend/templates/article_create.html:45 +#: frontend/templates/article_edit.html:74 +msgid "Embed YouTube video" +msgstr "" + +#: frontend/templates/article_create.html:46 +#: frontend/templates/article_edit.html:75 +msgid "Add YouTube video URL" +msgstr "" + +#: frontend/templates/article_create.html:47 +#: frontend/templates/article_edit.html:76 +msgid "Only YouTube links are supported" +msgstr "" + +#: frontend/templates/article_create.html:48 +#: frontend/templates/article_detail.html:222 +#: frontend/templates/article_edit.html:77 +msgid "Failed to load article." +msgstr "" + +#: frontend/templates/article_create.html:49 +#: frontend/templates/article_detail.html:224 +#: frontend/templates/article_edit.html:78 +msgid "Copy" +msgstr "" + +#: frontend/templates/article_create.html:50 +#: frontend/templates/article_edit.html:79 +msgid "Copy Block" +msgstr "" + +#: frontend/templates/article_create.html:51 +#: frontend/templates/article_detail.html:220 +#: frontend/templates/article_edit.html:80 +msgid "Copied to clipboard" +msgstr "" + +#: frontend/templates/article_create.html:52 +#: frontend/templates/article_edit.html:81 +msgid "Move Up" +msgstr "" + +#: frontend/templates/article_create.html:53 +#: frontend/templates/article_edit.html:82 +msgid "Move Down" +msgstr "" + +#: frontend/templates/article_create.html:54 +#: frontend/templates/article_detail.html:79 +#: frontend/templates/article_edit.html:83 frontend/templates/base.html:116 +#: frontend/templates/user_list.html:95 +msgid "Delete" +msgstr "" + +#: frontend/templates/article_create.html:55 +#: frontend/templates/article_edit.html:84 +msgid "Align text justify" +msgstr "" + +#: frontend/templates/article_create.html:56 +#: frontend/templates/article_detail.html:223 +#: frontend/templates/article_edit.html:85 +msgid "Plain Text" +msgstr "" + +#: frontend/templates/article_create.html:57 +#: frontend/templates/article_edit.html:86 +msgid "Search languages..." +msgstr "" + +#: frontend/templates/article_create.html:58 +#: frontend/templates/article_edit.html:87 +msgid "No languages found" +msgstr "" + +#: frontend/templates/article_create.html:59 +#: frontend/templates/article_detail.html:226 +#: frontend/templates/article_edit.html:88 +msgid "Something went wrong. Please reload the page." +msgstr "" + +#: frontend/templates/article_detail.html:55 +#: frontend/templates/article_detail.html:120 +#: frontend/templates/article_list.html:70 +msgid "Anonymous" +msgstr "" + +#: frontend/templates/article_detail.html:72 +#: frontend/templates/article_detail.html:149 +#: frontend/templates/article_list.html:88 +#, python-format +msgid "edited at %(date)s" +msgstr "" + +#: frontend/templates/article_detail.html:78 +msgid "Edit" +msgstr "" + +#: frontend/templates/article_detail.html:79 +msgid "Delete this article permanently ?" +msgstr "" + +#: frontend/templates/article_detail.html:126 +#: frontend/templates/profile.html:53 +msgid "[Edit]" +msgstr "" + +#: frontend/templates/article_detail.html:131 +#: frontend/templates/article_detail.html:137 +msgid "[Delete]" +msgstr "" + +#: frontend/templates/article_detail.html:135 +msgid "Permanently delete this comment? This cannot be undone." +msgstr "" + +#: frontend/templates/article_detail.html:143 +msgid "Comment removed" +msgstr "" + +#: frontend/templates/article_detail.html:155 +#: frontend/templates/article_detail.html:227 +msgid "Reply" +msgstr "" + +#: frontend/templates/article_detail.html:158 +#: frontend/templates/article_detail.html:164 +msgid "View replies" +msgstr "" + +#: frontend/templates/article_detail.html:158 +#: frontend/templates/article_detail.html:164 +msgid "Hide replies" +msgstr "" + +#: frontend/templates/article_detail.html:180 +#, python-format +msgid "Comments (%(count)s)" +msgstr "" + +#: frontend/templates/article_detail.html:184 +msgid "No comments yet." +msgstr "" + +#: frontend/templates/article_detail.html:189 +#: frontend/templates/article_detail.html:233 +msgid "Join the discussion" +msgstr "" + +#: frontend/templates/article_detail.html:190 +msgid "Comment must be between 1 and 5000 characters" +msgstr "" + +#: frontend/templates/article_detail.html:201 +#: frontend/templates/article_detail.html:232 +msgid "Post Comment" +msgstr "" + +#: frontend/templates/article_detail.html:202 +#: frontend/templates/article_detail.html:231 +#: frontend/templates/article_list.html:143 frontend/templates/base.html:115 +#: frontend/templates/base.html:139 frontend/templates/profile.html:93 +#: frontend/templates/profile.html:115 frontend/templates/user_list.html:143 +msgid "Cancel" +msgstr "" + +#: frontend/templates/article_detail.html:209 +#, python-format +msgid "" +"Please sign in to " +"join the conversation." +msgstr "" + +#: frontend/templates/article_detail.html:219 +msgid "Unable to render article content." +msgstr "" + +#: frontend/templates/article_detail.html:221 +msgid "Comment cannot be empty" +msgstr "" + +#: frontend/templates/article_detail.html:225 +msgid "Insert Emoji" +msgstr "" + +#: frontend/templates/article_detail.html:229 +msgid "Reply to" +msgstr "" + +#: frontend/templates/article_detail.html:230 +msgid "Editing comment" +msgstr "" + +#: frontend/templates/article_edit.html:3 +msgid "Edit Article - DevJournal" +msgstr "" + +#: frontend/templates/article_list.html:5 +msgid "Latest Articles - DevJournal" +msgstr "" + +#: frontend/templates/article_list.html:7 +msgid "" +"DevJournal - Personal thoughts on software development, programming and " +"technology." +msgstr "" + +#: frontend/templates/article_list.html:26 +msgid "Search by title, description or author..." +msgstr "" + +#: frontend/templates/article_list.html:31 frontend/templates/user_list.html:25 +msgid "Search" +msgstr "" + +#: frontend/templates/article_list.html:40 +#: frontend/templates/article_list.html:107 +#: frontend/templates/user_list.html:33 frontend/templates/user_list.html:110 +msgid "Previous" +msgstr "" + +#: frontend/templates/article_list.html:48 +#: frontend/templates/article_list.html:115 +#: frontend/templates/user_list.html:40 frontend/templates/user_list.html:117 +msgid "Next" +msgstr "" + +#: frontend/templates/article_list.html:97 +#, python-format +msgid "No articles match \"%(query)s\"." +msgstr "" + +#: frontend/templates/article_list.html:97 +msgid "No articles found in the database." +msgstr "" + +#: frontend/templates/article_list.html:124 +#: frontend/templates/macros/pagination.html:20 +#: frontend/templates/macros/pagination.html:26 +#: frontend/templates/user_list.html:124 +msgid "Jump to page" +msgstr "" + +#: frontend/templates/article_list.html:128 +#, python-format +msgid "Enter page number (1 - %(max)s)" +msgstr "" + +#: frontend/templates/article_list.html:132 +#: frontend/templates/user_list.html:132 +msgid "Increase page" +msgstr "" + +#: frontend/templates/article_list.html:135 +#: frontend/templates/user_list.html:135 +msgid "Decrease page" +msgstr "" + +#: frontend/templates/article_list.html:144 +#: frontend/templates/user_list.html:144 +msgid "Go" +msgstr "" + +#: frontend/templates/base.html:43 +msgid "Toggle Theme" +msgstr "" + +#: frontend/templates/base.html:49 +msgid "Switch language" +msgstr "" + +#: frontend/templates/base.html:55 frontend/templates/base.html:57 +msgid "Home" +msgstr "" + +#: frontend/templates/base.html:62 +msgid "New Article" +msgstr "" + +#: frontend/templates/base.html:64 +msgid "New article" +msgstr "" + +#: frontend/templates/base.html:68 frontend/templates/base.html:70 +#: frontend/templates/profile.html:4 +msgid "Profile" +msgstr "" + +#: frontend/templates/base.html:74 frontend/templates/base.html:76 +msgid "Logout" +msgstr "" + +#: frontend/templates/base.html:80 frontend/templates/base.html:82 +#: frontend/templates/login.html:30 +msgid "Sign In" +msgstr "" + +#: frontend/templates/base.html:84 frontend/templates/base.html:86 +msgid "Sign Up" +msgstr "" + +#: frontend/templates/base.html:111 +msgid "Confirm" +msgstr "" + +#: frontend/templates/base.html:122 frontend/templates/base.html:140 +#: frontend/templates/profile.html:152 frontend/templates/user_list.html:88 +msgid "Ban" +msgstr "" + +#: frontend/templates/base.html:128 +msgid "Reason (optional)" +msgstr "" + +#: frontend/templates/base.html:130 +msgid "Maximum 150 characters" +msgstr "" + +#: frontend/templates/base.html:134 +msgid "e.g., Spam, harassment..." +msgstr "" + +#: frontend/templates/base.html:151 +#, python-format +msgid "© %(year)s DevJournal. All rights reserved." +msgstr "" + +#: frontend/templates/error.html:11 frontend/templates/login.html:42 +#: frontend/templates/profile.html:166 frontend/templates/registration.html:53 +#: frontend/templates/user_list.html:149 +msgid "Return to home" +msgstr "" + +#: frontend/templates/login.html:4 +msgid "Sign In - DevJournal" +msgstr "" + +#: frontend/templates/login.html:13 +msgid "Welcome Back" +msgstr "" + +#: frontend/templates/login.html:14 +msgid "Access your developer journal accounts." +msgstr "" + +#: frontend/templates/login.html:20 frontend/templates/user_list.html:49 +msgid "Username" +msgstr "" + +#: frontend/templates/login.html:21 frontend/templates/registration.html:21 +msgid "e.g., dev_journey" +msgstr "" + +#: frontend/templates/login.html:24 frontend/templates/registration.html:28 +msgid "Password" +msgstr "" + +#: frontend/templates/login.html:26 frontend/templates/profile.html:109 +#: frontend/templates/registration.html:30 +#: frontend/templates/registration.html:37 +msgid "e.g., MyP@ssw0rd!" +msgstr "" + +#: frontend/templates/login.html:27 frontend/templates/profile.html:110 +#: frontend/templates/registration.html:31 +#: frontend/templates/registration.html:38 +msgid "Toggle password visibility" +msgstr "" + +#: frontend/templates/login.html:36 +msgid "New to the system ?" +msgstr "" + +#: frontend/templates/login.html:37 +msgid "Create an account" +msgstr "" + +#: frontend/templates/profile.html:21 +msgid "Upload Photo" +msgstr "" + +#: frontend/templates/profile.html:26 +msgid "Remove Photo" +msgstr "" + +#: frontend/templates/profile.html:37 frontend/templates/user_list.html:75 +msgid "User" +msgstr "" + +#: frontend/templates/profile.html:38 frontend/templates/user_list.html:76 +msgid "Author" +msgstr "" + +#: frontend/templates/profile.html:40 frontend/templates/user_list.html:78 +msgid "Update" +msgstr "" + +#: frontend/templates/profile.html:49 frontend/templates/profile.html:87 +#: frontend/templates/user_list.html:50 +msgid "Email" +msgstr "" + +#: frontend/templates/profile.html:62 frontend/templates/user_list.html:54 +msgid "Member Since" +msgstr "" + +#: frontend/templates/profile.html:67 frontend/templates/user_list.html:66 +msgid "N/A" +msgstr "" + +#: frontend/templates/profile.html:73 frontend/templates/user_list.html:52 +msgid "Status" +msgstr "" + +#: frontend/templates/profile.html:74 frontend/templates/user_list.html:64 +msgid "Banned" +msgstr "" + +#: frontend/templates/profile.html:74 frontend/templates/user_list.html:64 +msgid "Active" +msgstr "" + +#: frontend/templates/profile.html:81 +msgid "Update Email" +msgstr "" + +#: frontend/templates/profile.html:89 frontend/templates/registration.html:25 +msgid "e.g., user@example.com" +msgstr "" + +#: frontend/templates/profile.html:101 +msgid "Update Password" +msgstr "" + +#: frontend/templates/profile.html:107 +msgid "New Password" +msgstr "" + +#: frontend/templates/profile.html:124 +msgid "Manage Users" +msgstr "" + +#: frontend/templates/profile.html:126 +msgid "Change Password" +msgstr "" + +#: frontend/templates/profile.html:129 +msgid "Sign Out" +msgstr "" + +#: frontend/templates/profile.html:136 +msgid "" +"Are you sure you want to delete your account ? This action is " +"irreversible." +msgstr "" + +#: frontend/templates/profile.html:136 frontend/templates/profile.html:159 +msgid "Delete Account" +msgstr "" + +#: frontend/templates/profile.html:147 frontend/templates/user_list.html:83 +msgid "Unban" +msgstr "" + +#: frontend/templates/profile.html:159 frontend/templates/user_list.html:95 +#, python-format +msgid "Delete user \\\"%(username)s\\\" ? This action is irreversible." +msgstr "" + +#: frontend/templates/registration.html:4 +msgid "Sign Up - DevJournal" +msgstr "" + +#: frontend/templates/registration.html:13 +msgid "Join DevJournal" +msgstr "" + +#: frontend/templates/registration.html:14 +msgid "Start your technical blog in seconds." +msgstr "" + +#: frontend/templates/registration.html:20 +msgid "Choose Username" +msgstr "" + +#: frontend/templates/registration.html:24 +msgid "Email Address" +msgstr "" + +#: frontend/templates/registration.html:35 +msgid "Confirm Password" +msgstr "" + +#: frontend/templates/registration.html:41 +msgid "Create Account" +msgstr "" + +#: frontend/templates/registration.html:47 +msgid "Already have an account ?" +msgstr "" + +#: frontend/templates/registration.html:48 +msgid "Sign In instead" +msgstr "" + +#: frontend/templates/user_list.html:4 +msgid "Users" +msgstr "" + +#: frontend/templates/user_list.html:16 +#, python-format +msgid "Manage Users (%(count)d users)" +msgstr "" + +#: frontend/templates/user_list.html:20 +msgid "Filter by username or email..." +msgstr "" + +#: frontend/templates/user_list.html:51 +msgid "Role" +msgstr "" + +#: frontend/templates/user_list.html:53 +msgid "Reason" +msgstr "" + +#: frontend/templates/user_list.html:55 +msgid "Actions" +msgstr "" + +#: frontend/templates/user_list.html:65 +msgid "—" +msgstr "" + +#: frontend/templates/user_list.html:128 +#, python-format +msgid "Enter page number (1 - %(max)d)" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:78 +msgid "You have been logged out." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:110 +msgid "Please sign in to view your profile." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:167 +msgid "Authentication required." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:171 +msgid "No file provided." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:214 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:245 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:273 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:362 +msgid "Please sign in." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:219 +msgid "No avatar to remove." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:226 +msgid "Failed to remove profile photo." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:229 +msgid "Profile photo removed." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:250 +msgid "Email is required." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:257 +msgid "Email updated." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:278 +msgid "Password is required." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:285 +msgid "Password updated." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:376 +msgid "Account not found." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:393 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:396 +msgid "Account deleted." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:435 +msgid "Role updated." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:470 +msgid "Account banned." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:501 +msgid "Account unbanned." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:114 +#, python-format +msgid "Error: %(error)s" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:156 +msgid "You must be signed in to author an article." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:160 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:362 +msgid "Insufficient permissions: Only authors or admins can create articles." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:181 +msgid "Article not found." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:217 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:262 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:307 +msgid "Unauthorized." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:219 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:264 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:309 +msgid "Insufficient permissions." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:223 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:268 +msgid "Invalid JSON body." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:234 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:279 +msgid "Validation error." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:333 +msgid "You must be logged in to delete articles." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:339 +msgid "error" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:341 +msgid "Article deleted successfully." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:358 +msgid "You must be signed in to edit an article." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:367 +msgid "Error: The requested article could not be found." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:371 +msgid "You do not have permission to edit this article." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:64 +msgid "You must be signed in to post a comment." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:80 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:125 +#, python-format +msgid "You're posting too fast. Please wait %(remaining)ss before posting again." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:92 +msgid "Comment added." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:109 +msgid "You must be signed in to reply." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:137 +msgid "Reply added." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:154 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:216 +msgid "You must be signed in to delete comments." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:165 +msgid "Comment deleted." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:167 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:229 +msgid "Unauthorized or error." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:185 +msgid "You must be signed in to edit comments." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:198 +msgid "Comment updated." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:227 +msgid "Comment permanently deleted." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:33 +msgid "No file provided" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:73 +msgid "File not found" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_login_adapter.py:55 +#, python-format +msgid "Validation Error (%(location)s): %(message)s" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_login_adapter.py:69 +msgid "Invalid username or password." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_registration_adapter.py:58 +#, python-format +msgid "%(location)s: %(message)s" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_registration_adapter.py:71 +msgid "Registration successful. Please sign in." +msgstr "" + diff --git a/utils/template_helpers.py b/utils/template_helpers.py index 58254a5a..f88fb052 100644 --- a/utils/template_helpers.py +++ b/utils/template_helpers.py @@ -1,7 +1,10 @@ import json import os from datetime import UTC, datetime +from zoneinfo import ZoneInfo +from babel.dates import format_datetime +from flask_babel import get_locale from markupsafe import Markup, escape @@ -116,21 +119,30 @@ def inject_current_year() -> dict[str, int]: return {"current_year": datetime.now(UTC).year} -def date_format_filter(date: datetime | None, format: str = "%b %d, %Y") -> str: + +def format_datetime_locale(date: datetime | None, format: str = "d MMMM yyyy 'à' HH:mm") -> str: """ - Jinja2 filter that formats a datetime into a human-readable date string. + Jinja2 filter that formats a UTC datetime to Europe/Paris local time + using Flask-Babel's locale-aware formatting. + + Respects the current application locale (fr, en, etc.) via + ``flask_babel.get_locale()``. Falls back to UTC if the datetime is naive. Args: - date: A datetime object to format, or None. - format: A strftime format string (default: ``"%b %d, %Y"``). + date: A UTC datetime object to format, or None. + format: A Babel format string (default: ``"d MMMM yyyy 'à' HH:mm"``). Returns: - The formatted date string (e.g. ``"Apr 29, 2026"``). - Returns ``"RECENT"`` if the input is None. + The formatted date string (e.g. ``"22 juillet 2026 à 10:54"`` in French, + ``"22 July 2026 à 10:54"`` in English). Returns empty string if date is None. """ if date is None: - return "RECENT" - return date.strftime(format) + return "" + if date.tzinfo is None: + date = date.replace(tzinfo=ZoneInfo("UTC")) + local = date.astimezone(ZoneInfo("Europe/Paris")) + locale = get_locale() + return format_datetime(local, format=format, locale=locale) def date_iso_filter(date: datetime | None) -> str: