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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ start: ## start all development services
.PHONY: start

start-back: ## start backend services only (for local frontend development)
@$(COMPOSE) up --force-recreate -d worker-dev
@$(COMPOSE) up --force-recreate -d backend-dev worker-dev
.PHONY: start-back

status: ## an alias for "docker compose ps"
Expand Down Expand Up @@ -272,6 +272,21 @@ reset-db: build ## flush database and re-run migrations
@$(MAKE) migrate-caldav
.PHONY: reset-db

reset-db-full: ## drop and recreate database, run all migrations + CalDAV schema
@echo "$(BOLD)Stopping services using the database...$(RESET)"
@$(COMPOSE) stop backend-dev worker-dev caldav 2>/dev/null || true
@$(COMPOSE) up -d postgresql
@sleep 2
@echo "$(BOLD)Dropping and recreating database...$(RESET)"
@$(COMPOSE) exec postgresql psql -U pgroot -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'calendars' AND pid <> pg_backend_pid();" > /dev/null 2>&1 || true
@$(COMPOSE) exec postgresql dropdb -U pgroot --if-exists calendars
@$(COMPOSE) exec postgresql createdb -U pgroot calendars
@echo "$(GREEN)Database recreated$(RESET)"
@$(MAKE) migrate
@$(MAKE) migrate-caldav
@echo "$(GREEN)All migrations applied. Run 'make start' to restart services.$(RESET)"
.PHONY: reset-db-full

demo: ## flush db then create a demo
@$(MAKE) reset-db
@$(MANAGE) create_demo
Expand Down
124 changes: 124 additions & 0 deletions docs/external-subscriptions-test-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Plan de test manuel — Import de calendriers externes

## Prérequis

- `make start` (tous les services up)
- `sync_all_subscriptions` et `cleanup_orphan_subscriptions` sont déclenchés par cron externe (voir docs de déploiement)
- Être connecté sur http://localhost:8930
- Avoir au moins un calendrier personnel existant
- Avoir une URL ICS publique valide pour tester (ex: jours fériés français : `https://calendrier.api.gouv.fr/jours-feries/metropole.ics`, ou un calendrier Google public)

---

## 1. Ajout d'un abonnement

### 1.1 Ouverture de la modale
- [ ] Dans le panneau gauche, vérifier qu'une section **"Abonnements"** (ou "Subscriptions") apparaît sous les calendriers personnels
- [ ] Cliquer sur le bouton `+` à côté du titre de la section
- [ ] La modale "Ajouter un abonnement" s'ouvre

### 1.2 Validation du formulaire
- [ ] Soumettre sans URL → erreur de validation
- [ ] Entrer une URL invalide (ex: `pas-une-url`) → soumettre → erreur du serveur affichée
- [ ] Entrer une URL HTTP (pas HTTPS, ex: `http://example.com/cal.ics`) → erreur

#### Protection SSRF
- [ ] `https://localhost/cal.ics` → rejet (hôte privé)
- [ ] `https://127.0.0.1/cal.ics` → rejet
- [ ] `https://[::1]/cal.ics` → rejet
- [ ] `https://10.0.0.1/cal.ics` (RFC1918) → rejet
- [ ] `https://192.168.1.1/cal.ics` (RFC1918) → rejet
- [ ] `https://172.16.0.1/cal.ics` (RFC1918) → rejet
- [ ] Hôte public qui résout vers une IP privée → rejet
- [ ] URL dont la chaîne de redirection 3xx aboutit à une adresse privée ou locale → rejet au hop concerné

### 1.3 Ajout réussi
- [ ] Entrer une URL ICS valide (HTTPS)
- [ ] Optionnel : saisir un nom d'affichage
- [ ] Optionnel : choisir une couleur
- [ ] Cliquer "S'abonner" → spinner de chargement visible
- [ ] La modale se ferme
- [ ] Le calendrier apparaît dans la section "Abonnements"
- [ ] Les événements du calendrier externe s'affichent dans le scheduler

---

## 2. Affichage read-only des événements

### 2.1 Clic sur un événement d'abonnement
- [ ] Cliquer sur un événement provenant d'un calendrier abonné
- [ ] La modale **read-only** s'ouvre (pas le formulaire d'édition)
- [ ] Vérifier l'affichage : titre, date/heure, lieu (si présent), description (si présente)
- [ ] Vérifier que les participants sont listés avec leur statut (accepted, declined, etc.)
- [ ] Vérifier qu'il n'y a **aucun bouton d'édition** ni de suppression

### 2.2 Drag & drop bloqué
- [ ] Essayer de drag-and-drop un événement d'abonnement → l'événement revient à sa position d'origine
- [ ] Essayer de resize un événement d'abonnement → l'événement revient à sa taille d'origine

### 2.3 Événements réguliers non impactés
- [ ] Cliquer sur un événement d'un calendrier personnel → le formulaire d'édition normal s'ouvre
- [ ] Drag & drop sur un événement personnel → fonctionne normalement
- [ ] Créer un nouvel événement → le calendrier abonné n'apparaît PAS dans la liste des calendriers disponibles

---

## 3. Gestion des abonnements

### 3.1 Édition
- [ ] Cliquer sur le menu d'un calendrier abonné → option "Modifier"
- [ ] Changer le nom → Sauvegarder → le nom est mis à jour dans la liste
- [ ] Changer la couleur → Sauvegarder → la couleur est mise à jour (événements aussi)
- [ ] Changer l'URL source → Sauvegarder → les anciens événements sont remplacés par les nouveaux

