Ekimart078 commited on
Commit
858bf77
·
1 Parent(s): fddc7a1

Introduce WebSocket-based notification service with targeted delivery and subscription support.

Browse files
.dockerignore ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ **
2
+
3
+ !/.dockerignore
4
+ !/.env.sample
5
+ !/Dockerfile
6
+ !/README.md
7
+ !/pyproject.toml
8
+ !/poetry.lock
9
+ !/main.py
10
+
11
+ !/core
12
+ !/core/**
13
+
14
+ !/*.ini
15
+ !/*.cfg
16
+ !/*.yaml
17
+ !/*.yml
18
+
19
+ .git
20
+ .gitignore
21
+ .gitattributes
22
+ .gitmodules
23
+ .idea
24
+ .vscode
25
+ *.swp
26
+ *.swo
27
+ *~
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ .env
32
+ .venv
33
+ venv/
34
+ env/
35
+ python_env/
36
+
37
+ __pycache__
38
+ *.pyc
39
+ *.pyo
40
+ *.pyd
41
+ .pytest_cache
42
+ .coverage
43
+ .coverage.*
44
+ *.log
45
+ logs/
46
+
47
+ build/
48
+ dist/
49
+ *.egg-info/
50
+ *.egg
51
+ *.whl
52
+ site/
53
+
54
+ tests/
55
+ test/
56
+ docs/
57
+ doc/
58
+ *.rst
59
+ *.txt
60
+
61
+ *.key
62
+ *.pem
63
+ *.cert
64
+ *.crt
65
+ *.secret
66
+ credentials.*
67
+ secrets.*
68
+ docker-compose*.yml
69
+
70
+ *.db
71
+ *.sqlite
72
+ *.dump
73
+ *.bak
74
+ *.large
75
+ *.zip
76
+ *.tar
77
+ *.gz
78
+ *.bz2
79
+
80
+ .tox
81
+ mypy_cache
82
+ .history
83
+ .ipynb_checkpoints
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ .idea
2
+ .venv
.idea/.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
6
+ # Datasource local storage ignored files
7
+ /dataSources/
8
+ /dataSources.local.xml
Dockerfile ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13.0a3-slim-bookworm AS builder
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ PYTHONDONTWRITEBYTECODE=1 \
5
+ PIP_NO_CACHE_DIR=on \
6
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
7
+ PIP_ROOT_USER_ACTION=ignore \
8
+ POETRY_VERSION=1.8.2 \
9
+ POETRY_HOME="/opt/poetry" \
10
+ POETRY_NO_INTERACTION=1
11
+
12
+ RUN apt-get update -qq && \
13
+ apt-get install -y --no-install-recommends \
14
+ curl \
15
+ build-essential \
16
+ pkg-config \
17
+ libssl-dev \
18
+ && apt-get clean \
19
+ && rm -rf /var/lib/apt/lists/*
20
+
21
+ RUN curl -sSL https://install.python-poetry.org | python3 - && \
22
+ chmod +x /opt/poetry/bin/poetry
23
+
24
+ WORKDIR /app
25
+
26
+ COPY pyproject.toml poetry.lock ./
27
+
28
+ RUN /opt/poetry/bin/poetry install --only main --no-root --no-interaction --no-ansi -v
29
+
30
+ FROM python:3.13.0a3-slim-bookworm AS runtime
31
+
32
+ ENV PYTHONUNBUFFERED=1 \
33
+ PYTHONDONTWRITEBYTECODE=1 \
34
+ PATH="/venv/bin:$PATH" \
35
+ PYTHONPATH="/app" \
36
+ UWSGI_HTTP=:8000 \
37
+ UWSGI_MASTER=1 \
38
+ UWSGI_HTTP_AUTO_CHUNKED=1 \
39
+ UWSGI_HTTP_KEEPALIVE=1 \
40
+ UWSGI_LAZY_APPS=1 \
41
+ UWSGI_WSGI_ENV_BEHAVIOR=holy
42
+
43
+ RUN apt-get update -qq && \
44
+ apt-get install -y --no-install-recommends \
45
+ libssl3 \
46
+ ca-certificates \
47
+ && apt-get clean \
48
+ && rm -rf /var/lib/apt/lists/* \
49
+ && groupadd -r appgroup --gid=1000 \
50
+ && useradd -r -g appgroup --uid=1000 --create-home --shell=/bin/false appuser
51
+
52
+ RUN python -m venv /venv
53
+
54
+ COPY --from=builder --chown=appuser:appgroup /root/.cache /home/appuser/.cache
55
+ COPY --from=builder --chown=appuser:appgroup /app /app
56
+ COPY --from=builder --chown=appuser:appgroup /venv /venv
57
+
58
+ USER appuser:appgroup
59
+ WORKDIR /app
60
+
61
+ COPY --chown=appuser:appgroup . .
62
+
63
+ RUN echo "[uwsgi]\n\
64
+ module = main:app\n\
65
+ callable = app\n\
66
+ chdir = /app\n\
67
+ processes = 2\n\
68
+ threads = 2\n\
69
+ enable-threads = true\n\
70
+ lazy-apps = true\n\
71
+ master = true\n\
72
+ http-auto-chunked = true\n\
73
+ http-keepalive = true\n\
74
+ vacuum = true\n\
75
+ die-on-term = true\n\
76
+ need-app = true" > uwsgi.ini
77
+
78
+ EXPOSE 7860
79
+
80
+ ENTRYPOINT ["python", "-m", "uvicorn"]
81
+ CMD ["main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,96 @@
1
  ---
2
  title: NotificationChannel
3
- emoji: 🦀
4
- colorFrom: gray
5
- colorTo: gray
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: NotificationChannel
3
+ emoji: 🔔
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: docker
7
+ pinned: true
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
11
+
12
+ # 🔔 NotificationChannel
13
+
14
+ **Service de notifications en temps réel professionnel**
15
+
16
+ *Notification push • WebSockets • Diffusion ciblée • Historique*
17
+
18
+ ## ✨ Fonctionnalités
19
+
20
+ - 🚀 **Connexions WebSocket** - Gestion optimisée des connexions persistantes
21
+ - 🎯 **Notifications ciblées** - Envoi par utilisateur, rôle ou diffusion globale
22
+ - 📊 **Historique intelligent** - Conservation des dernières notifications
23
+ - 🔐 **Sécurité renforcée** - Gestion de session et validation
24
+ - 📈 **Monitoring intégré** - Statistiques temps réel des connexions
25
+
26
+ ## 🏗️ Architecture
27
+
28
+ ```mermaid
29
+ graph TD
30
+ A[Client] -->|WebSocket| B(API Gateway)
31
+ B --> C{NotificationChannel}
32
+ C --> D[(Historique)]
33
+ C --> E[Utilisateur]
34
+ C --> F[Rôle]
35
+ C --> G[Broadcast]
36
+ ```
37
+
38
+ ## 🚀 Déploiement
39
+
40
+ ```bash
41
+ # Construction de l'image
42
+ docker build -t notification-channel .
43
+
44
+ # Lancement du service
45
+ docker run -d -p 7860:7860 --name notif-service notification-channel
46
+ ```
47
+
48
+ **Accès rapide :**
49
+
50
+ - Interface : http://0.0.0.0:7860
51
+ - Documentation API : http://0.0.0.0:7860/api-docs
52
+
53
+ ## ⚙️ Configuration
54
+
55
+ | Variable | Défaut | Description |
56
+ |--------------------|---------|--------------------------------------|
57
+ | `APP_HOST` | 0.0.0.0 | Adresse d'écoute du service |
58
+ | `APP_PORT` | 7860 | Port d'écoute du service |
59
+ | `MAX_HISTORY_SIZE` | 1000 | Taille max de l'historique |
60
+ | `NOTIF_TTL` | 3600 | Durée de vie des notifications (sec) |
61
+
62
+ ## 🔄 Workflow CI/CD
63
+
64
+ ```mermaid
65
+ graph LR
66
+ A[Code] --> B[Tests]
67
+ B --> C[Build Docker]
68
+ C --> D[Scan Sécurité]
69
+ D --> E[Staging]
70
+ E --> F[Validation]
71
+ F --> G[Production]
72
+ ```
73
+
74
+ ## 📋 API Endpoints
75
+
76
+ ### WebSocket
77
+
78
+ - `ws://localhost:8000/ws/{user_id}` - Connexion utilisateur
79
+ - `ws://localhost:8000/ws/broadcast` - Diffusion globale
80
+
81
+ ### REST API
82
+
83
+ - `POST /notify/user/{user_id}` - Notification individuelle
84
+ - `POST /notify/role/{role}` - Notification par rôle
85
+ - `POST /notify/broadcast` - Diffusion générale
86
+ - `GET /history/{user_id}` - Historique utilisateur
87
+
88
+ ## 🛠️ Développement
89
+
90
+ ```bash
91
+ # Installation des dépendances
92
+ pip install -r requirements.txt
93
+
94
+ # Lancement en mode dev
95
+ uvicorn main:app --reload --host 0.0.0.0 --port 7860
96
+ ```
config.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ APP_NAME: NotificationChannel
2
+ APP_ENV: local
3
+ APP_PRODUCTION: False
4
+ APP_PORT: 7860
5
+ APP_HOST: 0.0.0.0
6
+
7
+ MAX_HISTORY_SIZE: 1000
8
+
9
+ WS_HOST: 0.0.0.0
10
+ WS_PORT: 8765
11
+ WS_SSL: false
12
+ WS_CORS_ORIGINS: '*'
core/logger/core_logger.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from enum import Enum
4
+ from logging import Formatter, LogRecord, getLogger, StreamHandler, FileHandler
5
+ from pathlib import Path
6
+
7
+ from colorama import Fore, Style
8
+
9
+
10
+ class LoggerTarget(Enum):
11
+ CONSOLE = "console"
12
+ FILE = "file"
13
+ BOTH = "both"
14
+
15
+
16
+ class ColorFormatter(Formatter):
17
+ COLORS_LEVEL = {
18
+ "DEBUG": Fore.CYAN,
19
+ "INFO": Fore.GREEN,
20
+ "WARNING": Fore.YELLOW,
21
+ "ERROR": Fore.RED,
22
+ "CRITICAL": Fore.MAGENTA + Style.BRIGHT,
23
+ }
24
+
25
+ def format(self, record: LogRecord):
26
+ original_levelname = record.levelname
27
+ color = self.COLORS_LEVEL.get(original_levelname, "")
28
+ record.levelname = f"{color}{original_levelname}{Style.RESET_ALL}"
29
+ formatted = super().format(record)
30
+ return formatted
31
+
32
+
33
+ class CoreLogger:
34
+ def __init__(
35
+ self,
36
+ name: str = "app",
37
+ level: str = "INFO",
38
+ fmt: str = None,
39
+ datefmt: str = None,
40
+ log_to_console: bool = True,
41
+ log_to_file: bool = False,
42
+ ):
43
+ self.logger = getLogger(name)
44
+ self.logger.setLevel(level)
45
+ self.log_to_console = log_to_console
46
+ self.log_to_file = log_to_file
47
+ self.logger.propagate = False
48
+
49
+ self.fmt = fmt or "%(asctime)s | %(levelname)-8s | %(message)s"
50
+ self.datefmt = datefmt or "%Y-%m-%d %H:%M:%S"
51
+
52
+ self.logger_file_path = Path(__file__).resolve().parents[2]
53
+ self.logger_file_path = self.logger_file_path / "logs" / "app.log"
54
+
55
+ formatter = Formatter(self.fmt, self.datefmt)
56
+
57
+ color_formatter = ColorFormatter(self.fmt, self.datefmt)
58
+
59
+ if self.log_to_console:
60
+ console_handler = StreamHandler(sys.stdout)
61
+ console_handler.setFormatter(color_formatter)
62
+ self.logger.addHandler(console_handler)
63
+
64
+ if self.log_to_file:
65
+ log_file_path = Path(self.logger_file_path).resolve()
66
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
67
+ fh = FileHandler(log_file_path, encoding="utf-8")
68
+ fh.setFormatter(formatter)
69
+ self.logger.addHandler(fh)
70
+
71
+ def debug(self, message: str):
72
+ self.logger.debug(message)
73
+
74
+ def info(self, message: str):
75
+ self.logger.info(message)
76
+
77
+ def warning(self, message: str):
78
+ self.logger.warning(message)
79
+
80
+ def error(self, message: str):
81
+ self.logger.error(message)
82
+
83
+ def clear_log(self, target: LoggerTarget = LoggerTarget.BOTH):
84
+ if target in (LoggerTarget.CONSOLE, LoggerTarget.BOTH) and self.log_to_console:
85
+ os.system("cls" if sys.platform.startswith("win") else "clear")
86
+
87
+ if target in (LoggerTarget.FILE, LoggerTarget.BOTH) and self.log_to_file:
88
+ log_file_path = Path(self.logger_file_path).resolve()
89
+ if log_file_path.exists():
90
+ with log_file_path.open("w", encoding="utf-8") as f:
91
+ f.truncate(0)
92
+
93
+
94
+ if __name__ == "__main__":
95
+ logger = CoreLogger(log_to_console=True, log_to_file=True)
96
+ logger.info("test")
core/manager/websocket_manager.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import deque
2
+ from datetime import datetime
3
+ from typing import Dict, List, Any, Deque
4
+
5
+ from fastapi import WebSocket
6
+
7
+ from core.settings.settings import Settings
8
+ from core.types.datamodels import ClientInfo, Subscription, Notification
9
+
10
+
11
+ class WebSocketManager:
12
+ def __init__(self):
13
+ self.settings = Settings()
14
+ self.logger = self.settings.logger
15
+
16
+ self.active_connections: Dict[str, WebSocket] = {}
17
+ self.client_info: Dict[str, ClientInfo] = {}
18
+ self.subscriptions: Dict[str, List[Subscription]] = {}
19
+ self.notification_history: Deque[Notification] = deque(
20
+ maxlen=self.settings.get("MAX_NOTIFICATION_HISTORY", 1000)
21
+ )
22
+
23
+ async def connect(self, websocket: WebSocket, client: ClientInfo) -> str:
24
+ """Établit une nouvelle connexion WebSocket"""
25
+ await websocket.accept()
26
+ session_id = client.session_id
27
+
28
+ self.active_connections[session_id] = websocket
29
+ self.client_info[session_id] = client
30
+ self.subscriptions[session_id] = []
31
+
32
+ self.logger.info(f"Nouvelle connexion: {session_id}")
33
+
34
+ await self._send(
35
+ websocket,
36
+ {
37
+ "type": "connection_established",
38
+ "session_id": session_id,
39
+ "message": "Connexion au service de notifications établie",
40
+ "timestamp": datetime.utcnow().isoformat(),
41
+ },
42
+ )
43
+
44
+ return session_id
45
+
46
+ def disconnect(self, session_id: str) -> None:
47
+ """Ferme une connexion et nettoie les ressources"""
48
+ if session_id in self.active_connections:
49
+ del self.active_connections[session_id]
50
+ del self.client_info[session_id]
51
+ del self.subscriptions[session_id]
52
+ self.logger.info(f"Connexion fermée: {session_id}")
53
+
54
+ async def send_personal_message(
55
+ self, session_id: str, payload: Dict[str, Any]
56
+ ) -> None:
57
+ """Envoie un message à un client spécifique"""
58
+ if ws := self.active_connections.get(session_id):
59
+ try:
60
+ await self._send(ws, payload)
61
+ except RuntimeError as e:
62
+ self.logger.error(f"Erreur d'envoi à {session_id}: {e}")
63
+ self.disconnect(session_id)
64
+
65
+ async def broadcast_notification(self, notification: Notification) -> None:
66
+ """Diffuse une notification aux clients abonnés"""
67
+ if notification.is_expired:
68
+ return
69
+
70
+ self._add_to_history(notification)
71
+ payload = self._format_notification(notification)
72
+
73
+ for session_id, subscriptions in self.subscriptions.items():
74
+ if not (client := self.client_info.get(session_id)):
75
+ continue
76
+
77
+ if self._should_receive(subscriptions, notification, client):
78
+ await self.send_personal_message(session_id, payload)
79
+
80
+ async def send_to_user(self, user_id: str, notification: Notification) -> None:
81
+ """Envoie une notification à un utilisateur spécifique"""
82
+ if notification.is_expired:
83
+ return
84
+
85
+ self._add_to_history(notification)
86
+ payload = self._format_notification(notification)
87
+
88
+ for session_id, client in self.client_info.items():
89
+ if client.user_id == user_id:
90
+ await self.send_personal_message(session_id, payload)
91
+
92
+ async def send_to_role(self, role: str, notification: Notification) -> None:
93
+ """Envoie une notification à un rôle spécifique"""
94
+ if notification.is_expired:
95
+ return
96
+
97
+ self._add_to_history(notification)
98
+ payload = self._format_notification(notification)
99
+
100
+ for session_id, client in self.client_info.items():
101
+ if role in client.user_roles:
102
+ await self.send_personal_message(session_id, payload)
103
+
104
+ def add_subscription(self, session_id: str, subscription: Subscription) -> bool:
105
+ """Ajoute un nouvel abonnement pour un client"""
106
+ if not (sub_list := self.subscriptions.get(session_id)):
107
+ return False
108
+
109
+ for existing in sub_list:
110
+ if existing.id == subscription.id:
111
+ return False
112
+
113
+ sub_list.append(subscription)
114
+ return True
115
+
116
+ def update_subscription(self, session_id: str, subscription: Subscription) -> bool:
117
+ """Met à jour un abonnement existant"""
118
+ if not (sub_list := self.subscriptions.get(session_id)):
119
+ return False
120
+
121
+ for i, existing in enumerate(sub_list):
122
+ if existing.id == subscription.id:
123
+ sub_list[i] = subscription
124
+ return True
125
+ return False
126
+
127
+ def remove_subscription(self, session_id: str, subscription_id: str) -> bool:
128
+ """Supprime un abonnement"""
129
+ if not (sub_list := self.subscriptions.get(session_id)):
130
+ return False
131
+
132
+ initial_count = len(sub_list)
133
+ self.subscriptions[session_id] = [
134
+ sub for sub in sub_list if sub.id != subscription_id
135
+ ]
136
+ return len(self.subscriptions[session_id]) < initial_count
137
+
138
+ def get_subscriptions(self, session_id: str) -> List[Subscription]:
139
+ """Récupère les abonnements d'un client"""
140
+ return self.subscriptions.get(session_id, [])[:]
141
+
142
+ def get_notification_history(self, limit: int = 100) -> List[Notification]:
143
+ """Récupère l'historique des notifications"""
144
+ return [n for n in list(self.notification_history)[-limit:] if not n.is_expired]
145
+
146
+ def get_stats(self) -> Dict[str, Any]:
147
+ """Retourne les statistiques du service"""
148
+ return {
149
+ "active_connections": len(self.active_connections),
150
+ "total_subscriptions": sum(len(s) for s in self.subscriptions.values()),
151
+ "total_notifications": len(self.notification_history),
152
+ "timestamp": datetime.utcnow().isoformat(),
153
+ }
154
+
155
+ # Méthodes internes
156
+ async def _send(self, websocket: WebSocket, payload: Dict[str, Any]) -> None:
157
+ """Mécanisme d'envoi générique"""
158
+ await websocket.send_json(payload)
159
+
160
+ def _add_to_history(self, notification: Notification) -> None:
161
+ """Ajoute une notification à l'historique"""
162
+ if not notification.is_expired:
163
+ self.notification_history.append(notification)
164
+
165
+ @staticmethod
166
+ def _should_receive(
167
+ subscriptions: List[Subscription],
168
+ notification: Notification,
169
+ client: ClientInfo,
170
+ ) -> bool:
171
+ """Vérifie si un client doit recevoir une notification"""
172
+ return any(
173
+ sub.matches(notification, client.user_id, client.user_roles)
174
+ for sub in subscriptions
175
+ )
176
+
177
+ @staticmethod
178
+ def _format_notification(notification: Notification) -> Dict[str, Any]:
179
+ """Formate une notification pour l'envoi"""
180
+ return {"type": "notification", **notification.model_dump(exclude_unset=True)}
core/settings/settings.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import stat
3
+ from pathlib import Path
4
+ from typing import Any, Dict, Optional
5
+
6
+ import yaml
7
+
8
+ from core.logger.core_logger import CoreLogger
9
+
10
+
11
+ class Settings:
12
+ __slots__ = (
13
+ "_config",
14
+ "_file_path",
15
+ "_logger",
16
+ "_max_size_file",
17
+ "_env_vars_to_load",
18
+ )
19
+
20
+ def __init__(self):
21
+ self._config: Dict[str, Any] = {}
22
+ self._file_path: Optional[Path] = None
23
+ self._logger: CoreLogger = CoreLogger(name="settings")
24
+ self._max_size_file: int = 5 * 1024 * 1024
25
+
26
+ self._env_vars_to_load = [
27
+ "ANTHROPIC_KEY",
28
+ "ANTHROPIC_VERSION",
29
+ "OPENAI_API_KEY",
30
+ ]
31
+
32
+ self.load_configuration_file(Path(__file__).resolve().parents[2] / "config.yml")
33
+
34
+ @property
35
+ def all_settings(self) -> Dict[str, Any]:
36
+ return self._config.copy()
37
+
38
+ @property
39
+ def logger(self):
40
+ return self._logger
41
+
42
+ def _load_env_variable(self) -> None:
43
+ for key in self._env_vars_to_load:
44
+ if key in os.environ:
45
+ self._config[key] = os.environ.get(key)
46
+
47
+ def check_file_permission(self, file_path: Optional[Path]) -> bool:
48
+ if file_path:
49
+ path = Path(file_path)
50
+ if not path.exists():
51
+ self._logger.error(f"🛑 Le fichier de configuration est introuvable.")
52
+ return False
53
+
54
+ if not path.is_file():
55
+ self._logger.error(f"Le fichier de configuration n'est pas un fichier.")
56
+ return False
57
+
58
+ if path.suffix.lower() not in (".yaml", ".yml"):
59
+ self._logger.error(
60
+ f"🛑 Le fichier de configuration n'est pas un fichier yaml."
61
+ )
62
+ return False
63
+
64
+ mode = path.stat().st_mode
65
+
66
+ modified = False
67
+
68
+ if mode & (stat.S_IWOTH | stat.S_IROTH):
69
+ self._logger.warning(
70
+ "⚠️ Permissions incorrectes pour 'others', correction automatique en lecture seule."
71
+ )
72
+ mode = mode & ~(stat.S_IWOTH | stat.S_IROTH)
73
+ modified = True
74
+
75
+ if mode & stat.S_IWGRP:
76
+ self._logger.warning(
77
+ "⚠️ Permissions incorrectes pour 'group', correction automatique en lecture seule."
78
+ )
79
+ mode = mode & ~stat.S_IWGRP
80
+ modified = True
81
+
82
+ if modified:
83
+ try:
84
+ os.chmod(path, mode)
85
+ self._logger.info(f"🔧 Permissions corrigées automatiquement.")
86
+ except Exception as e:
87
+ self._logger.error(f"🛑 Échec de la correction des permissions : {e}")
88
+ return False
89
+
90
+ return True
91
+
92
+ return False
93
+
94
+ def load_configuration_file(self, file_path: str | Path) -> None:
95
+ path = Path(file_path).resolve()
96
+ self._file_path = path
97
+
98
+ try:
99
+ permission = self.check_file_permission(path)
100
+
101
+ if permission:
102
+ size = path.stat().st_size
103
+ if size > self._max_size_file:
104
+ self._logger.error(
105
+ "🛑 Le fichier de configuration ne doit pas dépasser la taille maximal"
106
+ )
107
+
108
+ with open(path, "r", encoding="utf-8") as file:
109
+ self._config = yaml.safe_load(file) or {}
110
+
111
+ if not self._config:
112
+ self._logger.error(
113
+ "🛑 La configuration doit être un dictionnaire."
114
+ )
115
+
116
+ self._logger.info(
117
+ "✅ Chargement de la configuration terminée avec succès."
118
+ )
119
+
120
+ except PermissionError as exc:
121
+ self._logger.error(
122
+ f"🛑 Impossible de lire le fichier de configuration : {exc}"
123
+ )
124
+ if "e" in locals() and exc:
125
+ self._config = {}
126
+ raise
127
+ except Exception as e:
128
+ self._logger.error(
129
+ f"🛑 Erreur lors de la lecture du fichier de configuration : {e}"
130
+ )
131
+ if "e" in locals() and e:
132
+ self._config = {}
133
+ raise
134
+ finally:
135
+ self._logger.clear_log()
136
+
137
+ def settings_is_initialized(self) -> bool:
138
+ return bool(self._config)
139
+
140
+ def reload(self) -> None:
141
+ if not self._file_path:
142
+ self._logger.error("🛑 Aucun fichier de configuration trouvée.")
143
+ return
144
+
145
+ self.load_configuration_file(self._file_path)
146
+
147
+ def get(self, key: str, default: Any = None) -> Any:
148
+ return self._config.get(key, default)
149
+
150
+ def __getattr__(self, item: str) -> Any:
151
+ if item in self._config:
152
+ return self._config[item]
153
+ else:
154
+ self._logger.error(
155
+ f"Le paramètre {item} n'existe pas dans la configuration."
156
+ )
157
+ return None
158
+
159
+ def __setattr__(self, key: str, value: Any) -> None:
160
+ if key in self.__slots__:
161
+ super().__setattr__(key, value)
162
+ else:
163
+ self._logger.error("Impossible de modifier les configurations.")
164
+
165
+
166
+ if __name__ == "__main__":
167
+ settings = Settings()
168
+ if settings.settings_is_initialized():
169
+ settings.logger.info(f"{settings.all_settings}")
core/types/datamodels.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, Dict, Any, List
3
+ from uuid import uuid4
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class SubscriptionRequest(BaseModel):
9
+ type: str # 'user', 'role', 'global'
10
+ target: str
11
+ notification_types: List[str] = Field(default_factory=list)
12
+
13
+
14
+ class NotificationRequest(BaseModel):
15
+ type: str
16
+ title: str
17
+ message: str
18
+ data: Dict[str, Any] = Field(default_factory=dict)
19
+ expires_at: Optional[datetime] = None
20
+ priority: str = Field(default="normal")
21
+
22
+
23
+ class BroadcastRequest(BaseModel):
24
+ notification: NotificationRequest
25
+ target_type: str # 'user', 'role', 'global'
26
+ target_value: str # user_id, role_name, or 'all'
27
+
28
+
29
+ class Notification(BaseModel):
30
+ id: str = Field(default_factory=lambda: str(uuid4()))
31
+ type: str
32
+ title: str
33
+ message: str
34
+ data: Dict[str, Any] = Field(default_factory=dict)
35
+ created_at: datetime = Field(default_factory=datetime.now)
36
+ expires_at: Optional[datetime] = None
37
+ priority: str = Field(default="normal") # 'low', 'normal', 'high', 'urgent'
38
+
39
+ @property
40
+ def is_expired(self) -> bool:
41
+ if self.expires_at is None:
42
+ return False
43
+ return datetime.now() > self.expires_at
44
+
45
+ def to_dict(self) -> Dict[str, Any]:
46
+ return {
47
+ "id": self.id,
48
+ "type": self.type,
49
+ "title": self.title,
50
+ "message": self.message,
51
+ "data": self.data,
52
+ "created_at": self.created_at.isoformat(),
53
+ "expires_at": self.expires_at.isoformat() if self.expires_at else None,
54
+ "priority": self.priority,
55
+ }
56
+
57
+
58
+ class Subscription(BaseModel):
59
+ id: str = Field(default_factory=lambda: str(uuid4()))
60
+ type: str # 'user', 'role', 'global'
61
+ target: str # user_id, role_name, or 'all'
62
+ notification_types: List[str] = Field(default_factory=list)
63
+ created_at: datetime = Field(default_factory=datetime.now)
64
+
65
+ def matches(
66
+ self, notification: "Notification", user_id: str, user_roles: List[str]
67
+ ) -> bool:
68
+ if self.notification_types and notification.type not in self.notification_types:
69
+ return False
70
+
71
+ if self.type == "user" and self.target == user_id:
72
+ return True
73
+ elif self.type == "role" and self.target in user_roles:
74
+ return True
75
+ elif self.type == "global" and self.target == "all":
76
+ return True
77
+
78
+ return False
79
+
80
+
81
+ class ClientInfo(BaseModel):
82
+ session_id: str
83
+ user_id: str
84
+ user_roles: List[str] = Field(default_factory=list)
85
+ connected_at: datetime = Field(default_factory=datetime.now)
core/utils/utils.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+
4
+ def current_utc_iso() -> str:
5
+ return datetime.now(timezone.utc).isoformat()
main.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from uuid import uuid4
3
+
4
+ import uvicorn
5
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends
6
+ from fastapi.responses import HTMLResponse
7
+
8
+ from core.manager.websocket_manager import WebSocketManager
9
+ from core.types.datamodels import (
10
+ ClientInfo,
11
+ SubscriptionRequest,
12
+ Subscription,
13
+ NotificationRequest,
14
+ Notification,
15
+ BroadcastRequest,
16
+ )
17
+ from core.utils.utils import current_utc_iso
18
+
19
+ manager = WebSocketManager()
20
+
21
+ logger = manager.logger
22
+
23
+
24
+ async def validate_session(session_id: str) -> str:
25
+ if session_id not in manager.active_connections:
26
+ raise HTTPException(
27
+ status_code=404, detail="Session introuvable ou déjà fermée"
28
+ )
29
+ return session_id
30
+
31
+
32
+ @asynccontextmanager
33
+ async def lifespan(_app: FastAPI):
34
+ """Gère les événements de démarrage et d'arrêt de l'application"""
35
+
36
+ logger.info("Démarrage du service de notifications WebSocket")
37
+
38
+ yield
39
+
40
+ logger.info("Arrêt du service de notifications WebSocket")
41
+
42
+
43
+ app = FastAPI(
44
+ title=manager.settings.APP_NAME,
45
+ description="Service de notifications en temps réel avec support WebSocket",
46
+ version="1.0.0",
47
+ lifespan=lifespan,
48
+ docs_url="/docs",
49
+ redoc_url="/redoc",
50
+ )
51
+
52
+
53
+ @app.websocket("/ws/{user_id}")
54
+ async def websocket_endpoint(websocket: WebSocket, user_id: str):
55
+ """Endpoint pour les connexions WebSocket"""
56
+ session_id = None
57
+
58
+ try:
59
+ client_info = ClientInfo(
60
+ session_id=str(uuid4()),
61
+ user_id=user_id,
62
+ user_roles=(
63
+ websocket.query_params.get("roles", "").split(",")
64
+ if websocket.query_params.get("roles")
65
+ else []
66
+ ),
67
+ )
68
+
69
+ session_id = await manager.connect(websocket, client_info)
70
+
71
+ while True:
72
+ data = await websocket.receive_json()
73
+
74
+ await manager.send_personal_message(
75
+ session_id,
76
+ {
77
+ "type": "message_ack",
78
+ "message": f"Message reçu: {data}",
79
+ "timestamp": current_utc_iso(),
80
+ },
81
+ )
82
+
83
+ except WebSocketDisconnect:
84
+ manager.disconnect(session_id)
85
+ logger.info(f"Connexion WebSocket fermée: {session_id}")
86
+ except Exception as e:
87
+ logger.error(f"Erreur WebSocket: {e}")
88
+ manager.disconnect(session_id)
89
+
90
+
91
+ # Endpoints REST
92
+ @app.get("/", response_class=HTMLResponse)
93
+ async def get_test_page():
94
+ """Page de test pour les connexions WebSocket"""
95
+ return """
96
+ <!DOCTYPE html>
97
+ <html>
98
+ <head>
99
+ <title>WebSocket Notification Test</title>
100
+ <style>
101
+ body { font-family: Arial, sans-serif; padding: 20px; }
102
+ .container { max-width: 800px; margin: 0 auto; }
103
+ .messages { height: 300px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; margin: 10px 0; }
104
+ .message { margin: 5px 0; padding: 5px; border-radius: 3px; }
105
+ .notification { background-color: #e7f3ff; }
106
+ .system { background-color: #f0f0f0; }
107
+ input, button { margin: 5px; padding: 5px; }
108
+ </style>
109
+ </head>
110
+ <body>
111
+ <div class="container">
112
+ <h1>WebSocket Notification Test</h1>
113
+ <div>
114
+ <input type="text" id="userId" placeholder="User ID" value="user123">
115
+ <input type="text" id="roles" placeholder="Roles (comma-separated)" value="admin,user">
116
+ <button onclick="connect()">Connect</button>
117
+ <button onclick="disconnect()">Disconnect</button>
118
+ </div>
119
+ <div id="messages" class="messages"></div>
120
+ <div>
121
+ <input type="text" id="messageInput" placeholder="Type a message...">
122
+ <button onclick="sendMessage()">Send</button>
123
+ </div>
124
+ </div>
125
+
126
+ <script>
127
+ let ws = null;
128
+
129
+ function connect() {
130
+ const userId = document.getElementById('userId').value;
131
+ const roles = document.getElementById('roles').value;
132
+ const url = `ws://localhost:8000/ws/${userId}?roles=${roles}`;
133
+
134
+ ws = new WebSocket(url);
135
+
136
+ ws.onmessage = function(event) {
137
+ const data = JSON.parse(event.data);
138
+ addMessage(data, 'notification');
139
+ };
140
+
141
+ ws.onopen = function() {
142
+ addMessage({message: 'Connected to WebSocket'}, 'system');
143
+ };
144
+
145
+ ws.onclose = function() {
146
+ addMessage({message: 'Disconnected from WebSocket'}, 'system');
147
+ };
148
+ }
149
+
150
+ function disconnect() {
151
+ if (ws) {
152
+ ws.close();
153
+ ws = null;
154
+ }
155
+ }
156
+
157
+ function sendMessage() {
158
+ if (ws && ws.readyState === WebSocket.OPEN) {
159
+ const message = document.getElementById('messageInput').value;
160
+ ws.send(JSON.stringify({message: message}));
161
+ document.getElementById('messageInput').value = '';
162
+ }
163
+ }
164
+
165
+ function addMessage(data, type) {
166
+ const messages = document.getElementById('messages');
167
+ const messageDiv = document.createElement('div');
168
+ messageDiv.className = `message ${type}`;
169
+ messageDiv.innerHTML = `<strong>${data.type || 'message'}:</strong> ${data.message || JSON.stringify(data)}`;
170
+ messages.appendChild(messageDiv);
171
+ messages.scrollTop = messages.scrollHeight;
172
+ }
173
+
174
+ // Auto-connect on page load
175
+ connect();
176
+ </script>
177
+ </body>
178
+ </html>
179
+ """
180
+
181
+
182
+ @app.post("/subscriptions/{session_id}")
183
+ async def add_subscription(
184
+ session_id: str = Depends(validate_session),
185
+ subscription_request: SubscriptionRequest = None,
186
+ ):
187
+ """Ajoute un nouvel abonnement pour une session"""
188
+ subscription = Subscription(**subscription_request.model_dump())
189
+
190
+ if not manager.add_subscription(session_id, subscription):
191
+ raise HTTPException(status_code=400, detail="Échec de l'ajout de l'abonnement")
192
+
193
+ return {
194
+ "message": "Abonnement ajouté avec succès",
195
+ "subscription_id": subscription.id,
196
+ }
197
+
198
+
199
+ @app.delete("/subscriptions/{session_id}/{subscription_id}")
200
+ async def remove_subscription(
201
+ subscription_id: str,
202
+ session_id: str = Depends(validate_session),
203
+ ):
204
+ """Supprime un abonnement existant"""
205
+ if not manager.remove_subscription(session_id, subscription_id):
206
+ raise HTTPException(status_code=404, detail="Abonnement introuvable")
207
+
208
+ return {"message": "Abonnement supprimé avec succès"}
209
+
210
+
211
+ @app.get("/subscriptions/{session_id}")
212
+ async def get_subscriptions(session_id: str = Depends(validate_session)):
213
+ """Récupère les abonnements d'une session"""
214
+ return manager.get_subscriptions(session_id)
215
+
216
+
217
+ @app.post("/notifications/user/{user_id}")
218
+ async def send_user_notification(
219
+ user_id: str, notification_request: NotificationRequest
220
+ ):
221
+ """Envoie une notification à un utilisateur spécifique"""
222
+ notification = Notification(**notification_request.model_dump())
223
+ await manager.send_to_user(user_id, notification)
224
+
225
+ return {
226
+ "message": f"Notification envoyée à l'utilisateur {user_id}",
227
+ "notification_id": notification.id,
228
+ }
229
+
230
+
231
+ @app.post("/notifications/role/{role}")
232
+ async def send_role_notification(role: str, notification_request: NotificationRequest):
233
+ """Envoie une notification à un rôle spécifique"""
234
+ notification = Notification(**notification_request.model_dump())
235
+ await manager.send_to_role(role, notification)
236
+
237
+ return {
238
+ "message": f"Notification envoyée au rôle {role}",
239
+ "notification_id": notification.id,
240
+ }
241
+
242
+
243
+ @app.post("/notifications/broadcast")
244
+ async def broadcast_notification(notification_request: NotificationRequest):
245
+ """Diffuse une notification à tous les clients connectés"""
246
+ notification = Notification(**notification_request.model_dump())
247
+ await manager.broadcast_notification(notification)
248
+
249
+ return {
250
+ "message": "Notification diffusée",
251
+ "notification_id": notification.id,
252
+ }
253
+
254
+
255
+ @app.post("/notifications/send")
256
+ async def send_targeted_notification(request: BroadcastRequest):
257
+ """Envoie une notification ciblée"""
258
+ notification = Notification(**request.notification.model_dump())
259
+
260
+ if request.target_type == "user":
261
+ await manager.send_to_user(request.target_value, notification)
262
+ elif request.target_type == "role":
263
+ await manager.send_to_role(request.target_value, notification)
264
+ elif request.target_type == "global":
265
+ await manager.broadcast_notification(notification)
266
+ else:
267
+ raise HTTPException(status_code=400, detail="Type de cible invalide")
268
+
269
+ return {
270
+ "message": f"Notification envoyée à {request.target_type}: {request.target_value}",
271
+ "notification_id": notification.id,
272
+ }
273
+
274
+
275
+ @app.get("/notifications/history")
276
+ async def get_notification_history(limit: int = 100):
277
+ """Récupère l'historique des notifications"""
278
+ return manager.get_notification_history(limit)
279
+
280
+
281
+ @app.get("/stats")
282
+ async def get_service_stats():
283
+ """Récupère les statistiques du service"""
284
+ return manager.get_stats()
285
+
286
+
287
+ @app.get("/clients")
288
+ async def get_connected_clients():
289
+ """Récupère la liste des clients connectés"""
290
+ clients = [
291
+ {
292
+ "session_id": session_id,
293
+ "user_id": info.user_id,
294
+ "user_roles": info.user_roles,
295
+ "connected_at": info.connected_at.isoformat(),
296
+ "subscriptions": len(manager.subscriptions.get(session_id, [])),
297
+ }
298
+ for session_id, info in manager.client_info.items()
299
+ ]
300
+ return {"clients": clients}
301
+
302
+
303
+ if __name__ == "__main__":
304
+ uvicorn.run(
305
+ "main:app",
306
+ host=manager.settings.APP_HOST,
307
+ port=manager.settings.APP_PORT,
308
+ reload=True,
309
+ log_level="info",
310
+ server_header=False,
311
+ )
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ colorama
2
+ PyYAML
3
+ pydantic
4
+ fastapi