From eac8cde272a25c07c865e2b89d5e2c3f1fe5a27c Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Fri, 6 Feb 2026 17:38:57 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8(front)=20add=20video=20conference?= =?UTF-8?q?=20link=20generation=20in=20event=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the visio conference feature in the event modal: - Add NEXT_PUBLIC_VISIO_BASE_URL env var for configurable base URL - Create generateVisioRoomId() utility (xxx-xxxx-xxx format, a-z) - Refactor VideoConferenceSection: button to create, link + remove when URL exists - Hide visio pill when env var is not configured (feature flag) - Add removeVisio i18n key in EN/FR/NL The visio URL is stored via the standard ICS URL property, which is already wired through useEventForm.toIcsEvent() and CalDavService. Co-Authored-By: Claude Opus 4.6 --- src/frontend/apps/calendars/.env | 3 +- src/frontend/apps/calendars/.env.development | 1 + .../components/scheduler/EventModal.tsx | 18 ++++--- .../VideoConferenceSection.tsx | 50 +++++++++++++------ .../generateVisioRoomId.ts | 7 +++ .../src/features/i18n/translations.json | 3 ++ 6 files changed, 59 insertions(+), 23 deletions(-) create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/generateVisioRoomId.ts diff --git a/src/frontend/apps/calendars/.env b/src/frontend/apps/calendars/.env index 8f22306..7ef3cda 100644 --- a/src/frontend/apps/calendars/.env +++ b/src/frontend/apps/calendars/.env @@ -1 +1,2 @@ -NEXT_PUBLIC_API_ORIGIN= \ No newline at end of file +NEXT_PUBLIC_API_ORIGIN= +NEXT_PUBLIC_VISIO_BASE_URL=https://visio.suite.anct.gouv.fr \ No newline at end of file diff --git a/src/frontend/apps/calendars/.env.development b/src/frontend/apps/calendars/.env.development index 73c257c..d53e487 100644 --- a/src/frontend/apps/calendars/.env.development +++ b/src/frontend/apps/calendars/.env.development @@ -1 +1,2 @@ NEXT_PUBLIC_API_ORIGIN=http://localhost:8921 +NEXT_PUBLIC_VISIO_BASE_URL=https://visio.suite.anct.gouv.fr diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx index b4632f0..f76c970 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx @@ -119,13 +119,19 @@ export const EventModal = ({ } }; + const visioBaseUrl = process.env.NEXT_PUBLIC_VISIO_BASE_URL; + const pills = useMemo( () => [ - { - id: "videoConference" as const, - icon: "videocam", - label: t("calendar.event.sections.addVideoConference"), - }, + ...(visioBaseUrl + ? [ + { + id: "videoConference" as const, + icon: "videocam", + label: t("calendar.event.sections.addVideoConference"), + }, + ] + : []), { id: "location" as const, icon: "place", @@ -147,7 +153,7 @@ export const EventModal = ({ label: t("calendar.event.attendees"), }, ], - [t], + [t, visioBaseUrl], ); return ( diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/VideoConferenceSection.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/VideoConferenceSection.tsx index abb2e6c..fc4883c 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/VideoConferenceSection.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/VideoConferenceSection.tsx @@ -1,7 +1,7 @@ -import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@gouvfr-lasuite/cunningham-react"; import { SectionRow } from "./SectionRow"; +import { generateVisioRoomId } from "./generateVisioRoomId"; interface VideoConferenceSectionProps { url: string; @@ -19,14 +19,12 @@ export const VideoConferenceSection = ({ onToggle, }: VideoConferenceSectionProps) => { const { t } = useTranslation(); - const [isCreating, setIsCreating] = useState(false); const handleCreateVisio = () => { - // Inert for now - will integrate with La Suite API in the future - setIsCreating(true); - setTimeout(() => { - setIsCreating(false); - }, 500); + const baseUrl = process.env.NEXT_PUBLIC_VISIO_BASE_URL; + if (!baseUrl) return; + const roomId = generateVisioRoomId(); + onChange(`${baseUrl}/${roomId}`); }; const handleRemove = () => { @@ -42,15 +40,35 @@ export const VideoConferenceSection = ({ isExpanded={isExpanded} onToggle={onToggle} > - + {url ? ( +
+ + {url} + +
+ ) : ( + + )} ); }; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/generateVisioRoomId.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/generateVisioRoomId.ts new file mode 100644 index 0000000..0d38251 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/generateVisioRoomId.ts @@ -0,0 +1,7 @@ +const randomLetters = (n: number): string => + Array.from({ length: n }, () => + String.fromCharCode(97 + Math.floor(Math.random() * 26)), + ).join(""); + +export const generateVisioRoomId = (): string => + `${randomLetters(3)}-${randomLetters(4)}-${randomLetters(3)}`; diff --git a/src/frontend/apps/calendars/src/features/i18n/translations.json b/src/frontend/apps/calendars/src/features/i18n/translations.json index 50928d8..80c7557 100644 --- a/src/frontend/apps/calendars/src/features/i18n/translations.json +++ b/src/frontend/apps/calendars/src/features/i18n/translations.json @@ -137,6 +137,7 @@ "addLocation": "Add location", "addVideoConference": "Visio", "createVisio": "Add video conference", + "removeVisio": "Remove video conference", "videoLink": "Video conference link", "addAttendees": "Add participants", "addDescription": "Add description", @@ -747,6 +748,7 @@ "addLocation": "Ajouter un lieu", "addVideoConference": "Visio", "createVisio": "Ajouter une visioconférence", + "removeVisio": "Supprimer la visioconférence", "videoLink": "Lien de visioconférence", "addAttendees": "Ajouter des participants", "addDescription": "Ajouter une description", @@ -1104,6 +1106,7 @@ "addLocation": "Locatie toevoegen", "addVideoConference": "Visio", "createVisio": "Add video conference", + "removeVisio": "Videoconferentie verwijderen", "videoLink": "Videoconferentie link", "addAttendees": "Deelnemers toevoegen", "addDescription": "Beschrijving toevoegen", From e26193dc3c799e89034d361679347603ab47a276 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Fri, 6 Feb 2026 17:39:08 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8(back)=20extract=20ICS=20URL=20pro?= =?UTF-8?q?perty=20and=20display=20in=20invitation=20emails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add visio conference URL support to invitation emails: - Add url field to EventDetails dataclass - Extract URL property in ICalendarParser.parse() - Fix extract_property regex to preserve full URLs (was truncating https:// by splitting on colon) - Add conditional visio section to all 8 email templates (invitation, update, cancel, reply — HTML and text) Co-Authored-By: Claude Opus 4.6 --- .../core/services/calendar_invitation_service.py | 14 +++++--------- .../core/templates/emails/calendar_invitation.html | 6 ++++++ .../core/templates/emails/calendar_invitation.txt | 1 + .../emails/calendar_invitation_cancel.html | 6 ++++++ .../emails/calendar_invitation_cancel.txt | 1 + .../emails/calendar_invitation_reply.html | 6 ++++++ .../templates/emails/calendar_invitation_reply.txt | 2 +- .../emails/calendar_invitation_update.html | 6 ++++++ .../emails/calendar_invitation_update.txt | 1 + 9 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/backend/core/services/calendar_invitation_service.py b/src/backend/core/services/calendar_invitation_service.py index 4d3e42c..7cde276 100644 --- a/src/backend/core/services/calendar_invitation_service.py +++ b/src/backend/core/services/calendar_invitation_service.py @@ -50,6 +50,7 @@ class EventDetails: summary: str description: Optional[str] location: Optional[str] + url: Optional[str] dtstart: datetime dtend: Optional[datetime] organizer_email: str @@ -95,17 +96,10 @@ class ICalendarParser: # Handle multi-line values (lines starting with space/tab are continuations) icalendar = re.sub(r"\r?\n[ \t]", "", icalendar) - pattern = rf"^{property_name}[;:](.+)$" + pattern = rf"^{property_name}(;[^:]*)?:(.+)$" match = re.search(pattern, icalendar, re.MULTILINE | re.IGNORECASE) if match: - value = match.group(1) - # Remove parameters (everything before the last colon if there are parameters) - if ";" in property_name or ":" not in value: - return value.strip() - # Handle properties with parameters like ORGANIZER;CN=Name:mailto:email - if ":" in value: - return value.split(":")[-1].strip() - return value.strip() + return match.group(2).strip() return None @staticmethod @@ -205,6 +199,7 @@ class ICalendarParser: summary = cls.extract_property(vevent_block, "SUMMARY") or "(Sans titre)" description = cls.extract_property(vevent_block, "DESCRIPTION") location = cls.extract_property(vevent_block, "LOCATION") + url = cls.extract_property(vevent_block, "URL") # Parse dates with timezone support - from VEVENT block only dtstart_raw, dtstart_params = cls.extract_property_with_params( @@ -265,6 +260,7 @@ class ICalendarParser: summary=summary, description=description, location=location, + url=url, dtstart=dtstart, dtend=dtend, organizer_email=organizer_email, diff --git a/src/backend/core/templates/emails/calendar_invitation.html b/src/backend/core/templates/emails/calendar_invitation.html index b4e0b9b..45e193e 100644 --- a/src/backend/core/templates/emails/calendar_invitation.html +++ b/src/backend/core/templates/emails/calendar_invitation.html @@ -119,6 +119,12 @@ {{ event.location }} {% endif %} + {% if event.url %} + + Visio + {{ event.url }} + + {% endif %} Organisateur {{ organizer_display }} <{{ event.organizer_email }}> diff --git a/src/backend/core/templates/emails/calendar_invitation.txt b/src/backend/core/templates/emails/calendar_invitation.txt index 90a81ed..fcac7bb 100644 --- a/src/backend/core/templates/emails/calendar_invitation.txt +++ b/src/backend/core/templates/emails/calendar_invitation.txt @@ -6,6 +6,7 @@ Détails de l'événement Titre : {{ event.summary }} Quand : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %} {% if event.location %}Lieu : {{ event.location }} +{% endif %}{% if event.url %}Visio : {{ event.url }} {% endif %}Organisateur : {{ organizer_display }} <{{ event.organizer_email }}> {% if event.description %} diff --git a/src/backend/core/templates/emails/calendar_invitation_cancel.html b/src/backend/core/templates/emails/calendar_invitation_cancel.html index 42c759a..42a05e5 100644 --- a/src/backend/core/templates/emails/calendar_invitation_cancel.html +++ b/src/backend/core/templates/emails/calendar_invitation_cancel.html @@ -114,6 +114,12 @@ {{ event.location }} {% endif %} + {% if event.url %} + + Visio + {{ event.url }} + + {% endif %} Organisateur {{ organizer_display }} <{{ event.organizer_email }}> diff --git a/src/backend/core/templates/emails/calendar_invitation_cancel.txt b/src/backend/core/templates/emails/calendar_invitation_cancel.txt index 8f479ec..ac4bada 100644 --- a/src/backend/core/templates/emails/calendar_invitation_cancel.txt +++ b/src/backend/core/templates/emails/calendar_invitation_cancel.txt @@ -6,6 +6,7 @@ Détails de l'événement annulé Titre : {{ event.summary }} Était prévu le : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %} {% if event.location %}Lieu : {{ event.location }} +{% endif %}{% if event.url %}Visio : {{ event.url }} {% endif %}Organisateur : {{ organizer_display }} <{{ event.organizer_email }}> Cet événement a été annulé. Vous pouvez le supprimer de votre calendrier en ouvrant le fichier .ics en pièce jointe. diff --git a/src/backend/core/templates/emails/calendar_invitation_reply.html b/src/backend/core/templates/emails/calendar_invitation_reply.html index 3ccf54d..09aa9e4 100644 --- a/src/backend/core/templates/emails/calendar_invitation_reply.html +++ b/src/backend/core/templates/emails/calendar_invitation_reply.html @@ -101,6 +101,12 @@ {{ event.location }} {% endif %} + {% if event.url %} + + Visio + {{ event.url }} + + {% endif %} Participant {{ attendee_display }} <{{ event.attendee_email }}> diff --git a/src/backend/core/templates/emails/calendar_invitation_reply.txt b/src/backend/core/templates/emails/calendar_invitation_reply.txt index 694a9c0..93dd626 100644 --- a/src/backend/core/templates/emails/calendar_invitation_reply.txt +++ b/src/backend/core/templates/emails/calendar_invitation_reply.txt @@ -6,8 +6,8 @@ Détails de l'événement Titre : {{ event.summary }} Quand : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %} {% if event.location %}Lieu : {{ event.location }} +{% endif %}{% if event.url %}Visio : {{ event.url }} {% endif %} - La réponse du participant a été enregistrée dans le fichier .ics en pièce jointe. --- diff --git a/src/backend/core/templates/emails/calendar_invitation_update.html b/src/backend/core/templates/emails/calendar_invitation_update.html index 944c69c..d4dd8ab 100644 --- a/src/backend/core/templates/emails/calendar_invitation_update.html +++ b/src/backend/core/templates/emails/calendar_invitation_update.html @@ -124,6 +124,12 @@ {{ event.location }} {% endif %} + {% if event.url %} + + Visio + {{ event.url }} + + {% endif %} Organisateur {{ organizer_display }} <{{ event.organizer_email }}> diff --git a/src/backend/core/templates/emails/calendar_invitation_update.txt b/src/backend/core/templates/emails/calendar_invitation_update.txt index 4a24e55..64abb75 100644 --- a/src/backend/core/templates/emails/calendar_invitation_update.txt +++ b/src/backend/core/templates/emails/calendar_invitation_update.txt @@ -6,6 +6,7 @@ Détails de l'événement mis à jour Titre : {{ event.summary }} Quand : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %} {% if event.location %}Lieu : {{ event.location }} +{% endif %}{% if event.url %}Visio : {{ event.url }} {% endif %}Organisateur : {{ organizer_display }} <{{ event.organizer_email }}> {% if event.description %} From 52a26f5747d201bef0cfef7572f13e04ba15c6e5 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Fri, 6 Feb 2026 17:39:16 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85(front,back)=20add=20tests=20for?= =?UTF-8?q?=20visio=20conference=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frontend: 3 unit tests for generateVisioRoomId (format, length, uniqueness) - Backend: 7 tests for ICalendarParser URL extraction and email template rendering with/without visio URL Co-Authored-By: Claude Opus 4.6 --- .../tests/test_calendar_invitation_service.py | 100 ++++++++++++++++++ .../__tests__/generateVisioRoomId.test.ts | 20 ++++ 2 files changed, 120 insertions(+) create mode 100644 src/backend/core/tests/test_calendar_invitation_service.py create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/__tests__/generateVisioRoomId.test.ts diff --git a/src/backend/core/tests/test_calendar_invitation_service.py b/src/backend/core/tests/test_calendar_invitation_service.py new file mode 100644 index 0000000..dac8779 --- /dev/null +++ b/src/backend/core/tests/test_calendar_invitation_service.py @@ -0,0 +1,100 @@ +"""Tests for ICalendarParser and email template rendering.""" + +from django.template.loader import render_to_string + +import pytest + +from core.services.calendar_invitation_service import ( + CalendarInvitationService, + ICalendarParser, +) + +# Sample ICS with URL property +ICS_WITH_URL = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:test-123 +DTSTART:20260210T140000Z +DTEND:20260210T150000Z +SUMMARY:Réunion d'équipe +DESCRIPTION:Point hebdomadaire +LOCATION:Salle 301 +URL:https://visio.numerique.gouv.fr/abc-defg-hij +ORGANIZER;CN=Alice:mailto:alice@example.com +ATTENDEE;CN=Bob;RSVP=TRUE:mailto:bob@example.com +SEQUENCE:0 +END:VEVENT +END:VCALENDAR""" + +# Sample ICS without URL property +ICS_WITHOUT_URL = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:test-456 +DTSTART:20260210T140000Z +DTEND:20260210T150000Z +SUMMARY:Simple meeting +ORGANIZER;CN=Alice:mailto:alice@example.com +ATTENDEE;CN=Bob;RSVP=TRUE:mailto:bob@example.com +SEQUENCE:0 +END:VEVENT +END:VCALENDAR""" + + +class TestICalendarParserUrl: + """Tests for URL property extraction in ICalendarParser.""" + + def test_parse_extracts_url_when_present(self): + event = ICalendarParser.parse(ICS_WITH_URL, "bob@example.com") + assert event is not None + assert event.url == "https://visio.numerique.gouv.fr/abc-defg-hij" + + def test_parse_url_is_none_when_absent(self): + event = ICalendarParser.parse(ICS_WITHOUT_URL, "bob@example.com") + assert event is not None + assert event.url is None + + def test_parse_preserves_other_fields_with_url(self): + event = ICalendarParser.parse(ICS_WITH_URL, "bob@example.com") + assert event is not None + assert event.summary == "Réunion d'équipe" + assert event.description == "Point hebdomadaire" + assert event.location == "Salle 301" + assert event.organizer_email == "alice@example.com" + + +@pytest.mark.django_db +class TestEmailTemplateVisioUrl: + """Tests for visio URL rendering in email templates.""" + + def _build_context(self, event): + service = CalendarInvitationService() + return service._build_template_context(event, "REQUEST") + + def test_invitation_html_contains_visio_link(self): + event = ICalendarParser.parse(ICS_WITH_URL, "bob@example.com") + context = self._build_context(event) + html = render_to_string("emails/calendar_invitation.html", context) + assert "https://visio.numerique.gouv.fr/abc-defg-hij" in html + assert "Visio" in html + + def test_invitation_txt_contains_visio_link(self): + event = ICalendarParser.parse(ICS_WITH_URL, "bob@example.com") + context = self._build_context(event) + txt = render_to_string("emails/calendar_invitation.txt", context) + assert "https://visio.numerique.gouv.fr/abc-defg-hij" in txt + assert "Visio" in txt + + def test_invitation_html_no_visio_when_absent(self): + event = ICalendarParser.parse(ICS_WITHOUT_URL, "bob@example.com") + context = self._build_context(event) + html = render_to_string("emails/calendar_invitation.html", context) + assert "Visio" not in html + + def test_invitation_txt_no_visio_when_absent(self): + event = ICalendarParser.parse(ICS_WITHOUT_URL, "bob@example.com") + context = self._build_context(event) + txt = render_to_string("emails/calendar_invitation.txt", context) + assert "Visio" not in txt diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/__tests__/generateVisioRoomId.test.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/__tests__/generateVisioRoomId.test.ts new file mode 100644 index 0000000..4a137c0 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/__tests__/generateVisioRoomId.test.ts @@ -0,0 +1,20 @@ +import { generateVisioRoomId } from "../generateVisioRoomId"; + +describe("generateVisioRoomId", () => { + it("returns a string matching the xxx-xxxx-xxx format", () => { + const id = generateVisioRoomId(); + expect(id).toMatch(/^[a-z]{3}-[a-z]{4}-[a-z]{3}$/); + }); + + it("generates different IDs on subsequent calls", () => { + const ids = new Set( + Array.from({ length: 20 }, () => generateVisioRoomId()), + ); + expect(ids.size).toBeGreaterThan(1); + }); + + it("has exactly 12 characters (3 + 1 + 4 + 1 + 3)", () => { + const id = generateVisioRoomId(); + expect(id.length).toBe(12); + }); +});