Spaces:
Sleeping
Sleeping
Commit ·
858bf77
1
Parent(s): fddc7a1
Introduce WebSocket-based notification service with targeted delivery and subscription support.
Browse files- .dockerignore +83 -0
- .gitignore +2 -0
- .idea/.gitignore +8 -0
- Dockerfile +81 -0
- README.md +90 -4
- config.yml +12 -0
- core/logger/core_logger.py +96 -0
- core/manager/websocket_manager.py +180 -0
- core/settings/settings.py +169 -0
- core/types/datamodels.py +85 -0
- core/utils/utils.py +5 -0
- main.py +311 -0
- requirements.txt +4 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
pinned:
|
| 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
|