### 3.2 Suppression
- [ ] Cliquer sur le menu d'un calendrier abonné → option "Supprimer"
- [ ] Le calendrier disparaît de la liste
- [ ] Les événements associés disparaissent du scheduler

### 3.3 Toggle visibilité
- [ ] Décocher un calendrier abonné dans la liste → ses événements disparaissent du scheduler
- [ ] Re-cocher → les événements réapparaissent

---

## 4. Status badge & synchronisation

### 4.1 État normal
- [ ] Après un ajout réussi, pas de badge visible (état "ok")

### 4.2 État erreur (nécessite une URL qui va échouer)
- [ ] Créer un abonnement avec une URL qui marche
- [ ] Modifier l'URL vers une URL invalide (ex: `https://example.com/not-a-calendar`)
- [ ] Attendre quelques minutes (ou forcer via l'API)
- [ ] Un badge d'erreur (icône warning) apparaît
- [ ] Cliquer dessus → le détail de l'erreur s'affiche

### 4.3 État stoppé & réactivation
- [ ] Après 3 erreurs consécutives, le badge passe en "stoppé" (icône block)
- [ ] Le détail affiche un message explicatif + bouton "Réactiver"
- [ ] Corriger l'URL (modifier vers une URL valide)
- [ ] Cliquer "Réactiver" → le statut repasse en "pending" puis "ok"

---

## 5. Limites

### 5.1 Limite d'abonnements (20 par défaut)
- [ ] Si on a déjà 20 abonnements, le bouton `+` est désactivé ou un message "Limite atteinte" s'affiche

---

## 6. Section repliable

- [ ] Cliquer sur le header "Abonnements" → la section se replie (les calendriers sont masqués)
- [ ] Re-cliquer → la section se déplie
- [ ] Quand la section est repliée, les événements restent visibles dans le scheduler (seul l'affichage de la liste est impacté)

---

## 7. Internationalisation

- [ ] Changer la langue en FR → toutes les chaînes de la feature sont en français
- [ ] Changer en EN → toutes les chaînes sont en anglais
- [ ] Changer en NL → toutes les chaînes sont en néerlandais (pas de clés brutes visibles)
27 changes: 27 additions & 0 deletions src/backend/calendars/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,26 @@ class Base(Configuration):
None, environ_name="MESSAGES_CHANNEL_ID", environ_prefix=None
)

# Default sync interval for subscription calendars (seconds)
SUBSCRIPTION_SYNC_INTERVAL = values.IntegerValue(
300, environ_name="SUBSCRIPTION_SYNC_INTERVAL", environ_prefix=None
)

# Maximum number of subscription channels per user
MAX_SUBSCRIPTIONS_PER_USER = values.IntegerValue(
20, environ_name="MAX_SUBSCRIPTIONS_PER_USER", environ_prefix=None
)

# Grace period before an orphaned subscription principal (no sharees)
# is reaped by the cleanup job. Prevents racing with in-flight
# subscribe flows that create the principal before adding the first
# sharee row.
SUBSCRIPTION_ORPHAN_MAX_AGE_SECONDS = values.IntegerValue(
300,
environ_name="SUBSCRIPTION_ORPHAN_MAX_AGE_SECONDS",
environ_prefix=None,
)

# Default calendar sharing level for new organizations.
# Controls what colleagues in the same org can see by default.
# Values: "none", "freebusy", "read", "write"
Expand Down Expand Up @@ -908,6 +928,13 @@ def post_setup(cls):
"""
super().post_setup()

if cls.SUBSCRIPTION_SYNC_INTERVAL < 1:
raise ValueError("SUBSCRIPTION_SYNC_INTERVAL must be >= 1")
if cls.MAX_SUBSCRIPTIONS_PER_USER < 1:
raise ValueError("MAX_SUBSCRIPTIONS_PER_USER must be >= 1")
if cls.SUBSCRIPTION_ORPHAN_MAX_AGE_SECONDS < 0:
raise ValueError("SUBSCRIPTION_ORPHAN_MAX_AGE_SECONDS must be >= 0")

# The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None:
sentry_sdk.init(
Expand Down
8 changes: 7 additions & 1 deletion src/backend/core/api/viewsets_caldav.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@

from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import Channel
from core.services.caldav_service import CalDAVHTTPClient, validate_caldav_proxy_path
from core.services.caldav_service import (
CalDAVHTTPClient,
validate_caldav_proxy_path,
)
from core.services.calendar_invitation_service import calendar_invitation_service

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -171,6 +174,9 @@ def dispatch(self, request, *args, **kwargs): # noqa: PLR0912, PLR0911, PLR0915
if denied := self._check_entitlements_for_creation(effective_user):
return denied

# Subscription calendars are enforced read-only by SabreDAV's
# SubscriptionPlugin — no Django-side check needed.

# Build the CalDAV server URL
path = kwargs.get("path", "")

Expand Down
11 changes: 10 additions & 1 deletion src/backend/core/api/viewsets_channels.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
"""Channel API for managing integration tokens."""
"""Channel API for managing integration tokens.

Handles ``type="caldav"`` (bearer tokens for external integrations like
Messages) and ``type="ical-feed"`` (public iCal export channels).
Subscription channels (external ICS imports) live in
``viewsets_subscriptions.py`` — they use SabreDAV-native sharing and do
not have Channel rows.
"""

# pylint: disable=broad-exception-caught,import-outside-toplevel

import logging
import secrets
Expand Down
Loading
Loading