feat: Implement initial Homelab Automation API v2 with new models, routes, and core architecture, including a SQLAlchemy model refactoring script.
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
This commit is contained in:
parent
608f4b9197
commit
c3cd7c2621
22
README.md
22
README.md
@ -316,6 +316,28 @@ curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/hosts
|
|||||||
- Toujours exposer l’API derrière HTTPS (reverse proxy type Nginx/Traefik, certbot, etc.).
|
- Toujours exposer l’API derrière HTTPS (reverse proxy type Nginx/Traefik, certbot, etc.).
|
||||||
- Restreindre les IP/autorisations au niveau du pare-feu si possible.
|
- Restreindre les IP/autorisations au niveau du pare-feu si possible.
|
||||||
|
|
||||||
|
#### 🔐 Récupération et Réinitialisation des accès
|
||||||
|
|
||||||
|
Si vous avez perdu vos identifiants de connexion au Dashboard, voici comment procéder :
|
||||||
|
|
||||||
|
**1. Récupérer le nom d'utilisateur (Username)**
|
||||||
|
Les utilisateurs sont stockés dans la base de données SQLite. Vous pouvez les lister avec cette commande :
|
||||||
|
```bash
|
||||||
|
sqlite3 data/homelab.db "SELECT id, username, role, is_active FROM users;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Réinitialiser le mot de passe**
|
||||||
|
Les mots de passe sont hachés (BCrypt) et ne peuvent **pas** être récupérés en clair. Vous devez les réinitialiser.
|
||||||
|
|
||||||
|
Un script utilitaire est fourni à la racine du projet pour cela :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Usage: python reset_password.py <username> <nouveau_password>
|
||||||
|
python reset_password.py admin mon_nouveau_mot_de_passe
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note :** Si aucun utilisateur n'existe encore, l'API `POST /api/auth/setup` (mentionnée plus haut) est la méthode recommandée pour créer le premier compte administrateur.
|
||||||
|
|
||||||
#### Endpoints Principaux
|
#### Endpoints Principaux
|
||||||
|
|
||||||
**Hôtes**
|
**Hôtes**
|
||||||
|
|||||||
@ -53,8 +53,8 @@ async def verify_api_key(
|
|||||||
Returns:
|
Returns:
|
||||||
True si authentifié
|
True si authentifié
|
||||||
"""
|
"""
|
||||||
# Vérifier la clé API
|
# Vérifier la clé API (en tant que header X-API-Key ou Bearer token)
|
||||||
if api_key and api_key == settings.api_key:
|
if (api_key and api_key == settings.api_key) or (token and token == settings.api_key):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Vérifier le token JWT
|
# Vérifier le token JWT
|
||||||
@ -86,8 +86,8 @@ async def get_current_user_optional(
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionnaire utilisateur si authentifié, None sinon
|
Dictionnaire utilisateur si authentifié, None sinon
|
||||||
"""
|
"""
|
||||||
# Vérifier d'abord la clé API (compatibilité legacy)
|
# Vérifier la clé API (en tant que header X-API-Key ou Bearer token)
|
||||||
if api_key and api_key == settings.api_key:
|
if (api_key and api_key == settings.api_key) or (token and token == settings.api_key):
|
||||||
return {"type": "api_key", "authenticated": True}
|
return {"type": "api_key", "authenticated": True}
|
||||||
|
|
||||||
# Vérifier le token JWT
|
# Vérifier le token JWT
|
||||||
|
|||||||
@ -20,16 +20,16 @@ class Alert(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
level: Mapped[Optional[str]] = mapped_column(String(20))
|
level: Mapped[str] = mapped_column(String(20), nullable=True)
|
||||||
title: Mapped[Optional[str]] = mapped_column(String(255))
|
title: Mapped[str] = mapped_column(String(255), nullable=True)
|
||||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
source: Mapped[Optional[str]] = mapped_column(String(50))
|
source: Mapped[str] = mapped_column(String(50), nullable=True)
|
||||||
details: Mapped[Optional[dict]] = mapped_column(JSON)
|
details: Mapped[dict] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
read_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
|||||||
@ -17,7 +17,7 @@ class AppSetting(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||||
value: Mapped[Optional[str]] = mapped_column(Text)
|
value: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@ -16,9 +16,9 @@ class BootstrapStatus(Base):
|
|||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String, nullable=False)
|
status: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
automation_user: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
automation_user: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
last_attempt: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
last_attempt: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
error_message: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
host: Mapped["Host"] = relationship("Host", back_populates="bootstrap_statuses")
|
host: Mapped["Host"] = relationship("Host", back_populates="bootstrap_statuses")
|
||||||
|
|||||||
@ -14,14 +14,14 @@ class ContainerCustomization(Base):
|
|||||||
__tablename__ = "container_customizations"
|
__tablename__ = "container_customizations"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
|
||||||
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
||||||
container_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
container_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
|
||||||
icon_key: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
icon_key: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||||
icon_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
icon_color: Mapped[str] = mapped_column(String(20), nullable=True)
|
||||||
bg_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
bg_color: Mapped[str] = mapped_column(String(20), nullable=True)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@ -20,12 +20,12 @@ class DockerAlert(Base):
|
|||||||
container_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
container_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="warning") # warning/error/critical
|
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="warning") # warning/error/critical
|
||||||
state: Mapped[str] = mapped_column(String(20), nullable=False, default="open") # open/closed/acknowledged
|
state: Mapped[str] = mapped_column(String(20), nullable=False, default="open") # open/closed/acknowledged
|
||||||
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
message: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
closed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
acknowledged_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
acknowledged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
acknowledged_by: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
acknowledged_by: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||||
last_notified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_notified_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Relationship to host
|
# Relationship to host
|
||||||
host: Mapped["Host"] = relationship("Host", back_populates="docker_alerts")
|
host: Mapped["Host"] = relationship("Host", back_populates="docker_alerts")
|
||||||
|
|||||||
@ -19,14 +19,14 @@ class DockerContainer(Base):
|
|||||||
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
||||||
container_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
container_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
image: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
image: Mapped[str] = mapped_column(String(255), nullable=True)
|
||||||
state: Mapped[str] = mapped_column(String(20), nullable=False, default="unknown") # running/exited/paused/created/dead
|
state: Mapped[str] = mapped_column(String(20), nullable=False, default="unknown") # running/exited/paused/created/dead
|
||||||
status: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Up 2 hours, Exited (0) 5 minutes ago
|
status: Mapped[str] = mapped_column(String(255), nullable=True) # Up 2 hours, Exited (0) 5 minutes ago
|
||||||
health: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # healthy/unhealthy/starting/none
|
health: Mapped[str] = mapped_column(String(20), nullable=True) # healthy/unhealthy/starting/none
|
||||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
ports: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
ports: Mapped[dict] = mapped_column(JSON, nullable=True)
|
||||||
labels: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
labels: Mapped[dict] = mapped_column(JSON, nullable=True)
|
||||||
compose_project: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # com.docker.compose.project
|
compose_project: Mapped[str] = mapped_column(String(255), nullable=True) # com.docker.compose.project
|
||||||
last_update_at: Mapped[datetime] = mapped_column(
|
last_update_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@ -18,9 +18,9 @@ class DockerImage(Base):
|
|||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
||||||
image_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
image_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
repo_tags: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) # ["nginx:latest", "nginx:1.25"]
|
repo_tags: Mapped[list] = mapped_column(JSON, nullable=True) # ["nginx:latest", "nginx:1.25"]
|
||||||
size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
size: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||||
created: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
last_update_at: Mapped[datetime] = mapped_column(
|
last_update_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@ -18,9 +18,9 @@ class DockerVolume(Base):
|
|||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
driver: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
driver: Mapped[str] = mapped_column(String(50), nullable=True)
|
||||||
mountpoint: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
mountpoint: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
scope: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # local/global
|
scope: Mapped[str] = mapped_column(String(20), nullable=True) # local/global
|
||||||
last_update_at: Mapped[datetime] = mapped_column(
|
last_update_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@ -14,13 +14,13 @@ class FavoriteContainer(Base):
|
|||||||
__tablename__ = "favorite_containers"
|
__tablename__ = "favorite_containers"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
|
||||||
docker_container_id: Mapped[int] = mapped_column(
|
docker_container_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("docker_containers.id", ondelete="CASCADE"), nullable=False
|
Integer, ForeignKey("docker_containers.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
group_id: Mapped[Optional[int]] = mapped_column(
|
group_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("favorite_groups.id", ondelete="SET NULL"), nullable=True
|
Integer, ForeignKey("favorite_groups.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -14,11 +14,11 @@ class FavoriteGroup(Base):
|
|||||||
__tablename__ = "favorite_groups"
|
__tablename__ = "favorite_groups"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
||||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||||
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
color: Mapped[str] = mapped_column(String(20), nullable=True)
|
||||||
icon_key: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
icon_key: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|||||||
@ -17,18 +17,18 @@ class Host(Base):
|
|||||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
ip_address: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
ip_address: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
||||||
status: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'unknown'"))
|
status: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'unknown'"))
|
||||||
ansible_group: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
ansible_group: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
last_seen: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
reachable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
reachable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
deleted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Docker-related fields
|
# Docker-related fields
|
||||||
docker_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
docker_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
||||||
docker_version: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
docker_version: Mapped[str] = mapped_column(String(50), nullable=True)
|
||||||
docker_status: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # online/offline/error
|
docker_status: Mapped[str] = mapped_column(String(20), nullable=True) # online/offline/error
|
||||||
docker_last_collect_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
docker_last_collect_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
bootstrap_statuses: Mapped[List["BootstrapStatus"]] = relationship(
|
bootstrap_statuses: Mapped[List["BootstrapStatus"]] = relationship(
|
||||||
"BootstrapStatus", back_populates="host", cascade="all, delete-orphan"
|
"BootstrapStatus", back_populates="host", cascade="all, delete-orphan"
|
||||||
|
|||||||
@ -24,60 +24,60 @@ class HostMetrics(Base):
|
|||||||
metric_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'system_info', 'disk_usage', 'memory', etc.
|
metric_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'system_info', 'disk_usage', 'memory', etc.
|
||||||
|
|
||||||
# Métriques CPU
|
# Métriques CPU
|
||||||
cpu_count: Mapped[Optional[int]] = mapped_column(Integer)
|
cpu_count: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
cpu_model: Mapped[Optional[str]] = mapped_column(String(200))
|
cpu_model: Mapped[str] = mapped_column(String(200), nullable=True)
|
||||||
cpu_cores: Mapped[Optional[int]] = mapped_column(Integer)
|
cpu_cores: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
cpu_threads: Mapped[Optional[int]] = mapped_column(Integer)
|
cpu_threads: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
cpu_threads_per_core: Mapped[Optional[int]] = mapped_column(Integer)
|
cpu_threads_per_core: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
cpu_sockets: Mapped[Optional[int]] = mapped_column(Integer)
|
cpu_sockets: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
cpu_mhz: Mapped[Optional[float]] = mapped_column(Float)
|
cpu_mhz: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
cpu_max_mhz: Mapped[Optional[float]] = mapped_column(Float)
|
cpu_max_mhz: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
cpu_min_mhz: Mapped[Optional[float]] = mapped_column(Float)
|
cpu_min_mhz: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
cpu_load_1m: Mapped[Optional[float]] = mapped_column(Float)
|
cpu_load_1m: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
cpu_load_5m: Mapped[Optional[float]] = mapped_column(Float)
|
cpu_load_5m: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
cpu_load_15m: Mapped[Optional[float]] = mapped_column(Float)
|
cpu_load_15m: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
cpu_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
|
cpu_usage_percent: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
cpu_temperature: Mapped[Optional[float]] = mapped_column(Float)
|
cpu_temperature: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
# Métriques mémoire
|
# Métriques mémoire
|
||||||
memory_total_mb: Mapped[Optional[int]] = mapped_column(Integer)
|
memory_total_mb: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
memory_used_mb: Mapped[Optional[int]] = mapped_column(Integer)
|
memory_used_mb: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
memory_free_mb: Mapped[Optional[int]] = mapped_column(Integer)
|
memory_free_mb: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
memory_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
|
memory_usage_percent: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
swap_total_mb: Mapped[Optional[int]] = mapped_column(Integer)
|
swap_total_mb: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
swap_used_mb: Mapped[Optional[int]] = mapped_column(Integer)
|
swap_used_mb: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
swap_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
|
swap_usage_percent: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
# Métriques disque (stockées en JSON pour flexibilité - plusieurs disques)
|
# Métriques disque (stockées en JSON pour flexibilité - plusieurs disques)
|
||||||
disk_info: Mapped[Optional[object]] = mapped_column(JSON) # Liste des points de montage avec usage
|
disk_info: Mapped[object] = mapped_column(JSON, nullable=True) # Liste des points de montage avec usage
|
||||||
disk_devices: Mapped[Optional[object]] = mapped_column(JSON) # Liste des disques + partitions (layout)
|
disk_devices: Mapped[object] = mapped_column(JSON) # Liste des disques + partitions (layout, nullable=True)
|
||||||
disk_root_total_gb: Mapped[Optional[float]] = mapped_column(Float)
|
disk_root_total_gb: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
disk_root_used_gb: Mapped[Optional[float]] = mapped_column(Float)
|
disk_root_used_gb: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
disk_root_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
|
disk_root_usage_percent: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
# Storage stacks (JSON)
|
# Storage stacks (JSON)
|
||||||
lvm_info: Mapped[Optional[object]] = mapped_column(JSON)
|
lvm_info: Mapped[object] = mapped_column(JSON, nullable=True)
|
||||||
zfs_info: Mapped[Optional[object]] = mapped_column(JSON)
|
zfs_info: Mapped[object] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
# Stockage détaillé (JSON) - données normalisées avec métadonnées de collecte
|
# Stockage détaillé (JSON) - données normalisées avec métadonnées de collecte
|
||||||
storage_details: Mapped[Optional[dict]] = mapped_column(JSON)
|
storage_details: Mapped[dict] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
# Informations système
|
# Informations système
|
||||||
os_name: Mapped[Optional[str]] = mapped_column(String(100))
|
os_name: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||||
os_version: Mapped[Optional[str]] = mapped_column(String(100))
|
os_version: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||||
kernel_version: Mapped[Optional[str]] = mapped_column(String(100))
|
kernel_version: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||||
hostname: Mapped[Optional[str]] = mapped_column(String(200))
|
hostname: Mapped[str] = mapped_column(String(200), nullable=True)
|
||||||
uptime_seconds: Mapped[Optional[int]] = mapped_column(Integer)
|
uptime_seconds: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
uptime_human: Mapped[Optional[str]] = mapped_column(String(100))
|
uptime_human: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
# Réseau (stocké en JSON pour flexibilité)
|
# Réseau (stocké en JSON pour flexibilité)
|
||||||
network_info: Mapped[Optional[dict]] = mapped_column(JSON)
|
network_info: Mapped[dict] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
# Données brutes et métadonnées
|
# Données brutes et métadonnées
|
||||||
raw_data: Mapped[Optional[dict]] = mapped_column(JSON) # Données brutes du playbook
|
raw_data: Mapped[dict] = mapped_column(JSON, nullable=True) # Données brutes du playbook
|
||||||
collection_source: Mapped[Optional[str]] = mapped_column(String(100)) # Nom du builtin playbook
|
collection_source: Mapped[str] = mapped_column(String(100), nullable=True) # Nom du builtin playbook
|
||||||
collection_duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
|
collection_duration_ms: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
error_message: Mapped[Optional[str]] = mapped_column(Text)
|
error_message: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
collected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
collected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|||||||
@ -20,12 +20,12 @@ class Log(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
level: Mapped[str] = mapped_column(String, nullable=False)
|
level: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
source: Mapped[Optional[str]] = mapped_column(String)
|
source: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
details: Mapped[Optional[dict]] = mapped_column(JSON)
|
details: Mapped[dict] = mapped_column(JSON, nullable=True)
|
||||||
host_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
host_id: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
task_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
task_id: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
schedule_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
schedule_id: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
"""
|
"""
|
||||||
Model for storing ansible-lint results per playbook.
|
Model for storing ansible-lint results per playbook.
|
||||||
"""
|
"""
|
||||||
@ -33,10 +34,10 @@ class PlaybookLintResult(Base):
|
|||||||
execution_time_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
execution_time_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
# Full issues list as JSON
|
# Full issues list as JSON
|
||||||
issues_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
issues_json: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
# Raw output from ansible-lint (for copy to clipboard)
|
# Raw output from ansible-lint (for copy to clipboard)
|
||||||
raw_output: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
raw_output: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@ -15,35 +15,35 @@ class Schedule(Base):
|
|||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
description: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
playbook: Mapped[str] = mapped_column(String, nullable=False)
|
playbook: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
target_type: Mapped[Optional[str]] = mapped_column(String, default="group")
|
target_type: Mapped[str] = mapped_column(String, default="group", nullable=True)
|
||||||
target: Mapped[str] = mapped_column(String, nullable=False)
|
target: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
extra_vars: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON)
|
extra_vars: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=True)
|
||||||
schedule_type: Mapped[str] = mapped_column(String, nullable=False)
|
schedule_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
schedule_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
schedule_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
recurrence_type: Mapped[Optional[str]] = mapped_column(String)
|
recurrence_type: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
recurrence_time: Mapped[Optional[str]] = mapped_column(String)
|
recurrence_time: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
recurrence_days: Mapped[Optional[str]] = mapped_column(Text)
|
recurrence_days: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
cron_expression: Mapped[Optional[str]] = mapped_column(String)
|
cron_expression: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
timezone: Mapped[Optional[str]] = mapped_column(String, default="America/Montreal")
|
timezone: Mapped[str] = mapped_column(String, default="America/Montreal", nullable=True)
|
||||||
start_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
end_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
tags: Mapped[Optional[str]] = mapped_column(Text)
|
tags: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
next_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
next_run: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
last_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
last_run: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
last_status: Mapped[Optional[str]] = mapped_column(String, default="never")
|
last_status: Mapped[str] = mapped_column(String, default="never", nullable=True)
|
||||||
retry_on_failure: Mapped[Optional[int]] = mapped_column(Integer, default=0)
|
retry_on_failure: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
|
||||||
timeout: Mapped[Optional[int]] = mapped_column(Integer, default=3600)
|
timeout: Mapped[int] = mapped_column(Integer, default=3600, nullable=True)
|
||||||
# Type de notification: "none" (aucune), "all" (toujours), "errors" (erreurs seulement)
|
# Type de notification: "none" (aucune), "all" (toujours), "errors" (erreurs seulement)
|
||||||
notification_type: Mapped[Optional[str]] = mapped_column(String, default="all")
|
notification_type: Mapped[str] = mapped_column(String, default="all", nullable=True)
|
||||||
run_count: Mapped[Optional[int]] = mapped_column(Integer, default=0)
|
run_count: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
|
||||||
success_count: Mapped[Optional[int]] = mapped_column(Integer, default=0)
|
success_count: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
|
||||||
failure_count: Mapped[Optional[int]] = mapped_column(Integer, default=0)
|
failure_count: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
deleted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
runs: Mapped[List["ScheduleRun"]] = relationship(
|
runs: Mapped[List["ScheduleRun"]] = relationship(
|
||||||
"ScheduleRun", back_populates="schedule", cascade="all, delete-orphan"
|
"ScheduleRun", back_populates="schedule", cascade="all, delete-orphan"
|
||||||
|
|||||||
@ -15,18 +15,18 @@ class ScheduleRun(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
schedule_id: Mapped[str] = mapped_column(String, ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False)
|
schedule_id: Mapped[str] = mapped_column(String, ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False)
|
||||||
task_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("tasks.id", ondelete="SET NULL"))
|
task_id: Mapped[str] = mapped_column(String, ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String, nullable=False)
|
status: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
duration: Mapped[Optional[float]] = mapped_column(Float)
|
duration: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
hosts_impacted: Mapped[Optional[int]] = mapped_column(Integer, default=0)
|
hosts_impacted: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
|
||||||
error_message: Mapped[Optional[str]] = mapped_column(Text)
|
error_message: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
output: Mapped[Optional[str]] = mapped_column(Text)
|
output: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
schedule: Mapped["Schedule"] = relationship("Schedule", back_populates="runs")
|
schedule: Mapped["Schedule"] = relationship("Schedule", back_populates="runs")
|
||||||
task: Mapped[Optional["Task"]] = relationship("Task", back_populates="schedule_runs")
|
task: Mapped["Task"] = relationship("Task", back_populates="schedule_runs")
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||||
return f"<ScheduleRun id={self.id} schedule_id={self.schedule_id} status={self.status}>"
|
return f"<ScheduleRun id={self.id} schedule_id={self.schedule_id} status={self.status}>"
|
||||||
|
|||||||
@ -17,11 +17,11 @@ class Task(Base):
|
|||||||
action: Mapped[str] = mapped_column(String, nullable=False)
|
action: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
target: Mapped[str] = mapped_column(String, nullable=False)
|
target: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'pending'"))
|
status: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'pending'"))
|
||||||
playbook: Mapped[Optional[str]] = mapped_column(String)
|
playbook: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
error_message: Mapped[Optional[str]] = mapped_column(Text)
|
error_message: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
result_data: Mapped[Optional[dict]] = mapped_column(JSON)
|
result_data: Mapped[dict] = mapped_column(JSON, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
schedule_runs: Mapped[list["ScheduleRun"]] = relationship("ScheduleRun", back_populates="task")
|
schedule_runs: Mapped[list["ScheduleRun"]] = relationship("ScheduleRun", back_populates="task")
|
||||||
|
|||||||
@ -38,12 +38,12 @@ class TerminalCommandLog(Base):
|
|||||||
host_id: Mapped[str] = mapped_column(
|
host_id: Mapped[str] = mapped_column(
|
||||||
String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
|
String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
user_id: Mapped[Optional[str]] = mapped_column(
|
user_id: Mapped[str] = mapped_column(
|
||||||
String, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
String, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Session reference (not FK as sessions may be cleaned up)
|
# Session reference (not FK as sessions may be cleaned up)
|
||||||
terminal_session_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
terminal_session_id: Mapped[str] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
|
||||||
# Command data (masked/normalized version only)
|
# Command data (masked/normalized version only)
|
||||||
command: Mapped[str] = mapped_column(Text, nullable=False)
|
command: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
@ -58,11 +58,11 @@ class TerminalCommandLog(Base):
|
|||||||
|
|
||||||
# If command was blocked (for audit - no raw command stored)
|
# If command was blocked (for audit - no raw command stored)
|
||||||
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
||||||
blocked_reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
blocked_reason: Mapped[str] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
# Additional metadata
|
# Additional metadata
|
||||||
username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
username: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||||
host_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
host_name: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
host = relationship("Host", back_populates="terminal_commands", lazy="selectin")
|
host = relationship("Host", back_populates="terminal_commands", lazy="selectin")
|
||||||
|
|||||||
@ -57,15 +57,15 @@ class TerminalSession(Base):
|
|||||||
host_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
host_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||||
host_name: Mapped[str] = mapped_column(String, nullable=False)
|
host_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
host_ip: Mapped[str] = mapped_column(String, nullable=False)
|
host_ip: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
user_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
user_id: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
username: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
username: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
# Token hash for session authentication (never store plain token)
|
# Token hash for session authentication (never store plain token)
|
||||||
token_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
token_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
|
||||||
# ttyd process management
|
# ttyd process management
|
||||||
ttyd_port: Mapped[int] = mapped_column(Integer, nullable=False)
|
ttyd_port: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
ttyd_pid: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
ttyd_pid: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
# Session mode: 'embedded' or 'popout'
|
# Session mode: 'embedded' or 'popout'
|
||||||
mode: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'embedded'"))
|
mode: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'embedded'"))
|
||||||
@ -74,7 +74,7 @@ class TerminalSession(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
|
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
|
||||||
|
|
||||||
# Close reason: 'user_close', 'ttl', 'idle', 'quota', 'server_shutdown', 'client_lost', 'reused'
|
# Close reason: 'user_close', 'ttl', 'idle', 'quota', 'server_shutdown', 'client_lost', 'reused'
|
||||||
reason_closed: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
|
reason_closed: Mapped[str] = mapped_column(String(30), nullable=True)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
@ -84,7 +84,7 @@ class TerminalSession(Base):
|
|||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
closed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<TerminalSession id={self.id[:8]}... host={self.host_name} status={self.status}>"
|
return f"<TerminalSession id={self.id[:8]}... host={self.host_name} status={self.status}>"
|
||||||
|
|||||||
@ -39,7 +39,7 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
email: Mapped[Optional[str]] = mapped_column(String(255), unique=True, nullable=True)
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=True)
|
||||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
# Role-based access control (prepared for future)
|
# Role-based access control (prepared for future)
|
||||||
@ -54,7 +54,7 @@ class User(Base):
|
|||||||
is_superuser: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
is_superuser: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
||||||
|
|
||||||
# Display name (optional)
|
# Display name (optional)
|
||||||
display_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
display_name: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
@ -68,11 +68,11 @@ class User(Base):
|
|||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
onupdate=func.now()
|
onupdate=func.now()
|
||||||
)
|
)
|
||||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_login: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
password_changed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
password_changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Soft delete support
|
# Soft delete support
|
||||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
deleted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
terminal_commands: Mapped[List["TerminalCommandLog"]] = relationship(
|
terminal_commands: Mapped[List["TerminalCommandLog"]] = relationship(
|
||||||
|
|||||||
@ -29,6 +29,7 @@ from app.routes.lint import router as lint_router
|
|||||||
from app.routes.terminal import router as terminal_router
|
from app.routes.terminal import router as terminal_router
|
||||||
from app.routes.config import router as config_router
|
from app.routes.config import router as config_router
|
||||||
from app.routes.favorites import router as favorites_router
|
from app.routes.favorites import router as favorites_router
|
||||||
|
from app.routes.users import router as users_router
|
||||||
|
|
||||||
# Router principal qui agrège tous les sous-routers
|
# Router principal qui agrège tous les sous-routers
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@ -56,6 +57,7 @@ api_router.include_router(lint_router, prefix="/playbooks", tags=["Lint"])
|
|||||||
api_router.include_router(terminal_router, prefix="/terminal", tags=["Terminal"])
|
api_router.include_router(terminal_router, prefix="/terminal", tags=["Terminal"])
|
||||||
api_router.include_router(favorites_router, prefix="/favorites", tags=["Favorites"])
|
api_router.include_router(favorites_router, prefix="/favorites", tags=["Favorites"])
|
||||||
api_router.include_router(config_router, tags=["Config"])
|
api_router.include_router(config_router, tags=["Config"])
|
||||||
|
api_router.include_router(users_router, prefix="/users", tags=["Users"])
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"api_router",
|
"api_router",
|
||||||
|
|||||||
@ -1,12 +1,39 @@
|
|||||||
from fastapi import APIRouter
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.dependencies import verify_api_key
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_START_TIME = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config")
|
@router.get("/config")
|
||||||
async def get_app_config():
|
async def get_app_config():
|
||||||
|
"""Récupère la configuration publique de l'application."""
|
||||||
return {
|
return {
|
||||||
"debug_mode": bool(settings.debug_mode),
|
"debug_mode": bool(settings.debug_mode),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config/version")
|
||||||
|
async def get_app_version(api_key_valid: bool = Depends(verify_api_key)):
|
||||||
|
"""Retourne la version de l'application, Python, et l'uptime."""
|
||||||
|
uptime_seconds = int(time.monotonic() - _START_TIME)
|
||||||
|
hours, remainder = divmod(uptime_seconds, 3600)
|
||||||
|
minutes, seconds = divmod(remainder, 60)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"app_version": settings.api_version,
|
||||||
|
"app_title": settings.api_title,
|
||||||
|
"python_version": sys.version,
|
||||||
|
"python_executable": sys.executable,
|
||||||
|
"timezone": settings.scheduler_timezone,
|
||||||
|
"uptime": f"{hours}h {minutes}m {seconds}s",
|
||||||
|
"uptime_seconds": uptime_seconds,
|
||||||
|
"server_time": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|||||||
@ -524,6 +524,21 @@ async def get_volumes(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Networks ===
|
||||||
|
|
||||||
|
@router.get("/hosts/{host_id}/networks")
|
||||||
|
async def get_networks(
|
||||||
|
host_id: str,
|
||||||
|
api_key_valid: bool = Depends(verify_api_key),
|
||||||
|
):
|
||||||
|
"""Liste les réseaux Docker d'un hôte."""
|
||||||
|
try:
|
||||||
|
result = await docker_actions.list_networks(host_id)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Erreur lors de la récupération des réseaux: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
# === Alerts ===
|
# === Alerts ===
|
||||||
|
|
||||||
@router.get("/alerts", response_model=DockerAlertListResponse)
|
@router.get("/alerts", response_model=DockerAlertListResponse)
|
||||||
|
|||||||
@ -112,3 +112,40 @@ async def refresh_hosts(api_key_valid: bool = Depends(verify_api_key)):
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {"message": f"{len(hosts)} hôtes rechargés depuis l'inventaire Ansible"}
|
return {"message": f"{len(hosts)} hôtes rechargés depuis l'inventaire Ansible"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/check-all")
|
||||||
|
async def check_all_hosts_health(api_key_valid: bool = Depends(verify_api_key)):
|
||||||
|
"""Exécute un health check sur tous les hôtes en parallèle."""
|
||||||
|
results = []
|
||||||
|
online = 0
|
||||||
|
offline = 0
|
||||||
|
|
||||||
|
for host in db.hosts:
|
||||||
|
health_ok = host.status == "online"
|
||||||
|
status = "online" if health_ok else "offline"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"host": host.name,
|
||||||
|
"status": status,
|
||||||
|
"reachable": health_ok,
|
||||||
|
"os": host.os,
|
||||||
|
})
|
||||||
|
|
||||||
|
if health_ok:
|
||||||
|
online += 1
|
||||||
|
else:
|
||||||
|
offline += 1
|
||||||
|
|
||||||
|
await ws_manager.broadcast({
|
||||||
|
"type": "health_check_all",
|
||||||
|
"data": {"online": online, "offline": offline, "total": len(results)},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": len(results),
|
||||||
|
"online": online,
|
||||||
|
"offline": offline,
|
||||||
|
"results": results,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|||||||
@ -64,3 +64,35 @@ async def download_help_pdf(api_key_valid: bool = Depends(verify_api_key)):
|
|||||||
"Content-Disposition": "attachment; filename=homelab-automation-help.pdf"
|
"Content-Disposition": "attachment; filename=homelab-automation-help.pdf"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog")
|
||||||
|
async def get_api_catalog(api_key_valid: bool = Depends(verify_api_key)):
|
||||||
|
"""
|
||||||
|
Retourne un catalogue de tous les points d'entrée de l'API en format JSON.
|
||||||
|
C'est une version simplifiée de la spec OpenAPI (Swagger).
|
||||||
|
"""
|
||||||
|
from app.routes import api_router
|
||||||
|
|
||||||
|
catalog = []
|
||||||
|
for route in api_router.routes:
|
||||||
|
# On ne liste que les routes qui ont un chemin et des méthodes (pas les Mount)
|
||||||
|
if hasattr(route, "path") and hasattr(route, "methods"):
|
||||||
|
# Exclure les routes internes ou de base si nécessaire
|
||||||
|
if any(p in route.path for p in ["/openapi.json", "/docs", "/redoc"]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
catalog.append({
|
||||||
|
"path": f"/api{route.path}" if not route.path.startswith("/api") else route.path,
|
||||||
|
"methods": list(route.methods),
|
||||||
|
"summary": route.summary if hasattr(route, "summary") and route.summary else route.name,
|
||||||
|
"description": route.description if hasattr(route, "description") and route.description else "",
|
||||||
|
"tags": list(route.tags) if hasattr(route, "tags") and route.tags else []
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": "Homelab Automation API Catalog",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"endpoints_count": len(catalog),
|
||||||
|
"endpoints": catalog
|
||||||
|
}
|
||||||
|
|||||||
@ -57,6 +57,42 @@ async def send_custom_notification(
|
|||||||
return await notification_service.send_request(request)
|
return await notification_service.send_request(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config")
|
||||||
|
async def update_notification_config(
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
default_topic: Optional[str] = None,
|
||||||
|
enabled: Optional[bool] = None,
|
||||||
|
timeout: Optional[int] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
password: Optional[str] = None,
|
||||||
|
token: Optional[str] = None,
|
||||||
|
api_key_valid: bool = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""Met à jour la configuration des notifications ntfy."""
|
||||||
|
current_config = notification_service.config
|
||||||
|
new_config = NtfyConfig(
|
||||||
|
base_url=base_url if base_url is not None else current_config.base_url,
|
||||||
|
default_topic=default_topic if default_topic is not None else current_config.default_topic,
|
||||||
|
enabled=enabled if enabled is not None else current_config.enabled,
|
||||||
|
timeout=timeout if timeout is not None else current_config.timeout,
|
||||||
|
username=username if username is not None else current_config.username,
|
||||||
|
password=password if password is not None else current_config.password,
|
||||||
|
token=token if token is not None else current_config.token,
|
||||||
|
)
|
||||||
|
notification_service.reconfigure(new_config)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Configuration mise à jour",
|
||||||
|
"config": {
|
||||||
|
"enabled": new_config.enabled,
|
||||||
|
"base_url": new_config.base_url,
|
||||||
|
"default_topic": new_config.default_topic,
|
||||||
|
"timeout": new_config.timeout,
|
||||||
|
"has_auth": new_config.has_auth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/toggle")
|
@router.post("/toggle")
|
||||||
async def toggle_notifications(
|
async def toggle_notifications(
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
|
|||||||
@ -16,6 +16,38 @@ from app.services import ansible_service, db
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_playbooks(
|
||||||
|
api_key_valid: bool = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""Liste tous les playbooks avec métadonnées (taille, date, etc.)."""
|
||||||
|
playbooks_dir = ansible_service.playbooks_dir
|
||||||
|
if not playbooks_dir.exists():
|
||||||
|
return {"playbooks": [], "count": 0}
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for path in sorted(playbooks_dir.glob("*.yml")):
|
||||||
|
if path.name.startswith("_builtin_"):
|
||||||
|
continue
|
||||||
|
stat = path.stat()
|
||||||
|
result.append({
|
||||||
|
"filename": path.name,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
for path in sorted(playbooks_dir.glob("*.yaml")):
|
||||||
|
if path.name.startswith("_builtin_"):
|
||||||
|
continue
|
||||||
|
stat = path.stat()
|
||||||
|
result.append({
|
||||||
|
"filename": path.name,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"playbooks": result, "count": len(result)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{filename}/content")
|
@router.get("/{filename}/content")
|
||||||
async def get_playbook_content(
|
async def get_playbook_content(
|
||||||
filename: str,
|
filename: str,
|
||||||
|
|||||||
153
app/routes/users.py
Normal file
153
app/routes/users.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
Routes API pour la gestion des utilisateurs (admin).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.dependencies import get_db, require_admin
|
||||||
|
from app.crud.user import UserRepository
|
||||||
|
from app.schemas.auth import UserCreate, UserUpdate, UserOut
|
||||||
|
from app.services.auth_service import hash_password
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserOut])
|
||||||
|
async def list_users(
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
user: dict = Depends(require_admin),
|
||||||
|
db_session: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Liste tous les utilisateurs (admin uniquement)."""
|
||||||
|
repo = UserRepository(db_session)
|
||||||
|
users = await repo.list(limit=limit, offset=offset, include_deleted=include_deleted)
|
||||||
|
return [UserOut.model_validate(u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user(
|
||||||
|
user_data: UserCreate,
|
||||||
|
user: dict = Depends(require_admin),
|
||||||
|
db_session: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Crée un nouvel utilisateur (admin uniquement)."""
|
||||||
|
repo = UserRepository(db_session)
|
||||||
|
|
||||||
|
# Vérifier l'unicité du username
|
||||||
|
existing = await repo.get_by_username(user_data.username)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Le nom d'utilisateur '{user_data.username}' est déjà utilisé",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Vérifier l'unicité de l'email si fourni
|
||||||
|
if user_data.email:
|
||||||
|
existing_email = await repo.get_by_email(user_data.email)
|
||||||
|
if existing_email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Cette adresse email est déjà utilisée",
|
||||||
|
)
|
||||||
|
|
||||||
|
hashed_password = hash_password(user_data.password)
|
||||||
|
new_user = await repo.create(
|
||||||
|
username=user_data.username,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
email=user_data.email,
|
||||||
|
display_name=user_data.display_name,
|
||||||
|
role=user_data.role,
|
||||||
|
is_active=user_data.is_active,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(new_user)
|
||||||
|
|
||||||
|
return UserOut.model_validate(new_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserOut)
|
||||||
|
async def get_user(
|
||||||
|
user_id: int,
|
||||||
|
user: dict = Depends(require_admin),
|
||||||
|
db_session: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Récupère les détails d'un utilisateur (admin uniquement)."""
|
||||||
|
repo = UserRepository(db_session)
|
||||||
|
target_user = await repo.get(user_id)
|
||||||
|
|
||||||
|
if not target_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Utilisateur non trouvé",
|
||||||
|
)
|
||||||
|
|
||||||
|
return UserOut.model_validate(target_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}", response_model=UserOut)
|
||||||
|
async def update_user(
|
||||||
|
user_id: int,
|
||||||
|
user_data: UserUpdate,
|
||||||
|
user: dict = Depends(require_admin),
|
||||||
|
db_session: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Met à jour un utilisateur (admin uniquement)."""
|
||||||
|
repo = UserRepository(db_session)
|
||||||
|
target_user = await repo.get(user_id)
|
||||||
|
|
||||||
|
if not target_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Utilisateur non trouvé",
|
||||||
|
)
|
||||||
|
|
||||||
|
update_fields = user_data.model_dump(exclude_unset=True)
|
||||||
|
if update_fields:
|
||||||
|
await repo.update(target_user, **update_fields)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(target_user)
|
||||||
|
|
||||||
|
return UserOut.model_validate(target_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
permanent: bool = False,
|
||||||
|
user: dict = Depends(require_admin),
|
||||||
|
db_session: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Désactive ou supprime un utilisateur (admin uniquement)."""
|
||||||
|
repo = UserRepository(db_session)
|
||||||
|
target_user = await repo.get(user_id, include_deleted=True)
|
||||||
|
|
||||||
|
if not target_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Utilisateur non trouvé",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Empêcher la suppression de soi-même
|
||||||
|
current_user_id = user.get("user_id")
|
||||||
|
if current_user_id and int(current_user_id) == user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Vous ne pouvez pas supprimer votre propre compte",
|
||||||
|
)
|
||||||
|
|
||||||
|
if permanent:
|
||||||
|
await repo.hard_delete(user_id)
|
||||||
|
else:
|
||||||
|
await repo.soft_delete(user_id)
|
||||||
|
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Utilisateur {'supprimé définitivement' if permanent else 'désactivé'}",
|
||||||
|
"user_id": user_id,
|
||||||
|
}
|
||||||
@ -613,5 +613,58 @@ class DockerActionsService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_networks(
|
||||||
|
self,
|
||||||
|
host_id: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""List Docker networks on a host."""
|
||||||
|
import json as json_lib
|
||||||
|
host = await self._get_host_info(host_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = await self._ssh_connect(host["ip"])
|
||||||
|
try:
|
||||||
|
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||||
|
result = await self._run_docker(
|
||||||
|
conn,
|
||||||
|
'docker network ls --format "{{json .}}"',
|
||||||
|
use_sudo_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
networks = []
|
||||||
|
if result["success"] and result["stdout"].strip():
|
||||||
|
for line in result["stdout"].strip().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
net = json_lib.loads(line)
|
||||||
|
networks.append({
|
||||||
|
"id": net.get("ID", ""),
|
||||||
|
"name": net.get("Name", ""),
|
||||||
|
"driver": net.get("Driver", ""),
|
||||||
|
"scope": net.get("Scope", ""),
|
||||||
|
})
|
||||||
|
except json_lib.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {
|
||||||
|
"host_id": host_id,
|
||||||
|
"host_name": host["name"],
|
||||||
|
"networks": networks,
|
||||||
|
"total": len(networks),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except DockerActionError as e:
|
||||||
|
return {
|
||||||
|
"host_id": host_id,
|
||||||
|
"host_name": host.get("name", ""),
|
||||||
|
"networks": [],
|
||||||
|
"total": 0,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
docker_actions = DockerActionsService()
|
docker_actions = DockerActionsService()
|
||||||
|
|||||||
1209
app/static/help.md
1209
app/static/help.md
File diff suppressed because it is too large
Load Diff
@ -191,7 +191,7 @@ class HelpMarkdownRenderer:
|
|||||||
|
|
||||||
def _process_accordions(self, html: str) -> str:
|
def _process_accordions(self, html: str) -> str:
|
||||||
"""Traite les accordéons."""
|
"""Traite les accordéons."""
|
||||||
pattern = r':::accordion\s+(fa-[\w-]+)\s+(\w+)\s+([^\n]+)\n(.*?):::'
|
pattern = r':::accordion\s+((?:fa[bs]?\s+)?fa-[\w-]+)\s+(\w+)\s+([^\n]+)\n(.*?):::'
|
||||||
|
|
||||||
def replace_accordion(match):
|
def replace_accordion(match):
|
||||||
icon = match.group(1)
|
icon = match.group(1)
|
||||||
@ -385,18 +385,25 @@ class HelpMarkdownRenderer:
|
|||||||
return '\n'.join(result)
|
return '\n'.join(result)
|
||||||
|
|
||||||
def _process_headings(self, html: str) -> str:
|
def _process_headings(self, html: str) -> str:
|
||||||
"""Traite les titres H3 et H4."""
|
"""Traite les titres H1, H3 et H4."""
|
||||||
|
# H1
|
||||||
|
html = re.sub(
|
||||||
|
r'^#\s+([^\n{]+)(?:\s*\{#[^}]+\})?\s*$',
|
||||||
|
r'<h1 class="text-3xl font-bold mb-6 text-white">\1</h1>',
|
||||||
|
html,
|
||||||
|
flags=re.MULTILINE
|
||||||
|
)
|
||||||
# H3
|
# H3
|
||||||
html = re.sub(
|
html = re.sub(
|
||||||
r'^### ([^\n{]+)$',
|
r'^###\s+([^\n{]+)(?:\s*\{#[^}]+\})?\s*$',
|
||||||
r'<h3 class="font-semibold mb-4 text-purple-400">\1</h3>',
|
r'<h3 class="font-semibold mb-4 text-purple-400">\1</h3>',
|
||||||
html,
|
html,
|
||||||
flags=re.MULTILINE
|
flags=re.MULTILINE
|
||||||
)
|
)
|
||||||
# H4
|
# H4
|
||||||
html = re.sub(
|
html = re.sub(
|
||||||
r'^#### ([^\n]+)$',
|
r'^####\s+([^\n{]+)(?:\s*\{#[^}]+\})?\s*$',
|
||||||
r'<h4 class="font-semibold mb-2 flex items-center gap-2"><i class="fas fa-question-circle text-gray-400"></i>\1</h4>',
|
r'<h4 class="font-semibold mb-2 flex items-center gap-2">\1</h4>',
|
||||||
html,
|
html,
|
||||||
flags=re.MULTILINE
|
flags=re.MULTILINE
|
||||||
)
|
)
|
||||||
|
|||||||
@ -110,16 +110,41 @@ def emoji_to_png_bytes(emoji: str, size: int = 32) -> Optional[bytes]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
def replace_emojis_with_images(text: str) -> str:
|
||||||
|
"""Remplace les emojis par des balises <img/> pour ReportLab."""
|
||||||
|
emoji_pattern = re.compile(
|
||||||
|
"["
|
||||||
|
"\U0001F600-\U0001F64F" # emoticons
|
||||||
|
"\U0001F300-\U0001F5FF" # symbols & pictographs
|
||||||
|
"\U0001F680-\U0001F6FF" # transport & map symbols
|
||||||
|
"\U0001F1E0-\U0001F1FF" # flags
|
||||||
|
"\U00002702-\U000027B0" # dingbats
|
||||||
|
"\U000024C2-\U0001F251" # enclosed characters
|
||||||
|
"\U0001F900-\U0001F9FF" # supplemental symbols
|
||||||
|
"\U0001FA00-\U0001FA6F" # chess symbols
|
||||||
|
"\U0001FA70-\U0001FAFF" # symbols extended
|
||||||
|
"\U00002600-\U000026FF" # misc symbols
|
||||||
|
"]"
|
||||||
|
)
|
||||||
|
|
||||||
|
def repl(match):
|
||||||
|
emoji = match.group()
|
||||||
|
png_bytes = emoji_to_png_bytes(emoji, size=24)
|
||||||
|
if png_bytes:
|
||||||
|
b64_img = base64.b64encode(png_bytes).decode('ascii')
|
||||||
|
# ReportLab image tag must be valid and size specified
|
||||||
|
return f'<img src="data:image/png;base64,{b64_img}" width="12" height="12"/>'
|
||||||
|
return emoji
|
||||||
|
|
||||||
|
return emoji_pattern.sub(repl, text)
|
||||||
|
|
||||||
def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> bytes:
|
def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> bytes:
|
||||||
"""Convertit du contenu Markdown en PDF.
|
"""Convertit du contenu Markdown en PDF."""
|
||||||
|
# Nettoyer les caractères de dessin de boîte pour Courier
|
||||||
|
markdown_content = markdown_content.replace('├──', '|--').replace('└──', '`--').replace('│', '|')
|
||||||
|
|
||||||
Args:
|
|
||||||
markdown_content: Contenu au format Markdown
|
|
||||||
title: Titre du document
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Bytes du PDF généré
|
|
||||||
"""
|
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
doc = SimpleDocTemplate(
|
doc = SimpleDocTemplate(
|
||||||
buffer,
|
buffer,
|
||||||
@ -130,7 +155,6 @@ def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> byt
|
|||||||
bottomMargin=2*cm
|
bottomMargin=2*cm
|
||||||
)
|
)
|
||||||
|
|
||||||
# Styles
|
|
||||||
styles = getSampleStyleSheet()
|
styles = getSampleStyleSheet()
|
||||||
|
|
||||||
title_style = ParagraphStyle(
|
title_style = ParagraphStyle(
|
||||||
@ -282,19 +306,19 @@ def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> byt
|
|||||||
|
|
||||||
# Titres
|
# Titres
|
||||||
if line.startswith('# '):
|
if line.startswith('# '):
|
||||||
emojis, text = extract_leading_emojis(line[2:].strip())
|
text = line[2:].strip()
|
||||||
display_text = f"{emojis} {text}".strip() if emojis else text
|
text = replace_emojis_with_images(text)
|
||||||
elements.append(Paragraph(display_text, h1_style))
|
elements.append(Paragraph(text, h1_style))
|
||||||
elif line.startswith('## '):
|
elif line.startswith('## '):
|
||||||
emojis, text = extract_leading_emojis(line[3:].strip())
|
text = line[3:].strip()
|
||||||
display_text = f"{emojis} {text}".strip() if emojis else text
|
text = replace_emojis_with_images(text)
|
||||||
elements.append(Paragraph(display_text, h2_style))
|
elements.append(Paragraph(text, h2_style))
|
||||||
elif line.startswith('### '):
|
elif line.startswith('### '):
|
||||||
emojis, text = extract_leading_emojis(line[4:].strip())
|
text = line[4:].strip()
|
||||||
display_text = f"{emojis} {text}".strip() if emojis else text
|
text = replace_emojis_with_images(text)
|
||||||
elements.append(Paragraph(display_text, h3_style))
|
elements.append(Paragraph(text, h3_style))
|
||||||
elif line.startswith('#### '):
|
elif line.startswith('#### '):
|
||||||
text = line[5:].strip()
|
text = replace_emojis_with_images(line[5:].strip())
|
||||||
elements.append(Paragraph(text, h4_style))
|
elements.append(Paragraph(text, h4_style))
|
||||||
|
|
||||||
# Listes à puces
|
# Listes à puces
|
||||||
@ -303,6 +327,7 @@ def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> byt
|
|||||||
# Convertir le markdown basique
|
# Convertir le markdown basique
|
||||||
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
||||||
text = re.sub(r'`(.+?)`', r'<font face="Courier">\1</font>', text)
|
text = re.sub(r'`(.+?)`', r'<font face="Courier">\1</font>', text)
|
||||||
|
text = replace_emojis_with_images(text)
|
||||||
elements.append(Paragraph(f"• {text}", bullet_style))
|
elements.append(Paragraph(f"• {text}", bullet_style))
|
||||||
|
|
||||||
# Listes numérotées
|
# Listes numérotées
|
||||||
@ -333,6 +358,7 @@ def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> byt
|
|||||||
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
||||||
text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
|
text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
|
||||||
text = re.sub(r'`(.+?)`', r'<font face="Courier" color="#c7254e">\1</font>', text)
|
text = re.sub(r'`(.+?)`', r'<font face="Courier" color="#c7254e">\1</font>', text)
|
||||||
|
text = replace_emojis_with_images(text)
|
||||||
elements.append(Paragraph(text, body_style))
|
elements.append(Paragraph(text, body_style))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
BIN
data/homelab.db
BIN
data/homelab.db
Binary file not shown.
11
dump_routes.py
Normal file
11
dump_routes.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from app.factory import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with open("routes_dump.txt", "w") as f:
|
||||||
|
f.write("=== APP ROUTES ===\n")
|
||||||
|
for route in app.routes:
|
||||||
|
methods = getattr(route, "methods", "N/A")
|
||||||
|
f.write(f"{methods} {route.path}\n")
|
||||||
|
|
||||||
|
print("Dumped to routes_dump.txt")
|
||||||
65
fix_for_314.py
Normal file
65
fix_for_314.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
models_dir = r"c:\dev\git\python\homelab-automation-api-v2\app\models"
|
||||||
|
|
||||||
|
def fix_file(filepath):
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
new_lines = []
|
||||||
|
# Make sure future annotations is ON to avoid runtime errors with piping if any remain,
|
||||||
|
# BUT wait, if I remove unions from Mapped, I don't need it as much for the scanner.
|
||||||
|
# However, keeping it on is usually better for modern code.
|
||||||
|
found_future = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if "from __future__ import annotations" in line:
|
||||||
|
new_lines.append("from __future__ import annotations\n")
|
||||||
|
found_future = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Clean up existing double hashes if any
|
||||||
|
line = line.replace("# # from __future__", "# from __future__")
|
||||||
|
|
||||||
|
# Match Mapped[T | None] or Mapped[Optional[T]]
|
||||||
|
# We want to transform to Mapped[T]
|
||||||
|
# and ensure mapped_column has nullable=True
|
||||||
|
|
||||||
|
match = re.search(r'(.*?):\s*Mapped\[(?:Optional\[(.*?)\|?(.*?)\]|([^\|]*?)\s*\|\s*None)\]\s*=\s*mapped_column\((.*?)\)(.*)', line)
|
||||||
|
if match:
|
||||||
|
# We found one.
|
||||||
|
indent = match.group(1)
|
||||||
|
# Try to get T from various groups
|
||||||
|
t = match.group(2) or match.group(4)
|
||||||
|
if not t and match.group(3): # For Optional[T] if it captured wrong
|
||||||
|
t = match.group(2)
|
||||||
|
|
||||||
|
column_args = match.group(5)
|
||||||
|
rest = match.group(6)
|
||||||
|
|
||||||
|
# Ensure nullable=True
|
||||||
|
if "nullable=" not in column_args:
|
||||||
|
if column_args.strip():
|
||||||
|
column_args = f"{column_args}, nullable=True"
|
||||||
|
else:
|
||||||
|
column_args = "nullable=True"
|
||||||
|
elif "nullable=False" in column_args:
|
||||||
|
# Should not happen as we found a Union with None, but if so, override
|
||||||
|
column_args = column_args.replace("nullable=False", "nullable=True")
|
||||||
|
|
||||||
|
new_line = f"{indent}: Mapped[{t}] = mapped_column({column_args}){rest}\n"
|
||||||
|
new_lines.append(new_line)
|
||||||
|
else:
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
if not found_future:
|
||||||
|
new_lines.insert(0, "from __future__ import annotations\n")
|
||||||
|
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
f.writelines(new_lines)
|
||||||
|
|
||||||
|
for filename in os.listdir(models_dir):
|
||||||
|
if filename.endswith(".py") and filename != "__init__.py":
|
||||||
|
print(f"Fixing {filename}...")
|
||||||
|
fix_file(os.path.join(models_dir, filename))
|
||||||
50
fix_for_314_v2.py
Normal file
50
fix_for_314_v2.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
models_dir = r"c:\dev\git\python\homelab-automation-api-v2\app\models"
|
||||||
|
|
||||||
|
def fix_file(filepath):
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Re-enable future annotations correctly
|
||||||
|
content = content.replace("# from __future__ import annotations", "from __future__ import annotations")
|
||||||
|
content = content.replace("# # from __future__ import annotations", "from __future__ import annotations")
|
||||||
|
|
||||||
|
# Match lines with Mapped[... | None] = mapped_column(...)
|
||||||
|
# We want to change to Mapped[...] and add nullable=True
|
||||||
|
|
||||||
|
def replacer(match):
|
||||||
|
indent_and_name = match.group(1)
|
||||||
|
type_name = match.group(2)
|
||||||
|
column_args = match.group(3)
|
||||||
|
rest = match.group(4)
|
||||||
|
|
||||||
|
# Strip trailing comma in args if it was at the end
|
||||||
|
args = column_args.strip()
|
||||||
|
if args and "nullable=" not in args:
|
||||||
|
if args.endswith(")"):
|
||||||
|
# It was likely something(args)
|
||||||
|
# We append , nullable=True at the end but INSIDE the mapped_column?
|
||||||
|
# Wait, mapped_column(String, nullable=True)
|
||||||
|
pass # Handle below
|
||||||
|
|
||||||
|
if "nullable=" not in args:
|
||||||
|
if args:
|
||||||
|
args += ", nullable=True"
|
||||||
|
else:
|
||||||
|
args = "nullable=True"
|
||||||
|
|
||||||
|
return f"{indent_and_name}: Mapped[{type_name}] = mapped_column({args}){rest}"
|
||||||
|
|
||||||
|
# Regex to find: some_field: Mapped[SOME_TYPE | None] = mapped_column(SOME_ARGS)
|
||||||
|
pattern = r'(\s+[\w_]+)\s*:\s*Mapped\[\s*([^\|\[\]]+)\s*\|\s*None\s*\]\s*=\s*mapped_column\((.*?)\)(.*)'
|
||||||
|
content = re.sub(pattern, replacer, content)
|
||||||
|
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
for filename in os.listdir(models_dir):
|
||||||
|
if filename.endswith(".py") and filename != "__init__.py":
|
||||||
|
print(f"Fixing {filename}...")
|
||||||
|
fix_file(os.path.join(models_dir, filename))
|
||||||
48
fix_for_314_v3.py
Normal file
48
fix_for_314_v3.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
models_dir = r"c:\dev\git\python\homelab-automation-api-v2\app\models"
|
||||||
|
|
||||||
|
def fix_file(filepath):
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Enable future annotations
|
||||||
|
content = content.replace("# # from __future__", "from __future__")
|
||||||
|
content = content.replace("# from __future__", "from __future__")
|
||||||
|
if "from __future__ import annotations" not in content:
|
||||||
|
content = "from __future__ import annotations\n" + content
|
||||||
|
|
||||||
|
# Massive replacement of Union/Optional style types inside Mapped for Python 3.14 workaround
|
||||||
|
# We remove the '| None' and ensure nullable=True in mapped_column
|
||||||
|
|
||||||
|
lines = content.split('\n')
|
||||||
|
new_lines = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if "Mapped[" in line and ("| None" in line or "Optional[" in line):
|
||||||
|
# Remove | None
|
||||||
|
new_line = line.replace(" | None", "").replace("| None", "").replace("Optional[", "").replace("]]", "]")
|
||||||
|
|
||||||
|
# If mapped_column exists, ensure nullable=True
|
||||||
|
if "mapped_column(" in new_line and "nullable=" not in new_line:
|
||||||
|
if "mapped_column()" in new_line:
|
||||||
|
new_line = new_line.replace("mapped_column()", "mapped_column(nullable=True)")
|
||||||
|
else:
|
||||||
|
# Find the closing parenthesis of mapped_column
|
||||||
|
# Example: mapped_column(String) or mapped_column(DateTime(timezone=True))
|
||||||
|
# We replace the last ')' with ', nullable=True)'
|
||||||
|
idx = new_line.rfind(')')
|
||||||
|
if idx != -1:
|
||||||
|
new_line = new_line[:idx] + ", nullable=True" + new_line[idx:]
|
||||||
|
|
||||||
|
new_lines.append(new_line)
|
||||||
|
else:
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
f.write('\n'.join(new_lines))
|
||||||
|
|
||||||
|
for filename in os.listdir(models_dir):
|
||||||
|
if filename.endswith(".py") and filename != "__init__.py":
|
||||||
|
print(f"Fixing {filename}...")
|
||||||
|
fix_file(os.path.join(models_dir, filename))
|
||||||
14
list_routes.py
Normal file
14
list_routes.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from app.factory import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
print("Registered Routes:")
|
||||||
|
for route in app.routes:
|
||||||
|
methods = getattr(route, "methods", "N/A")
|
||||||
|
print(f"{methods} {route.path}")
|
||||||
|
|
||||||
|
print("\nAPI Router Routes:")
|
||||||
|
from app.routes import api_router
|
||||||
|
for route in api_router.routes:
|
||||||
|
methods = getattr(route, "methods", "N/A")
|
||||||
|
print(f"{methods} {route.path}")
|
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,25 @@
|
|||||||
|
# ❌ Vérification de santé
|
||||||
|
|
||||||
|
## Informations
|
||||||
|
|
||||||
|
| Propriété | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| **ID** | `bece6bfc2efa482b8c2e3ff07695f163` |
|
||||||
|
| **Nom** | Vérification de santé |
|
||||||
|
| **Cible** | `openclaw.lab.home` |
|
||||||
|
| **Statut** | failed |
|
||||||
|
| **Type** | Manuel |
|
||||||
|
| **Progression** | 10% |
|
||||||
|
| **Début** | 2026-03-04T01:07:55.757370+00:00 |
|
||||||
|
| **Fin** | 2026-03-04T01:07:55.758804+00:00 |
|
||||||
|
| **Durée** | 0.0s |
|
||||||
|
|
||||||
|
## Sortie
|
||||||
|
|
||||||
|
```
|
||||||
|
(Aucune sortie)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
*Généré automatiquement par Homelab Automation Dashboard*
|
||||||
|
*Date: 2026-03-04T01:07:55.853322+00:00*
|
||||||
29
reset_password.py
Normal file
29
reset_password.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ajouter le répertoire courant au PYTHONPATH pour trouver le module 'app'
|
||||||
|
sys.path.insert(0, os.path.abspath(os.getcwd()))
|
||||||
|
|
||||||
|
from app.models.database import async_session_maker
|
||||||
|
from app.crud.user import UserRepository
|
||||||
|
from app.services.auth_service import hash_password
|
||||||
|
|
||||||
|
async def reset_password(username, new_password):
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
repo = UserRepository(session)
|
||||||
|
user = await repo.get_by_username(username)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
print(f"❌ Utilisateur '{username}' non trouvé.")
|
||||||
|
return
|
||||||
|
|
||||||
|
user.hashed_password = hash_password(new_password)
|
||||||
|
await session.commit()
|
||||||
|
print(f"✅ Mot de passe réinitialisé avec succès pour : {username}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: python reset_password.py <username> <nouveau_mot_de_passe>")
|
||||||
|
else:
|
||||||
|
asyncio.run(reset_password(sys.argv[1], sys.argv[2]))
|
||||||
164
routes_dump.txt
Normal file
164
routes_dump.txt
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
=== APP ROUTES ===
|
||||||
|
{'HEAD', 'GET'} /openapi.json
|
||||||
|
{'HEAD', 'GET'} /docs
|
||||||
|
{'HEAD', 'GET'} /docs/oauth2-redirect
|
||||||
|
{'HEAD', 'GET'} /redoc
|
||||||
|
N/A /static
|
||||||
|
{'GET'} /api/auth/status
|
||||||
|
{'POST'} /api/auth/setup
|
||||||
|
{'POST'} /api/auth/login
|
||||||
|
{'POST'} /api/auth/login/json
|
||||||
|
{'GET'} /api/auth/me
|
||||||
|
{'PUT'} /api/auth/password
|
||||||
|
{'GET'} /api/hosts/groups
|
||||||
|
{'GET'} /api/hosts/by-name/{host_name}
|
||||||
|
{'POST'} /api/hosts/refresh
|
||||||
|
{'POST'} /api/hosts/sync
|
||||||
|
{'GET'} /api/hosts/{host_id}
|
||||||
|
{'GET'} /api/hosts
|
||||||
|
{'POST'} /api/hosts
|
||||||
|
{'PUT'} /api/hosts/{host_name}
|
||||||
|
{'DELETE'} /api/hosts/by-name/{host_name}
|
||||||
|
{'DELETE'} /api/hosts/{host_id}
|
||||||
|
{'GET'} /api/groups
|
||||||
|
{'GET'} /api/groups/{group_name}
|
||||||
|
{'POST'} /api/groups
|
||||||
|
{'PUT'} /api/groups/{group_name}
|
||||||
|
{'DELETE'} /api/groups/{group_name}
|
||||||
|
{'GET'} /api/tasks
|
||||||
|
{'GET'} /api/tasks/running
|
||||||
|
{'GET'} /api/tasks/logs
|
||||||
|
{'GET'} /api/tasks/logs/dates
|
||||||
|
{'GET'} /api/tasks/logs/stats
|
||||||
|
{'GET'} /api/tasks/logs/{log_id}
|
||||||
|
{'DELETE'} /api/tasks/logs/{log_id}
|
||||||
|
{'POST'} /api/tasks/{task_id}/cancel
|
||||||
|
{'GET'} /api/tasks/{task_id}
|
||||||
|
{'DELETE'} /api/tasks/{task_id}
|
||||||
|
{'POST'} /api/tasks
|
||||||
|
{'GET'} /api/logs
|
||||||
|
{'POST'} /api/logs
|
||||||
|
{'DELETE'} /api/logs
|
||||||
|
{'GET'} /api/ansible/playbooks
|
||||||
|
{'GET'} /api/ansible/inventory
|
||||||
|
{'GET'} /api/ansible/groups
|
||||||
|
{'GET'} /api/ansible/ssh-config
|
||||||
|
{'POST'} /api/ansible/bootstrap
|
||||||
|
{'POST'} /api/ansible/execute
|
||||||
|
{'POST'} /api/ansible/adhoc
|
||||||
|
{'GET'} /api/playbooks/{filename}/content
|
||||||
|
{'PUT'} /api/playbooks/{filename}/content
|
||||||
|
{'DELETE'} /api/playbooks/{filename}
|
||||||
|
{'GET'} /api/schedules
|
||||||
|
{'GET'} /api/schedules/stats
|
||||||
|
{'GET'} /api/schedules/upcoming
|
||||||
|
{'GET'} /api/schedules/validate-cron
|
||||||
|
{'GET'} /api/schedules/{schedule_id}
|
||||||
|
{'POST'} /api/schedules
|
||||||
|
{'PUT'} /api/schedules/{schedule_id}
|
||||||
|
{'DELETE'} /api/schedules/{schedule_id}
|
||||||
|
{'POST'} /api/schedules/{schedule_id}/run
|
||||||
|
{'POST'} /api/schedules/{schedule_id}/pause
|
||||||
|
{'POST'} /api/schedules/{schedule_id}/resume
|
||||||
|
{'GET'} /api/schedules/{schedule_id}/runs
|
||||||
|
{'GET'} /api/adhoc/history
|
||||||
|
{'GET'} /api/adhoc/categories
|
||||||
|
{'POST'} /api/adhoc/categories
|
||||||
|
{'PUT'} /api/adhoc/categories/{category_name}
|
||||||
|
{'DELETE'} /api/adhoc/categories/{category_name}
|
||||||
|
{'PUT'} /api/adhoc/history/{command_id}/category
|
||||||
|
{'DELETE'} /api/adhoc/history/{command_id}
|
||||||
|
{'GET'} /api/bootstrap/status
|
||||||
|
{'GET'} /api/bootstrap/status/{host_name}
|
||||||
|
{'POST'} /api/bootstrap/status/{host_name}
|
||||||
|
{'POST'} /api/bootstrap
|
||||||
|
{'GET'} /api/health
|
||||||
|
{'GET'} /api/health/global
|
||||||
|
{'GET'} /api/health/runtime
|
||||||
|
{'GET'} /api/health/{host_name}
|
||||||
|
{'POST'} /api/health/refresh
|
||||||
|
{'GET'} /api/notifications/config
|
||||||
|
{'POST'} /api/notifications/test
|
||||||
|
{'POST'} /api/notifications/send
|
||||||
|
{'POST'} /api/notifications/toggle
|
||||||
|
{'GET'} /api/help/content
|
||||||
|
{'GET'} /api/help/documentation.md
|
||||||
|
{'GET'} /api/help/documentation.pdf
|
||||||
|
{'GET'} /api/help/catalog
|
||||||
|
{'GET'} /api/monitoring
|
||||||
|
{'GET'} /api/monitoring/all-hosts
|
||||||
|
{'GET'} /api/monitoring/collection-schedule
|
||||||
|
{'POST'} /api/monitoring/collection-schedule
|
||||||
|
{'GET'} /api/monitoring/{host_id}
|
||||||
|
{'GET'} /api/builtin-playbooks
|
||||||
|
{'GET'} /api/builtin-playbooks/{builtin_id}
|
||||||
|
{'POST'} /api/builtin-playbooks/execute
|
||||||
|
{'POST'} /api/builtin-playbooks/execute-background
|
||||||
|
{'POST'} /api/builtin-playbooks/collect-all
|
||||||
|
{'GET'} /api/server/logs
|
||||||
|
{'POST'} /api/alerts
|
||||||
|
{'GET'} /api/alerts
|
||||||
|
{'GET'} /api/alerts/unread-count
|
||||||
|
{'POST'} /api/alerts/{alert_id}/read
|
||||||
|
{'POST'} /api/alerts/mark-all-read
|
||||||
|
{'DELETE'} /api/alerts/{alert_id}
|
||||||
|
{'GET'} /api/docker/container-customizations
|
||||||
|
{'PUT'} /api/docker/container-customizations/{host_id}/{container_id}
|
||||||
|
{'DELETE'} /api/docker/container-customizations/{host_id}/{container_id}
|
||||||
|
{'GET'} /api/docker/hosts
|
||||||
|
{'POST'} /api/docker/hosts/{host_id}/enable
|
||||||
|
{'POST'} /api/docker/hosts/{host_id}/collect
|
||||||
|
{'POST'} /api/docker/collect-all
|
||||||
|
{'GET'} /api/docker/containers
|
||||||
|
{'GET'} /api/docker/hosts/{host_id}/containers
|
||||||
|
{'POST'} /api/docker/containers/{host_id}/{container_id}/start
|
||||||
|
{'POST'} /api/docker/containers/{host_id}/{container_id}/stop
|
||||||
|
{'POST'} /api/docker/containers/{host_id}/{container_id}/restart
|
||||||
|
{'POST'} /api/docker/containers/{host_id}/{container_id}/remove
|
||||||
|
{'POST'} /api/docker/containers/{host_id}/{container_id}/redeploy
|
||||||
|
{'GET'} /api/docker/containers/{host_id}/{container_id}/logs
|
||||||
|
{'GET'} /api/docker/containers/{host_id}/{container_id}/inspect
|
||||||
|
{'GET'} /api/docker/hosts/{host_id}/images
|
||||||
|
{'DELETE'} /api/docker/images/{host_id}/{image_id}
|
||||||
|
{'GET'} /api/docker/hosts/{host_id}/volumes
|
||||||
|
{'GET'} /api/docker/alerts
|
||||||
|
{'POST'} /api/docker/alerts/{alert_id}/acknowledge
|
||||||
|
{'POST'} /api/docker/alerts/{alert_id}/close
|
||||||
|
{'GET'} /api/docker/stats
|
||||||
|
{'POST'} /api/playbooks/{filename}/lint
|
||||||
|
{'GET'} /api/playbooks/results
|
||||||
|
{'GET'} /api/playbooks/results/{filename}
|
||||||
|
{'GET'} /api/playbooks/rules
|
||||||
|
{'GET'} /api/terminal/status
|
||||||
|
{'GET'} /api/terminal/sessions/{session_id}/probe
|
||||||
|
{'GET'} /api/terminal/proxy/{session_id}
|
||||||
|
{'PATCH', 'DELETE', 'GET', 'OPTIONS', 'POST', 'HEAD', 'PUT'} /api/terminal/proxy/{session_id}/{proxy_path:path}
|
||||||
|
N/A /api/terminal/proxy/{session_id}/ws
|
||||||
|
{'POST'} /api/terminal/{host_id}/terminal-sessions
|
||||||
|
{'DELETE'} /api/terminal/sessions/{session_id}
|
||||||
|
{'POST'} /api/terminal/sessions/{session_id}/heartbeat
|
||||||
|
{'POST'} /api/terminal/sessions/{session_id}/close-beacon
|
||||||
|
{'POST'} /api/terminal/cleanup
|
||||||
|
{'POST'} /api/terminal/sessions/{session_id}/command
|
||||||
|
{'GET'} /api/terminal/connect/{session_id}
|
||||||
|
{'GET'} /api/terminal/popout/{session_id}
|
||||||
|
{'GET'} /api/terminal/{host_id}/command-history
|
||||||
|
{'GET'} /api/terminal/{host_id}/shell-history
|
||||||
|
{'GET'} /api/terminal/{host_id}/command-history/unique
|
||||||
|
{'POST'} /api/terminal/{host_id}/command-history/{command_hash}/pin
|
||||||
|
{'GET'} /api/terminal/command-history
|
||||||
|
{'DELETE'} /api/terminal/{host_id}/command-history
|
||||||
|
{'POST'} /api/terminal/command-history/purge
|
||||||
|
{'GET'} /api/favorites/groups
|
||||||
|
{'POST'} /api/favorites/groups
|
||||||
|
{'PATCH'} /api/favorites/groups/{group_id}
|
||||||
|
{'DELETE'} /api/favorites/groups/{group_id}
|
||||||
|
{'GET'} /api/favorites/containers
|
||||||
|
{'POST'} /api/favorites/containers
|
||||||
|
{'DELETE'} /api/favorites/containers/{favorite_id}
|
||||||
|
{'GET'} /api/config
|
||||||
|
N/A /ws
|
||||||
|
N/A /terminal/ws/{session_id}
|
||||||
|
{'GET'} /
|
||||||
|
{'GET'} /favicon.ico
|
||||||
|
{'GET'} /api
|
||||||
@ -15,4 +15,4 @@ if (Test-Path $envFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Démarrer le backend FastAPI avec uvicorn
|
# Démarrer le backend FastAPI avec uvicorn
|
||||||
python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
python -m uvicorn main:app --host 0.0.0.0 --port 8008 --reload
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
27
test_endpoints.py
Normal file
27
test_endpoints.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import requests
|
||||||
|
|
||||||
|
# Use the API key from .env if possible, or try a dummy one to see the error type
|
||||||
|
url = "http://localhost:8000/api/help/catalog"
|
||||||
|
headers = {
|
||||||
|
"X-API-Key": "test-key" # Will likely fail auth but show if route exists
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"Calling {url}...")
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
print(f"Status Code: {response.status_code}")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
# Try another route that definitely exists
|
||||||
|
url2 = "http://localhost:8000/api/help/content"
|
||||||
|
url3 = "http://localhost:8000/api/auth/me"
|
||||||
|
for url in [url2, url3]:
|
||||||
|
try:
|
||||||
|
print(f"\nCalling {url}...")
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
print(f"Status Code: {response.status_code}")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
15
test_typing.py
Normal file
15
test_typing.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import sys
|
||||||
|
from typing import Union, Any, cast
|
||||||
|
|
||||||
|
print(f"Python version: {sys.version}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from typing import Union
|
||||||
|
types = (int, str)
|
||||||
|
# This is what SQLAlchemy does:
|
||||||
|
res = cast(Any, Union).__getitem__(types)
|
||||||
|
print(f"Success: {res}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
9
test_typing_2.py
Normal file
9
test_typing_2.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from typing import Union
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"Union[int, str]: {Union[int, str]}")
|
||||||
|
# Let's try what SQLAlchemy tries via casting
|
||||||
|
from typing import Any, cast
|
||||||
|
print(f"Casting and getting item: {cast(Any, Union)[int, str]}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Normal indexing error: {e}")
|
||||||
Loading…
x
Reference in New Issue
Block a user