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 %}
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/.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/__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);
+ });
+});
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",