diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 84efabce..9c232bfa 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -5652,13 +5652,19 @@ "mailbox_id": { "type": "string", "format": "uuid", - "description": "Mailbox UUID. Required when flag is 'unread'." + "description": "Mailbox UUID. Required when flag is 'unread' or 'starred'." }, "read_at": { "type": "string", "format": "date-time", "nullable": true, "description": "Timestamp up to which messages are considered read. When provided with flag='unread', sets ThreadAccess.read_at directly. null means nothing has been read (all messages unread)." + }, + "starred_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp when the thread was starred. When provided with flag='starred' and value=true, sets ThreadAccess.starred_at. null or value=false removes the starred flag." } }, "required": [ @@ -7024,10 +7030,6 @@ "type": "boolean", "readOnly": true }, - "is_starred": { - "type": "boolean", - "readOnly": true - }, "is_trashed": { "type": "boolean", "readOnly": true @@ -7068,7 +7070,6 @@ "is_archived", "is_draft", "is_sender", - "is_starred", "is_trashed", "is_unread", "parent_id", @@ -8123,13 +8124,20 @@ "format": "date-time", "readOnly": true, "nullable": true + }, + "starred_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true } }, "required": [ "id", "mailbox", "read_at", - "role" + "role", + "starred_at" ] }, "ThreadAccessRequest": { diff --git a/src/backend/core/api/viewsets/flag.py b/src/backend/core/api/viewsets/flag.py index 5c54d333..be1580ae 100644 --- a/src/backend/core/api/viewsets/flag.py +++ b/src/backend/core/api/viewsets/flag.py @@ -194,7 +194,7 @@ class ChangeFlagView(APIView): ).values_list("thread_id", flat=True) # Unread and starred are personal actions that don't require EDITOR access. - if flag not in ('unread', 'starred'): + if flag not in ("unread", "starred"): accessible_thread_ids_qs = accessible_thread_ids_qs.filter( role__in=enums.THREAD_ROLES_CAN_EDIT ) @@ -204,19 +204,19 @@ class ChangeFlagView(APIView): mailbox_id=mailbox_id ) + if flag in ("unread", "starred") and not thread_ids and message_ids: + # If no thread_ids but we have message_ids, we need to get the thread_ids from the messages + thread_ids = ( + models.Message.objects.filter( + id__in=message_ids, + thread_id__in=accessible_thread_ids_qs, + ) + .values_list("thread_id", flat=True) + .distinct() + ) + with transaction.atomic(): if flag == "unread": - if not thread_ids and message_ids: - # If no thread_ids but we have message_ids, we need to get the thread_ids from the messages - thread_ids = ( - models.Message.objects.filter( - id__in=message_ids, - thread_id__in=accessible_thread_ids_qs, - ) - .values_list("thread_id", flat=True) - .distinct() - ) - return self._handle_unread_flag( request, thread_ids, @@ -365,7 +365,11 @@ class ChangeFlagView(APIView): updated_count = accesses.update(read_at=read_at) if thread_ids_to_sync: - update_threads_mailbox_flags_task.delay(thread_ids_to_sync) + transaction.on_commit( + lambda ids=thread_ids_to_sync: update_threads_mailbox_flags_task.delay( + ids + ) + ) return drf.response.Response( {"success": True, "updated_threads": updated_count} @@ -412,14 +416,17 @@ class ChangeFlagView(APIView): mailbox_id=mailbox_id, ) - with transaction.atomic(): - thread_ids_to_sync = [ - str(tid) for tid in accesses.values_list("thread_id", flat=True) - ] - updated_count = accesses.update(starred_at=starred_at) + thread_ids_to_sync = [ + str(tid) for tid in accesses.values_list("thread_id", flat=True) + ] + updated_count = accesses.update(starred_at=starred_at) if thread_ids_to_sync: - update_threads_mailbox_flags_task.delay(thread_ids_to_sync) + transaction.on_commit( + lambda ids=thread_ids_to_sync: update_threads_mailbox_flags_task.delay( + ids + ) + ) return drf.response.Response( {"success": True, "updated_threads": updated_count} diff --git a/src/backend/core/tests/api/test_messages_flag.py b/src/backend/core/tests/api/test_messages_flag.py index 86e6b294..959e97a8 100644 --- a/src/backend/core/tests/api/test_messages_flag.py +++ b/src/backend/core/tests/api/test_messages_flag.py @@ -321,6 +321,7 @@ def test_api_flag_unread_per_mailbox_isolation(api_client): assert access2.read_at is None +@pytest.mark.django_db(transaction=True) def test_api_flag_read_at_syncs_opensearch(api_client, settings): """Sending read_at should explicitly sync OpenSearch.""" settings.OPENSEARCH_INDEX_THREADS = True @@ -534,6 +535,7 @@ def test_api_flag_starred_requires_mailbox_id(api_client): assert "mailbox_id" in response.data["detail"] +@pytest.mark.django_db(transaction=True) @patch("core.api.viewsets.flag.update_threads_mailbox_flags_task") def test_api_flag_mark_thread_starred_success(mock_task, api_client): """Test starring a thread sets starred_at on the ThreadAccess.""" @@ -564,6 +566,7 @@ def test_api_flag_mark_thread_starred_success(mock_task, api_client): mock_task.delay.assert_called_once() +@pytest.mark.django_db(transaction=True) @patch("core.api.viewsets.flag.update_threads_mailbox_flags_task") def test_api_flag_mark_thread_unstarred_success(mock_task, api_client): """Test unstarring a thread clears starred_at on the ThreadAccess.""" @@ -624,6 +627,32 @@ def test_api_flag_starred_scoped_to_mailbox(api_client): assert access_b.starred_at is None +def test_api_flag_starred_no_permission_on_mailbox(api_client): + """Test that starring via a mailbox the user doesn't have access to does nothing.""" + user = UserFactory() + api_client.force_authenticate(user=user) + other_mailbox = MailboxFactory() # User does not have access + thread = ThreadFactory() + access = ThreadAccessFactory( + mailbox=other_mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR + ) + MessageFactory(thread=thread) + + data = { + "flag": "starred", + "value": True, + "thread_ids": [str(thread.id)], + "mailbox_id": str(other_mailbox.id), + } + response = api_client.post(API_URL, data=data, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["updated_threads"] == 0 + + access.refresh_from_db() + assert access.starred_at is None + + def test_api_flag_viewer_can_star_thread(api_client): """Test that a VIEWER can star a thread (personal action).""" user = UserFactory() diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 481e984a..00690cfe 100644 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -9,6 +9,8 @@ "{{count}} hours ago_other": "{{count}} hours ago", "{{count}} messages_one": "{{count}} message", "{{count}} messages_other": "{{count}} messages", + "{{count}} messages are now starred._one": "The message is now starred.", + "{{count}} messages are now starred._other": "{{count}} messages are now starred.", "{{count}} messages have been archived._one": "The message has been archived.", "{{count}} messages have been archived._other": "{{count}} messages have been archived.", "{{count}} messages have been deleted._one": "The message has been deleted.", @@ -31,6 +33,8 @@ "{{count}} results_other": "{{count}} results", "{{count}} selected threads_one": "{{count}} selected thread", "{{count}} selected threads_other": "{{count}} selected threads", + "{{count}} threads are now starred._one": "The thread is now starred.", + "{{count}} threads are now starred._other": "{{count}} threads are now starred.", "{{count}} threads have been archived._one": "The thread has been archived.", "{{count}} threads have been archived._other": "{{count}} threads have been archived.", "{{count}} threads have been deleted._one": "The thread has been deleted.", @@ -321,7 +325,6 @@ "Mark {{count}} threads as unread_other": "Mark {{count}} threads as unread", "Mark all as read": "Mark all as read", "Mark all as unread": "Mark all as unread", - "Mark as important": "Mark as important", "Mark as read": "Mark as read", "Mark as read from here": "Mark as read from here", "Mark as unread": "Mark as unread", @@ -447,6 +450,11 @@ "Spam": "Spam", "Spam report removed from {{count}} threads._one": "Spam report removed from the thread.", "Spam report removed from {{count}} threads._other": "Spam report removed from {{count}} threads.", + "Star {{count}} threads_one": "Star {{count}} thread", + "Star {{count}} threads_other": "Star {{count}} threads", + "Star from here": "Star from here", + "Star this thread": "Star this thread", + "Starred": "Starred", "Start typing...": "Start typing...", "Subject": "Subject", "Subject template": "Subject template", @@ -498,6 +506,7 @@ "This signature is forced": "This signature is forced", "This thread has been reported as spam.": "This thread has been reported as spam.", "This thread has been reported as spam. For your security, downloading attachments has been disabled.": "This thread has been reported as spam. For your security, downloading attachments has been disabled.", + "This thread has been starred.": "This thread has been starred.", "Those message templates are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Those message templates are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.", "Those signatures are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Those signatures are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.", "Thread access removed": "Thread access removed", @@ -524,6 +533,9 @@ "Unknown user": "Unknown user", "Unread": "Unread", "Unsaved changes": "Unsaved changes", + "Unstar {{count}} threads_one": "Unstar {{count}} thread", + "Unstar {{count}} threads_other": "Unstar {{count}} threads", + "Unstar this thread": "Unstar this thread", "until {{date}}": "until {{date}}", "Update": "Update", "Update a Label": "Update a Label", diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index 71110ef6..a10ca577 100644 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -14,6 +14,9 @@ "{{count}} messages_one": "{{count}} message", "{{count}} messages_many": "{{count}} messages", "{{count}} messages_other": "{{count}} messages", + "{{count}} messages are now starred._one": "{{count}} message est maintenant marqué pour suivi.", + "{{count}} messages are now starred._many": "{{count}} messages sont maintenant marqués pour suivi.", + "{{count}} messages are now starred._other": "{{count}} messages sont maintenant marqués pour suivi.", "{{count}} messages have been archived._one": "Le message a été archivé.", "{{count}} messages have been archived._many": "{{count}} messages ont été archivés.", "{{count}} messages have been archived._other": "{{count}} messages ont été archivés.", @@ -47,6 +50,9 @@ "{{count}} selected threads_one": "{{count}} conversation sélectionnée", "{{count}} selected threads_many": "{{count}} conversations sélectionnées", "{{count}} selected threads_other": "{{count}} conversations sélectionnées", + "{{count}} threads are now starred._one": "{{count}} conversation est maintenant marquée pour suivi.", + "{{count}} threads are now starred._many": "{{count}} conversations sont maintenant marquées pour suivi.", + "{{count}} threads are now starred._other": "{{count}} conversations sont maintenant marquées pour suivi.", "{{count}} threads have been archived._one": "La conversation a été archivée.", "{{count}} threads have been archived._many": "{{count}} conversations ont été archivées.", "{{count}} threads have been archived._other": "{{count}} conversations ont été archivées.", @@ -356,7 +362,6 @@ "Mark {{count}} threads as unread_other": "Marquer {{count}} conversations comme non lues", "Mark all as read": "Tout marquer comme lu", "Mark all as unread": "Tout marquer comme non lu", - "Mark as important": "Marquer comme important", "Mark as read": "Marquer comme lu", "Mark as read from here": "Marquer comme lu à partir d'ici", "Mark as unread": "Marquer comme non lu", @@ -487,6 +492,12 @@ "Spam report removed from {{count}} threads._one": "Le signalement spam a été annulé.", "Spam report removed from {{count}} threads._many": "{{count}} signalements spam ont été annulés.", "Spam report removed from {{count}} threads._other": "{{count}} signalements spam ont été annulés.", + "Star {{count}} threads_one": "Suivre {{count}} conversation", + "Star {{count}} threads_many": "Suivre {{count}} conversations", + "Star {{count}} threads_other": "Suivre {{count}} conversations", + "Star from here": "Suivre à partir d'ici", + "Star this thread": "Suivre cette conversation", + "Starred": "Suivi", "Start typing...": "Commencez à écrire...", "Subject": "Objet", "Subject template": "Modèle d'objet", @@ -539,6 +550,7 @@ "This signature is forced": "Cette signature est forcée", "This thread has been reported as spam.": "Cette conversation a été signalée comme spam.", "This thread has been reported as spam. For your security, downloading attachments has been disabled.": "Cette conversation a été signalée comme spam. Pour votre sécurité, le téléchargement des pièces jointes a été désactivé.", + "This thread has been starred.": "Cette conversation a été marquée pour suivi.", "Those message templates are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Ces modèles de message sont liés à la boîte aux lettres \"{{mailbox}}\". Dans le cas d'une boîte aux lettres partagée, tous les autres utilisateurs de la boîte aux lettres pourront les utiliser.", "Those signatures are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Ces signatures sont liées à la boîte aux lettres \"{{mailbox}}\". Dans le cas d'une boîte aux lettres partagée, tous les autres utilisateurs de la boîte aux lettres pourront les utiliser.", "Thread access removed": "Accès à la conversation supprimé", @@ -567,6 +579,10 @@ "Unknown user": "Utilisateur inconnu", "Unread": "Non lu", "Unsaved changes": "Modifications non enregistrées", + "Unstar {{count}} threads_one": "Ne plus suivre {{count}} conversation", + "Unstar {{count}} threads_many": "Ne plus suivre {{count}} conversations", + "Unstar {{count}} threads_other": "Ne plus suivre {{count}} conversations", + "Unstar this thread": "Ne plus suivre cette conversation", "until {{date}}": "jusqu'au {{date}}", "Update": "Mettre à jour", "Update a Label": "Modifier un libellé", diff --git a/src/frontend/src/features/api/gen/models/change_flag_request_request.ts b/src/frontend/src/features/api/gen/models/change_flag_request_request.ts index 07ce4171..94df1c32 100644 --- a/src/frontend/src/features/api/gen/models/change_flag_request_request.ts +++ b/src/frontend/src/features/api/gen/models/change_flag_request_request.ts @@ -14,11 +14,16 @@ export interface ChangeFlagRequestRequest { message_ids?: string[]; /** List of thread UUIDs where all messages should have the flag change applied. */ thread_ids?: string[]; - /** Mailbox UUID. Required when flag is 'unread'. */ + /** Mailbox UUID. Required when flag is 'unread' or 'starred'. */ mailbox_id?: string; /** * Timestamp up to which messages are considered read. When provided with flag='unread', sets ThreadAccess.read_at directly. null means nothing has been read (all messages unread). * @nullable */ read_at?: string | null; + /** + * Timestamp when the thread was starred. When provided with flag='starred' and value=true, sets ThreadAccess.starred_at. null or value=false removes the starred flag. + * @nullable + */ + starred_at?: string | null; } diff --git a/src/frontend/src/features/api/gen/models/message.ts b/src/frontend/src/features/api/gen/models/message.ts index ae8ea250..4edb50a7 100644 --- a/src/frontend/src/features/api/gen/models/message.ts +++ b/src/frontend/src/features/api/gen/models/message.ts @@ -46,7 +46,6 @@ export interface Message { readonly is_sender: boolean; readonly is_draft: boolean; readonly is_unread: boolean; - readonly is_starred: boolean; readonly is_trashed: boolean; readonly is_archived: boolean; readonly has_attachments: boolean; diff --git a/src/frontend/src/features/api/gen/models/thread_access_detail.ts b/src/frontend/src/features/api/gen/models/thread_access_detail.ts index 1ec7f699..65e8a2bc 100644 --- a/src/frontend/src/features/api/gen/models/thread_access_detail.ts +++ b/src/frontend/src/features/api/gen/models/thread_access_detail.ts @@ -18,4 +18,6 @@ export interface ThreadAccessDetail { readonly role: ThreadAccessRoleChoices; /** @nullable */ readonly read_at: string | null; + /** @nullable */ + readonly starred_at: string | null; } diff --git a/src/frontend/src/features/forms/components/search-filters-form/index.tsx b/src/frontend/src/features/forms/components/search-filters-form/index.tsx index 277a7495..9f7d353e 100644 --- a/src/frontend/src/features/forms/components/search-filters-form/index.tsx +++ b/src/frontend/src/features/forms/components/search-filters-form/index.tsx @@ -2,7 +2,7 @@ import { MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/com import { SearchHelper } from "@/features/utils/search-helper"; import { Label } from "@gouvfr-lasuite/ui-kit"; import { Button, Checkbox, Input, Select } from "@gouvfr-lasuite/cunningham-react"; -import { useRef } from "react"; +import { useId, useRef } from "react"; import { useTranslation } from "react-i18next"; type SearchFiltersFormProps = { @@ -12,6 +12,7 @@ type SearchFiltersFormProps = { export const SearchFiltersForm = ({ query, onChange }: SearchFiltersFormProps) => { const { t, i18n } = useTranslation(); + const starredLabelId = useId(); const formRef = useRef(null); const updateQuery = (submit: boolean) => { @@ -86,6 +87,10 @@ export const SearchFiltersForm = ({ query, onChange }: SearchFiltersFormProps) = +
+ + +