clean app

This commit is contained in:
Louis Mazin 2026-10-01 15:58:22 +02:00
parent 51ef3b40ec
commit 463dfa9508
19 changed files with 267 additions and 908 deletions

View File

@ -1,8 +1,5 @@
PYTHON_PATH=path_to_python
PYTHON_PATH=python
EMAIL_ADDRESS=example@gmail.com
EMAIL_PASSWORD=ztgzegzeg
EMAIL_SMTP_SERVER=smtp.gmail.com
EMAIL_SMTP_PORT=587
LICENSE_API_URL=url
PURCHASE_URL=url
LICENSE_API_KEY=key
EMAIL_SMTP_PORT=587

View File

@ -1,220 +0,0 @@
import hashlib
import platform
import uuid
import requests
from datetime import datetime
from typing import Dict
from PyQt6.QtCore import QObject, pyqtSignal
import os
from dotenv import load_dotenv
import app.utils.paths as paths
# Load environment variables
load_dotenv(paths.resource_path(".env"))
class LicenseManager(QObject):
"""Gestionnaire de licences avec liaison matérielle"""
license_activated = pyqtSignal(str) # Signal émis lors de l'activation
license_expired = pyqtSignal()
def __init__(self, settings_manager):
super().__init__()
self.settings_manager = settings_manager
self.licensing_enabled = settings_manager.get_config("enable_licensing")
# Si le système de licence est désactivé, initialiser en mode gratuit
if not self.licensing_enabled:
self.hardware_id = None
self.license_key = ""
self.license_data = None
return
# Charger l'URL de l'API depuis .env
self.api_url = os.getenv("LICENSE_API_URL")
if not self.api_url:
raise ValueError("LICENSE_API_URL non définie dans le fichier .env")
self.hardware_id = self._get_hardware_id()
# Charger la licence sauvegardée
self.license_key = settings_manager.settings.value("license_key", "")
self.license_data = None
if self.license_key:
self._load_license_data()
def _get_hardware_id(self) -> str:
"""Génère un identifiant unique basé sur le matériel"""
# Combine plusieurs éléments matériels pour un ID unique
mac = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff)
for elements in range(0, 2*6, 2)][::-1])
system = platform.system()
machine = platform.machine()
processor = platform.processor()
# Créer un hash unique
unique_string = f"{mac}{system}{machine}{processor}"
return hashlib.sha256(unique_string.encode()).hexdigest()
def get_hardware_id(self) -> str:
"""Retourne le hardware ID pour l'afficher à l'utilisateur"""
return self.hardware_id
def activate_license(self, license_key: str) -> Dict:
"""
Active une licence avec le serveur
Returns:
dict: {"success": bool, "message": str, "data": dict}
"""
try:
response = requests.post(
f"{self.api_url}/activate",
json={
"license_key": license_key,
"hardware_id": self.hardware_id,
"app_version": self.settings_manager.get_config("app_version"),
"platform": platform.system()
},
timeout=10
)
data = response.json()
if response.status_code == 200 and data.get("success"):
# Sauvegarder la licence
self.license_key = license_key
self.license_data = data.get("license_data", {})
self.settings_manager.settings.setValue("license_key", license_key)
self.settings_manager.settings.setValue("license_data", self.license_data)
self.license_activated.emit(license_key)
return {
"success": True,
"message": "Licence activée avec succès",
"data": self.license_data
}
else:
return {
"success": False,
"message": data.get("message", "Erreur d'activation"),
"data": None
}
except requests.exceptions.RequestException as e:
return {
"success": False,
"message": f"Erreur de connexion au serveur: {str(e)}",
"data": None
}
def verify_license(self) -> bool:
"""Vérifie la validité de la licence avec le serveur"""
if not self.license_key:
return False
try:
response = requests.post(
f"{self.api_url}/verify",
json={
"license_key": self.license_key,
"hardware_id": self.hardware_id
},
timeout=10
)
data = response.json()
if response.status_code == 200 and data.get("valid"):
self.license_data = data.get("license_data", {})
self.settings_manager.settings.setValue("license_data", self.license_data)
return True
else:
return False
except requests.exceptions.RequestException:
# En cas d'erreur réseau, utiliser les données en cache
return self._verify_offline()
def _verify_offline(self) -> bool:
"""Vérification hors ligne basique"""
if not self.license_key or not self.license_data:
return False
# Vérifier que le hardware_id correspond
if self.license_data.get("hardware_id") != self.hardware_id:
return False
# Vérifier la date d'expiration si applicable
expires_at = self.license_data.get("expires_at")
if expires_at:
expiry_date = datetime.fromisoformat(expires_at)
if datetime.now() > expiry_date:
self.license_expired.emit()
return False
return True
def _load_license_data(self):
"""Charge les données de licence depuis les settings"""
self.license_data = self.settings_manager.settings.value("license_data", {})
def is_activated(self) -> bool:
"""Vérifie si l'application est activée"""
# Si le système de licence est désactivé, toujours retourner False
if not self.licensing_enabled:
return False
return bool(self.license_key) and self.verify_license()
def get_license_type(self) -> str:
"""Retourne le type de licence (free, premium, etc.)"""
# Si le système de licence est désactivé, retourner "free"
if not self.licensing_enabled:
return None
if not self.license_data:
return None
return self.license_data.get("type", None)
def is_feature_available(self, feature_id: str) -> bool:
"""
Vérifie si une fonctionnalité est disponible
Args:
feature_id: Identifiant de la fonctionnalité (ex: "advanced_export")
"""
# Si le système de licence est désactivé, toutes les fonctionnalités sont disponibles
if not self.licensing_enabled:
return True
# Si pas de licence, vérifier dans la config des features gratuites
if not self.is_activated():
free_features = self.settings_manager.get_config("features_by_license").get(None, [])
return feature_id in free_features
# Vérifier les features autorisées par la licence
license_type = self.get_license_type()
features_by_type = self.settings_manager.get_config("features_by_license")
allowed_features = features_by_type.get(license_type, [])
return feature_id in allowed_features
def get_license_info(self) -> Dict:
"""Retourne les informations de la licence"""
if not self.license_data:
return {
"type": None,
"status": "inactive",
"expires_at": None
}
return {
"type": self.get_license_type(),
"status": "active" if self.is_activated() else "inactive",
"expires_at": self.license_data.get("expires_at"),
"email": self.license_data.get("email"),
"activated_at": self.license_data.get("activated_at")
}

View File

@ -4,7 +4,6 @@ from app.core.theme_manager import ThemeManager
from app.core.settings_manager import SettingsManager
from app.core.alert_manager import AlertManager
from app.core.update_manager import UpdateManager
from app.core.license_manager import LicenseManager
from typing import Optional
@ -22,7 +21,6 @@ class MainManager:
self.language_manager: LanguageManager = LanguageManager(self.settings_manager)
self.alert_manager: AlertManager = AlertManager(self.language_manager, self.theme_manager)
self.update_manager: UpdateManager = UpdateManager(self.settings_manager, self.language_manager, self.alert_manager)
self.license_manager: LicenseManager = LicenseManager(self.settings_manager)
@classmethod
def get_instance(cls) -> 'MainManager':
if cls._instance is None:
@ -46,6 +44,3 @@ class MainManager:
def get_update_manager(self) -> UpdateManager:
return self.update_manager
def get_license_manager(self) -> LicenseManager:
return self.license_manager

View File

@ -1,6 +1,9 @@
import json
import os
from typing import Any, Dict, List, Optional
import app.utils.paths as paths
import os, json
from typing import List, Dict, Any, Optional
class Theme:
def __init__(self, name: str, colors: Dict[str, str]) -> None:
@ -10,212 +13,43 @@ class Theme:
def get_color(self, element: str) -> str:
return self.colors.get(element, "#FFFFFF")
class ThemeManager:
class ThemeManager:
def __init__(self) -> None:
theme_path: str = os.path.join(paths.get_data_dir(), "themes")
base_theme_path: str = os.path.join(theme_path, "base.qss")
with open(base_theme_path, "r", encoding="utf-8") as theme_file:
self.base_sheet: str = theme_file.read()
self.themes: List[Theme] = []
for theme_file in os.listdir(theme_path):
if theme_file.endswith(".json"):
with open(os.path.join(theme_path, theme_file), 'r', encoding='utf-8') as f:
theme_data: Dict[str, Any] = json.load(f)
theme: Theme = Theme(theme_data["theme_name"], theme_data["colors"])
self.themes.append(theme)
for theme_file in sorted(os.listdir(theme_path)):
if not theme_file.endswith(".json"):
continue
with open(os.path.join(theme_path, theme_file), "r", encoding="utf-8") as file:
theme_data: Dict[str, Any] = json.load(file)
self.themes.append(Theme(theme_data["theme_name"], theme_data["colors"]))
self.current_theme: Theme = self.themes[0]
def set_theme(self, theme: str) -> None:
if theme != self.current_theme.name:
found_theme: Optional[Theme] = next((t for t in self.themes if t.name == theme), None)
found_theme: Optional[Theme] = next(
(available_theme for available_theme in self.themes if available_theme.name == theme),
None,
)
if found_theme:
self.current_theme = found_theme
def get_theme(self) -> Theme:
return self.current_theme
def get_themes(self) -> List[Theme]:
return self.themes
def get_sheet(self) -> str:
return f"""
QWidget {{
background-color: {self.current_theme.get_color("background_secondary_color")};
color: {self.current_theme.get_color("text_color")};
}}
QLabel {{
background-color: transparent;
color: {self.current_theme.get_color("text_color")};
font-size: 20px;
}}
QPushButton {{
background-color: {self.current_theme.get_color("primary_color")};
color: {self.current_theme.get_color("text_color")};
border-radius: 8px;
font-size: 16px;
padding: 10px 20px;
border: none;
}}
QPushButton:hover {{
background-color: {self.current_theme.get_color("primary_hover_color")};
}}
QProgressBar {{
border: 1px solid {self.current_theme.get_color("border_color")};
border-radius: 5px;
background-color: {self.current_theme.get_color("background_secondary_color")};
text-align: center;
color: {self.current_theme.get_color("text_color")};
}}
QProgressBar::chunk {{
background-color: {self.current_theme.get_color("primary_color")};
border-radius: 3px;
}}
QTextEdit {{
border: 2px solid {self.current_theme.get_color("border_color")};
border-radius: 8px;
padding: 5px;
font-size: 14px;
background-color: {self.current_theme.get_color("background_tertiary_color")};
color: {self.current_theme.get_color("text_color")};
}}
QLineEdit {{
border: 2px solid {self.current_theme.get_color("border_color")};
border-radius: 8px;
padding: 5px;
font-size: 14px;
background-color: {self.current_theme.get_color("background_tertiary_color")};
color: {self.current_theme.get_color("text_color")};
}}
QDateEdit {{
border: 2px solid {self.current_theme.get_color("border_color")};
padding: 5px;
border-radius: 8px;
font-size: 14px;
min-height: 30px;
}}
QDateEdit:hover {{
border-color: {self.current_theme.get_color("primary_hover_color")};
}}
QComboBox {{
border: 2px solid {self.current_theme.get_color("border_color")};
padding: 5px;
border-radius: 8px;
font-size: 14px;
min-height: 30px;
}}
QComboBox QAbstractItemView {{
border-radius: 8px;
padding: 0px;
outline: none;
}}
QComboBox QAbstractItemView::item {{
padding: 12px 15px;
margin: 0px;
min-height: 20px;
border: 1px solid {self.current_theme.get_color("border_color")};
border-radius: 8px;
}}
QComboBox QAbstractItemView::item:hover {{
background-color: {self.current_theme.get_color("background_tertiary_color")};
color: {self.current_theme.get_color("text_color")};
}}
QComboBox QAbstractItemView::item:selected {{
color: {self.current_theme.get_color("text_color")};
}}
QComboBox::drop-down {{
border: none;
background: transparent;
}}
QComboBox:hover {{
border-color: {self.current_theme.get_color("primary_hover_color")};
}}
#table_combobox {{
border: 1px solid {self.current_theme.get_color("border_color")};
padding: 2px 5px;
border-radius: 4px;
font-size: 12px;
min-height: 20px;
max-height: 28px;
}}
#table_combobox::drop-down {{
border: none;
background: transparent;
}}
#table_combobox::down-arrow {{
image: none;
}}
#table_combobox QAbstractItemView {{
border-radius: 4px;
padding: 0px;
outline: none;
}}
#table_combobox QAbstractItemView::item {{
padding: 8px 10px;
margin: 0px;
min-height: 16px;
border: 1px solid {self.current_theme.get_color("border_color")};
border-radius: 4px;
}}
#table_combobox QAbstractItemView::item:hover {{
background-color: {self.current_theme.get_color("background_tertiary_color")};
color: {self.current_theme.get_color("text_color")};
}}
#table_combobox QAbstractItemView::item:selected {{
color: {self.current_theme.get_color("text_color")};
}}
QSlider::groove:horizontal {{
border: 1px solid {self.current_theme.get_color("primary_color")};
height: 10px;
background: transparent;
border-radius: 5px;
}}
QSlider::sub-page:horizontal {{
background: {self.current_theme.get_color("primary_color")};
border-radius: 5px;
}}
QSlider::add-page:horizontal {{
background: {self.current_theme.get_color("background_tertiary_color")};
border-radius: 5px;
}}
QSlider::handle:horizontal {{
background: white;
border: 2px solid {self.current_theme.get_color("primary_color")};
width: 14px;
margin: -4px 0;
border-radius: 7px;
}}
QScrollBar:vertical {{
border: none;
background: {self.current_theme.get_color("background_tertiary_color")};
width: 8px;
margin: 0px;
}}
QScrollBar::handle:vertical {{
background: {self.current_theme.get_color("primary_color")};
border-radius: 4px;
min-height: 20px;
}}
QScrollBar::handle:vertical:hover {{
background: {self.current_theme.get_color("primary_hover_color")};
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
border: none;
background: none;
height: 0px;
}}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
background: none;
}}
#tab_bar {{
background-color: {self.current_theme.get_color("background_color")};
}}
#icon_label {{
background: transparent;
border: none;
}}
#text_label {{
font-size: 14px;
background: transparent;
border: none;
}}
"""
sheet: str = self.base_sheet
for color_name, color_value in self.current_theme.colors.items():
sheet = sheet.replace("{{" + color_name + "}}", color_value)
return sheet

View File

@ -5,7 +5,6 @@ from app.core.main_manager import MainManager, NotificationType
from app.ui.widgets.tabs_widget import TabsWidget, MenuDirection, ButtonPosition, BorderSide, TabSide, TextPosition
from app.ui.windows.settings_window import SettingsWindow
from app.ui.windows.suggestion_window import SuggestionWindow
from app.ui.windows.activation_window import ActivationWindow
import app.utils.paths as paths, shutil
from typing import Optional
@ -259,11 +258,6 @@ class MainWindow(QMainWindow):
self.settings_window = SettingsWindow(self)
self.side_menu.add_widget(self.settings_window, self.language_manager.get_text("tab_settings"), paths.get_asset_svg_path("settings"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM)
# Ajouter la tab d'activation uniquement si le système de licence est activé
if self.settings_manager.get_config("enable_licensing"):
self.activation_window = ActivationWindow(self)
self.side_menu.add_widget(self.activation_window, self.language_manager.get_text("tab_licensing"), paths.get_asset_svg_path("license"), position=ButtonPosition.END, text_position=TextPosition.BOTTOM)
self.setCentralWidget(self.side_menu)
def get_tab_widget(self):
@ -277,5 +271,3 @@ class MainWindow(QMainWindow):
# Mettre à jour les textes des onglets
self.side_menu.update_button_text(0, self.language_manager.get_text("tab_suggestions"))
self.side_menu.update_button_text(1, self.language_manager.get_text("tab_settings"))
if self.settings_manager.get_config("enable_licensing"):
self.side_menu.update_button_text(2, self.language_manager.get_text("tab_licensing"))

View File

@ -1,241 +0,0 @@
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QFrame, QSizePolicy)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from app.core.main_manager import MainManager, NotificationType
from app.ui.widgets.loading_bar import LoadingBar
import webbrowser
import os
from dotenv import load_dotenv
import app.utils.paths as paths
# Load environment variables
load_dotenv(paths.resource_path(".env"))
class ActivationThread(QThread):
"""Thread pour l'activation afin de ne pas bloquer l'UI"""
finished = pyqtSignal(dict)
progress = pyqtSignal(int)
def __init__(self, license_manager, license_key):
super().__init__()
self.license_manager = license_manager
self.license_key = license_key
def run(self):
self.progress.emit(30)
result = self.license_manager.activate_license(self.license_key)
self.progress.emit(100)
self.finished.emit(result)
class ActivationWindow(QWidget):
"""Fenêtre d'activation de licence modernisée"""
def __init__(self, parent=None):
super().__init__(parent)
self.main_manager = MainManager.get_instance()
self.license_manager = self.main_manager.get_license_manager()
self.language_manager = self.main_manager.get_language_manager()
self.theme_manager = self.main_manager.get_theme_manager()
self.alert_manager = self.main_manager.get_alert_manager()
self.observer_manager = self.main_manager.get_observer_manager()
self.settings_manager = self.main_manager.get_settings_manager()
self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language)
self.observer_manager.subscribe(NotificationType.THEME, self.update_theme)
self.activation_thread = None
self.setup_ui()
self.update_license_status()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(20)
layout.setContentsMargins(20, 20, 20, 20)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# === Section titre ===
self.title_label = QLabel(self.language_manager.get_text("activate_license"))
self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.title_label.setWordWrap(True)
layout.addWidget(self.title_label)
# === Spacer flexible ===
layout.addStretch(1)
# === Section clé de licence ===
key_layout = QHBoxLayout()
self.key_title = QLabel(self.language_manager.get_text("license_key_section"))
self.key_title.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
key_layout.addWidget(self.key_title)
self.key_input = QLineEdit()
self.key_input.setPlaceholderText("XXXX-XXXX-XXXX-XXXX-XXXX")
self.key_input.textChanged.connect(self.format_license_key)
self.key_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
key_layout.addWidget(self.key_input)
layout.addLayout(key_layout)
# === Barre de progression ===
self.progress_bar = LoadingBar(self.language_manager.get_text("loading"))
self.progress_bar.setVisible(False)
self.progress_bar.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
layout.addWidget(self.progress_bar)
# === Spacer flexible ===
layout.addStretch(1)
# === Section statut ===
self.status_label = QLabel()
self.status_label.setWordWrap(True)
self.status_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
layout.addWidget(self.status_label)
# === Spacer flexible ===
layout.addStretch(1)
# === Boutons d'action ===
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
self.activate_btn = QPushButton(self.language_manager.get_text("activate"))
self.activate_btn.clicked.connect(self.activate_license)
self.activate_btn.setDefault(True)
self.activate_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
button_layout.addWidget(self.activate_btn)
self.buy_btn = QPushButton(self.language_manager.get_text("buy_license"))
self.buy_btn.clicked.connect(self.open_purchase_page)
self.buy_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
button_layout.addWidget(self.buy_btn)
self.compare_btn = QPushButton(self.language_manager.get_text("compare_versions"))
self.compare_btn.clicked.connect(self.show_features_comparison)
self.compare_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
button_layout.addWidget(self.compare_btn)
layout.addLayout(button_layout)
def show_features_comparison(self):
"""Affiche la comparaison des versions dans une alerte"""
comparison_text = f"{self.language_manager.get_text('comparaisons')}\n\n"
for version in self.settings_manager.get_config("features_by_license").keys():
comparison_text += f"{self.language_manager.get_text(version+'_version')}:\n"
features = self.settings_manager.get_config("features_by_license").get(version, [])
for feature in features:
feature_desc = self.language_manager.get_text(feature)
comparison_text += f" • {feature_desc}\n"
comparison_text += "\n"
self.alert_manager.show_info(comparison_text, parent=self)
def update_license_status(self):
"""Met à jour l'affichage du statut de la licence"""
if self.license_manager.is_activated():
license_info = self.license_manager.get_license_info()
license_type = license_info.get("type", "free").upper()
status_text = f"✓ {self.language_manager.get_text('license_active')}\n"
status_text += f"{self.language_manager.get_text('license_type')}: {license_type}\n"
if license_info.get("email"):
status_text += f"{self.language_manager.get_text('license_email')}: {license_info['email']}\n"
if license_info.get("expires_at"):
status_text += f"{self.language_manager.get_text('license_expires')}: {license_info['expires_at']}"
self.status_label.setText(status_text)
self.key_input.setEnabled(False)
self.activate_btn.setEnabled(False)
else:
status_text = f"{self.language_manager.get_text('no_license')}"
self.status_label.setText(status_text)
def format_license_key(self, text):
"""Formate automatiquement la clé de licence (XXXX-XXXX-...)"""
text = text.replace("-", "").upper()
formatted = "-".join([text[i:i+4] for i in range(0, len(text), 4)])
if len(formatted) > 24:
formatted = formatted[:24]
self.key_input.blockSignals(True)
self.key_input.setText(formatted)
self.key_input.blockSignals(False)
self.key_input.setCursorPosition(len(formatted))
def activate_license(self):
"""Lance l'activation de la licence"""
license_key = self.key_input.text().replace("-", "")
if len(license_key) < 16:
self.alert_manager.show_error("invalid_license_key")
return
# Disable inputs during activation
self.activate_btn.setEnabled(False)
self.key_input.setEnabled(False)
self.buy_btn.setEnabled(False)
# Show progress bar with initial message
self.progress_bar.set_label(self.language_manager.get_text("loading"))
self.progress_bar.set_progress(0)
self.progress_bar.setVisible(True)
# Start activation thread
self.activation_thread = ActivationThread(self.license_manager, license_key)
self.activation_thread.finished.connect(self.on_activation_finished)
self.activation_thread.progress.connect(self.on_activation_progress)
self.activation_thread.start()
def on_activation_progress(self, value):
"""Update progress bar during activation"""
self.progress_bar.set_progress(value)
def on_activation_finished(self, result):
"""Callback quand l'activation est terminée"""
# Hide progress bar
self.progress_bar.setVisible(False)
# Re-enable inputs
self.activate_btn.setEnabled(True)
self.key_input.setEnabled(True)
self.buy_btn.setEnabled(True)
if result["success"]:
# Show success message using AlertManager
success_msg = result['message']
if result.get("data"):
success_msg += f"\n\n{self.language_manager.get_text('license_type')}: {result['data'].get('type', 'N/A')}"
success_msg += f"\n{self.language_manager.get_text('license_email')}: {result['data'].get('email', 'N/A')}"
self.alert_manager.show_info(success_msg, parent=self)
self.update_license_status()
# Clear the input field
self.key_input.clear()
else:
# Show error message using AlertManager
self.alert_manager.show_info(f"✗ {result['message']}", parent=self)
def open_purchase_page(self):
"""Ouvre la page d'achat dans le navigateur"""
purchase_url = os.getenv("PURCHASE_URL")
if purchase_url:
webbrowser.open(purchase_url)
else:
self.alert_manager.show_error("PURCHASE_URL non définie dans .env", parent=self)
def update_language(self):
"""Met à jour tous les textes selon la nouvelle langue"""
self.title_label.setText(self.language_manager.get_text("activate_license"))
self.activate_btn.setText(self.language_manager.get_text("activate"))
self.buy_btn.setText(self.language_manager.get_text("buy_license"))
self.compare_btn.setText(self.language_manager.get_text("compare_versions"))
self.progress_bar.set_label(self.language_manager.get_text("loading"))
self.key_title.setText(self.language_manager.get_text("license_key_section"))
self.update_license_status()
def update_theme(self):
"""Met à jour le style selon le nouveau thème"""
self.update_license_status()

View File

@ -62,7 +62,6 @@ class SuggestionWindow(QWidget):
self.language_manager = self.main_manager.get_language_manager()
self.settings_manager = self.main_manager.get_settings_manager()
self.alert_manager = self.main_manager.get_alert_manager()
self.license_manager = self.main_manager.get_license_manager()
self.observer_manager = self.main_manager.get_observer_manager()
self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language)
@ -114,11 +113,7 @@ class SuggestionWindow(QWidget):
content = self.settings_manager.get_config('app_name').replace(' ', '_')
# Ajouter le préfixe "PRIORITAIRE" uniquement si le système de licence est activé ET que l'utilisateur a le support prioritaire
if self.settings_manager.get_config("enable_licensing") and self.license_manager.is_feature_available("priority_support"):
subject = "PRIORITAIRE - "+content
else:
subject = "Suggestion pour "+content
subject = "Suggestion pour "+content
# Create and start email sender thread
self.email_sender = EmailSender(subject, message)

View File

@ -1,66 +0,0 @@
from functools import wraps
from PyQt6.QtWidgets import QMessageBox
from app.core.main_manager import MainManager
def require_license(feature_id: str, show_upgrade_dialog: bool = True):
"""
Décorateur pour protéger une fonctionnalité premium
Usage:
@require_license("advanced_export")
def export_to_excel(self):
# Code de la fonctionnalité premium
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
main_manager = MainManager.get_instance()
license_manager = main_manager.get_license_manager()
settings_manager = main_manager.get_settings_manager()
# Si le système de licence est désactivé, autoriser toutes les fonctionnalités
if not settings_manager.get_config("enable_licensing"):
return func(*args, **kwargs)
if not license_manager.is_feature_available(feature_id):
if show_upgrade_dialog:
show_upgrade_message(feature_id)
return None
return func(*args, **kwargs)
return wrapper
return decorator
def show_upgrade_message(feature_id: str):
"""Affiche un message invitant à acheter la version premium"""
main_manager = MainManager.get_instance()
language_manager = main_manager.get_language_manager()
settings_manager = main_manager.get_settings_manager()
# Si le système de licence est désactivé, ne rien afficher
if not settings_manager.get_config("enable_licensing"):
return
feature_name = settings_manager.get_config("feature_descriptions", {}).get(
feature_id, feature_id
)
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle(language_manager.get_text("premium_feature"))
msg.setText(language_manager.get_text("premium_feature_message").format(feature=feature_name))
msg.setInformativeText(language_manager.get_text("upgrade_prompt"))
buy_btn = msg.addButton(language_manager.get_text("buy_now"), QMessageBox.ButtonRole.AcceptRole)
activate_btn = msg.addButton(language_manager.get_text("activate_license"), QMessageBox.ButtonRole.ActionRole)
msg.addButton(language_manager.get_text("cancel"), QMessageBox.ButtonRole.RejectRole)
msg.exec()
if msg.clickedButton() == buy_btn:
import webbrowser
webbrowser.open(settings_manager.get_config("purchase_url"))
elif msg.clickedButton() == activate_btn:
from app.ui.windows.activation_window import ActivationWindow
activation_window = ActivationWindow()
activation_window.exec()

View File

@ -6,18 +6,5 @@
"icon_path": "data/assets/icon.ico",
"splash_image": "splash",
"main_script": "main.py",
"git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate",
"enable_licensing": true,
"features_by_license": {
"basic": [
"support"
],
"premium": [
"priority_support"
],
"enterprise": [
"priority_support",
"unlimited_key_uses"
]
}
"git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate"
}

View File

@ -5,8 +5,12 @@ import os
from pathlib import Path
from os import getenv
# --- Resolve paths relative to this spec file ---
CONFIG_DIR = Path(SPECPATH).resolve()
PROJECT_DIR = CONFIG_DIR.parent
# --- Load config.json ---
config_path = Path("../config.json")
config_path = PROJECT_DIR / "config.json"
with config_path.open("r", encoding="utf-8") as f:
config = json.load(f)
@ -22,16 +26,16 @@ icon = getenv("ICON_PATH", "")
# --- Data files to bundle ---
datas = [
("data/assets/*", "data/assets/"),
("data/", "data/"),
("config.json", "."),
(".env", "."),
(str(PROJECT_DIR / "data/assets/*"), "data/assets/"),
(str(PROJECT_DIR / "data/"), "data/"),
(str(PROJECT_DIR / "config.json"), "."),
(str(PROJECT_DIR / ".env"), "."),
]
binaries = []
# --- Analysis ---
a = Analysis(
["main.py"],
[str(PROJECT_DIR / "main.py")],
pathex=[],
binaries=binaries,
datas=datas,

View File

@ -1,4 +1,5 @@
PyQt6
pyinstaller
python-dotenv
requests
requests
dotenv

View File

@ -1,4 +0,0 @@
<ns0:svg width="800px" height="800px" viewBox="0 0 24 24" xmlns:ns0="http://www.w3.org/2000/svg">
<ns0:path fill-rule="evenodd" clip-rule="evenodd" d="M15.6807 14.5869C19.1708 14.5869 22 11.7692 22 8.29344C22 4.81767 19.1708 2 15.6807 2C12.1907 2 9.3615 4.81767 9.3615 8.29344C9.3615 9.90338 10.0963 11.0743 10.0963 11.0743L2.45441 18.6849C2.1115 19.0264 1.63143 19.9143 2.45441 20.7339L3.33616 21.6121C3.67905 21.9048 4.54119 22.3146 5.2466 21.6121L6.27531 20.5876C7.30403 21.6121 8.4797 21.0267 8.92058 20.4412C9.65538 19.4167 8.77362 18.3922 8.77362 18.3922L9.06754 18.0995C10.4783 19.5045 11.7128 18.6849 12.1537 18.0995C12.8885 17.075 12.1537 16.0505 12.1537 16.0505C11.8598 15.465 11.272 15.465 12.0067 14.7333L12.8885 13.8551C13.5939 14.4405 15.0439 14.5869 15.6807 14.5869Z M17.8853 8.29353C17.8853 9.50601 16.8984 10.4889 15.681 10.4889C14.4635 10.4889 13.4766 9.50601 13.4766 8.29353C13.4766 7.08105 14.4635 6.09814 15.681 6.09814C16.8984 6.09814 17.8853 7.08105 17.8853 8.29353Z" fill="#D1D1D6"/>
</ns0:svg>

View File

@ -30,38 +30,10 @@
"update_details": "Update Details",
"update_aborted": "Update aborted by user",
"loading": "Loading...",
"verifying_license": "Verifying license...",
"checking_updates": "Checking for updates...",
"initializing": "Initializing interface...",
"loading_complete": "Loading complete",
"activate_license": "Activate your license to unlock all features",
"hardware_id_info": "This unique identifier is tied to your hardware. Share it with support if needed.",
"hardware_id_copied": "Hardware ID copied to clipboard!",
"license_key_section": "License Key :",
"enter_license_key": "Enter your license key",
"activate": "Activate",
"buy_license": "Buy License",
"comparaisons": "Version Comparison",
"basic_version": "Basic Version",
"premium_version": "Premium Version",
"enterprise_version": "Enterprise Version",
"support": "Support",
"priority_support": "Priority support",
"unlimited_key_uses": "Unlimited key uses",
"license_active": "License active",
"license_type": "Type",
"license_email": "Email",
"license_expires": "Expires on",
"license_basic_mode": "Basic mode - Activate a license for more features",
"invalid_license_key": "Invalid license key. It must contain at least 16 characters.",
"premium_feature_message": "The feature '{feature}' requires a Premium or Enterprise license.",
"upgrade_prompt": "Would you like to upgrade your license?",
"buy_now": "Buy Now",
"cancel": "Cancel",
"activation_required": "Activation is required to continue.",
"compare_versions": "Compare Versions",
"no_license": "No License",
"tab_suggestions": "Suggestions",
"tab_settings": "Settings",
"tab_licensing": "Licensing"
"tab_settings": "Settings"
}

View File

@ -30,39 +30,10 @@
"update_details": "Détails de la mise à jour",
"update_aborted": "Mise à jour annulée par l'utilisateur",
"loading": "Chargement...",
"verifying_license": "Vérification de la licence...",
"checking_updates": "Vérification des mises à jour...",
"initializing": "Initialisation de l'interface...",
"loading_complete": "Chargement terminé",
"activate_license": "Activez votre licence pour débloquer toutes les fonctionnalités",
"hardware_id_info": "Cet identifiant unique est lié à votre matériel. Partagez-le avec le support si nécessaire.",
"hardware_id_copied": "ID matériel copié dans le presse-papier !",
"license_key_section": "Clé de licence :",
"enter_license_key": "Entrez votre clé de licence",
"activate": "Activer",
"buy_license": "Acheter une licence",
"comparaisons": "Comparaison des versions",
"basic_version": "Version Basique",
"premium_version": "Version Premium",
"enterprise_version": "Version Enterprise",
"support": "Support",
"priority_support": "Support prioritaire",
"unlimited_key_uses": "Utilisations de clé illimitées",
"license_active": "Licence active",
"license_type": "Type",
"license_email": "Email",
"license_expires": "Expire le",
"license_basic_mode": "Mode basique - Activez une licence pour plus de fonctionnalités",
"invalid_license_key": "Clé de licence invalide. Elle doit contenir au moins 16 caractères.",
"premium_feature": "Fonctionnalité Premium",
"premium_feature_message": "La fonctionnalité '{feature}' nécessite une licence Premium ou Enterprise.",
"upgrade_prompt": "Souhaitez-vous mettre à niveau votre licence ?",
"buy_now": "Acheter maintenant",
"cancel": "Annuler",
"activation_required": "L'activation est requise pour continuer.",
"compare_versions": "Comparer les versions",
"no_license": "Pas de licence",
"tab_suggestions": "Suggestions",
"tab_settings": "Paramètres",
"tab_licensing": "Licence"
"tab_settings": "Paramètres"
}

183
data/themes/base.qss Normal file
View File

@ -0,0 +1,183 @@
QWidget {
background-color: {{background_secondary_color}};
color: {{text_color}};
}
QLabel {
background-color: transparent;
color: {{text_color}};
font-size: 20px;
}
QPushButton {
background-color: {{primary_color}};
color: {{text_color}};
border-radius: 8px;
font-size: 16px;
padding: 10px 20px;
border: none;
}
QPushButton:hover {
background-color: {{primary_hover_color}};
}
QProgressBar {
border: 1px solid {{border_color}};
border-radius: 5px;
background-color: {{background_secondary_color}};
text-align: center;
color: {{text_color}};
}
QProgressBar::chunk {
background-color: {{primary_color}};
border-radius: 3px;
}
QTextEdit {
border: 2px solid {{border_color}};
border-radius: 8px;
padding: 5px;
font-size: 14px;
background-color: {{background_tertiary_color}};
color: {{text_color}};
}
QLineEdit {
border: 2px solid {{border_color}};
border-radius: 8px;
padding: 5px;
font-size: 14px;
background-color: {{background_tertiary_color}};
color: {{text_color}};
}
QDateEdit {
border: 2px solid {{border_color}};
padding: 5px;
border-radius: 8px;
font-size: 14px;
min-height: 30px;
}
QDateEdit:hover {
border-color: {{primary_hover_color}};
}
QComboBox {
border: 2px solid {{border_color}};
padding: 5px;
border-radius: 8px;
font-size: 14px;
min-height: 30px;
}
QComboBox QAbstractItemView {
border-radius: 8px;
padding: 0px;
outline: none;
}
QComboBox QAbstractItemView::item {
padding: 12px 15px;
margin: 0px;
min-height: 20px;
border: 1px solid {{border_color}};
border-radius: 8px;
}
QComboBox QAbstractItemView::item:hover {
background-color: {{background_tertiary_color}};
color: {{text_color}};
}
QComboBox QAbstractItemView::item:selected {
color: {{text_color}};
}
QComboBox::drop-down {
border: none;
background: transparent;
}
QComboBox:hover {
border-color: {{primary_hover_color}};
}
#table_combobox {
border: 1px solid {{border_color}};
padding: 2px 5px;
border-radius: 4px;
font-size: 12px;
min-height: 20px;
max-height: 28px;
}
#table_combobox::drop-down {
border: none;
background: transparent;
}
#table_combobox::down-arrow {
image: none;
}
#table_combobox QAbstractItemView {
border-radius: 4px;
padding: 0px;
outline: none;
}
#table_combobox QAbstractItemView::item {
padding: 8px 10px;
margin: 0px;
min-height: 16px;
border: 1px solid {{border_color}};
border-radius: 4px;
}
#table_combobox QAbstractItemView::item:hover {
background-color: {{background_tertiary_color}};
color: {{text_color}};
}
#table_combobox QAbstractItemView::item:selected {
color: {{text_color}};
}
QSlider::groove:horizontal {
border: 1px solid {{primary_color}};
height: 10px;
background: transparent;
border-radius: 5px;
}
QSlider::sub-page:horizontal {
background: {{primary_color}};
border-radius: 5px;
}
QSlider::add-page:horizontal {
background: {{background_tertiary_color}};
border-radius: 5px;
}
QSlider::handle:horizontal {
background: white;
border: 2px solid {{primary_color}};
width: 14px;
margin: -4px 0;
border-radius: 7px;
}
QScrollBar:vertical {
border: none;
background: {{background_tertiary_color}};
width: 8px;
margin: 0px;
}
QScrollBar::handle:vertical {
background: {{primary_color}};
border-radius: 4px;
min-height: 20px;
}
QScrollBar::handle:vertical:hover {
background: {{primary_hover_color}};
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
border: none;
background: none;
height: 0px;
}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
background: none;
}
#tab_bar {
background-color: {{background_color}};
}
#icon_label {
background: transparent;
border: none;
}
#text_label {
font-size: 14px;
background: transparent;
border: none;
}

View File

@ -25,13 +25,6 @@ def preload_application(progress_callback, splash=None):
main_manager = MainManager.get_instance()
language_manager = main_manager.get_language_manager()
update_manager = main_manager.get_update_manager()
license_manager = main_manager.get_license_manager()
settings_manager = main_manager.get_settings_manager()
# Vérifier la licence uniquement si le système est activé
if settings_manager.get_config("enable_licensing"):
progress_callback(language_manager.get_text("verifying_license"))
license_manager.verify_license()
progress_callback(language_manager.get_text("checking_updates"))

View File

@ -3,15 +3,10 @@ setlocal enabledelayedexpansion
REM === PATH SETUP ===
set PARENT_DIR=%~dp0..
set CONFIG_DIR=%PARENT_DIR%\config
set CONFIG_FILE=%PARENT_DIR%\config.json
set ICON_FILE=%PARENT_DIR%\data\assets\icon.png
set ENV_FILE=%PARENT_DIR%\.env
REM Check if .env file exists
if not exist "%ENV_FILE%" (
echo [ERROR] .env file not found. Please copy .env.example to .env and configure it.
exit /b 1
)
set ICON_FILE=%PARENT_DIR%\data\assets\icon.png
REM === Extract values from config.json ===
for /f "delims=" %%i in ('powershell -NoProfile -Command ^
@ -21,18 +16,22 @@ for /f "delims=" %%i in ('powershell -NoProfile -Command ^
for /f "delims=" %%i in ('powershell -NoProfile -Command ^
"Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty architecture"') do set ARCHITECTURE=%%i
REM === Extract python path from .env file ===
for /f "usebackq tokens=2 delims==" %%i in (`findstr "PYTHON_PATH" "%ENV_FILE%"`) do set SYSTEM_PYTHON=%%i
REM === Use the Python interpreter provided by the environment ===
set SYSTEM_PYTHON=python
if exist "%ENV_FILE%" (
for /f "usebackq tokens=1,* delims==" %%A in ("%ENV_FILE%") do if /I "%%A"=="PYTHON_PATH" set "SYSTEM_PYTHON=%%B"
)
set VENV_PATH=%PARENT_DIR%\WINenv_%ARCHITECTURE%
set VENV_PATH=%CONFIG_DIR%\WINenv_%ARCHITECTURE%
set EXE_NAME=%APP_NAME%.exe
set PYTHON_IN_VENV=%VENV_PATH%\Scripts\python.exe
set BUILD_DIR=%PARENT_DIR%\build
set ZIP_FILE=%BUILD_DIR%\%APP_NAME%.zip
REM === Verify Python existence ===
if not exist "%SYSTEM_PYTHON%" (
echo [ERROR] Python not found at: %SYSTEM_PYTHON%
"%SYSTEM_PYTHON%" --version >nul 2>&1
if errorlevel 1 (
echo [ERROR] Python interpreter not found in PATH.
exit /b 1
)
@ -41,7 +40,7 @@ if not exist "%VENV_PATH%\Scripts\activate.bat" (
echo [INFO] Virtual environment not found. Creating...
"%SYSTEM_PYTHON%" -m venv "%VENV_PATH%"
"%PYTHON_IN_VENV%" -m pip install --upgrade pip
"%PYTHON_IN_VENV%" -m pip install -r "%PARENT_DIR%\requirements.txt"
"%PYTHON_IN_VENV%" -m pip install -r "%CONFIG_DIR%\requirements.txt"
) else (
echo [INFO] Virtual environment found.
)
@ -51,41 +50,9 @@ REM === Run PyInstaller ===
--distpath "%BUILD_DIR%" ^
--workpath "%BUILD_DIR%\dist" ^
--clean ^
"%PARENT_DIR%\BUILD.spec"
"%CONFIG_DIR%\BUILD.spec"
REM === Clean build cache ===
rmdir /s /q "%BUILD_DIR%\dist"
REM === Create ZIP ===
echo [INFO] Creating ZIP archive...
set TEMP_ZIP_DIR=%BUILD_DIR%\temp_zip
REM Remove old temp dir if exists
if exist "%TEMP_ZIP_DIR%" rmdir /s /q "%TEMP_ZIP_DIR%"
mkdir "%TEMP_ZIP_DIR%"
REM Copy compiled app - tout le contenu du build sauf les ZIP existants
move /Y "%BUILD_DIR%\%EXE_NAME%" "%TEMP_ZIP_DIR%\"
REM Copy config.json
copy "%CONFIG_FILE%" "%TEMP_ZIP_DIR%\" /Y
REM Copy icon.png
copy "%ICON_FILE%" "%TEMP_ZIP_DIR%\" /Y
REM Copy data/lang
xcopy /E /I /Y "%PARENT_DIR%\data\lang" "%TEMP_ZIP_DIR%\lang"
REM Remove old ZIP if exists
if exist "%ZIP_FILE%" del "%ZIP_FILE%"
REM Create ZIP
powershell -NoProfile -Command "Add-Type -AssemblyName 'System.IO.Compression.FileSystem'; [IO.Compression.ZipFile]::CreateFromDirectory('%TEMP_ZIP_DIR%', '%ZIP_FILE%')"
REM Remove temp folder
rmdir /s /q "%TEMP_ZIP_DIR%"
echo [INFO] ZIP created at: %ZIP_FILE%
endlocal

View File

@ -4,14 +4,14 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PARENT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_DIR="$PARENT_DIR/config"
CONFIG_FILE="$PARENT_DIR/config.json"
ICON_FILE="$PARENT_DIR/data/assets/icon.png"
ENV_FILE="$PARENT_DIR/.env"
# === Check .env ===
if [ ! -f "$ENV_FILE" ]; then
echo "[ERROR] .env file not found. Please copy .env.example to .env and configure it."
exit 1
if [ -f "$PARENT_DIR/.env" ]; then
set -a
. "$PARENT_DIR/.env"
set +a
fi
# === Extract values from config.json ===
@ -19,18 +19,18 @@ ICON_PATH=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['icon
APP_NAME=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['app_name'])")
ARCHITECTURE=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['architecture'])")
# === Extract PYTHON_PATH from .env ===
SYSTEM_PYTHON=$(grep '^PYTHON_PATH=' "$ENV_FILE" | cut -d '=' -f2-)
# === Use the Python interpreter provided by the environment ===
SYSTEM_PYTHON="${PYTHON_PATH:-python3}"
VENV_PATH="$PARENT_DIR/MACenv_$ARCHITECTURE"
VENV_PATH="$CONFIG_DIR/MACenv_$ARCHITECTURE"
APP_FILE="$APP_NAME.app"
PYTHON_IN_VENV="$VENV_PATH/bin/python"
BUILD_DIR="$PARENT_DIR/build"
ZIP_FILE="$BUILD_DIR/$APP_NAME.zip"
# === Verify Python existence ===
if [ ! -x "$SYSTEM_PYTHON" ]; then
echo "[ERROR] Python not found at: $SYSTEM_PYTHON"
if ! command -v "$SYSTEM_PYTHON" >/dev/null 2>&1; then
echo "[ERROR] Python interpreter not found in PATH."
exit 1
fi
@ -39,7 +39,7 @@ if [ ! -f "$VENV_PATH/bin/activate" ]; then
echo "[INFO] Virtual environment not found. Creating..."
"$SYSTEM_PYTHON" -m venv "$VENV_PATH"
"$PYTHON_IN_VENV" -m pip install --upgrade pip
"$PYTHON_IN_VENV" -m pip install -r "$PARENT_DIR/requirements.txt"
"$PYTHON_IN_VENV" -m pip install -r "$CONFIG_DIR/requirements.txt"
else
echo "[INFO] Virtual environment found."
fi
@ -49,7 +49,7 @@ fi
--distpath "$BUILD_DIR" \
--workpath "$BUILD_DIR/dist" \
--clean \
"$PARENT_DIR/BUILD.spec"
"$CONFIG_DIR/BUILD.spec"
# === Clean build cache ===
rm -rf "$BUILD_DIR/dist"

View File

@ -3,30 +3,29 @@ setlocal enabledelayedexpansion
REM Set file paths
set ROOT_DIR=%~dp0..
set CONFIG_DIR=%ROOT_DIR%\config
set CONFIG_FILE=%ROOT_DIR%\config.json
set REQUIREMENTS=%ROOT_DIR%\requirements.txt
set REQUIREMENTS=%CONFIG_DIR%\requirements.txt
set ENV_FILE=%ROOT_DIR%\.env
REM Check if .env file exists
if not exist "%ENV_FILE%" (
echo [ERROR] .env file not found. Please copy .env.example to .env and configure it.
exit /b 1
)
REM Extract config.json fields using PowerShell
for /f "delims=" %%i in ('powershell -NoProfile -Command ^
"Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty architecture"') do set ARCHITECTURE=%%i
REM Extract python path from .env file
for /f "usebackq tokens=2 delims==" %%i in (`findstr "PYTHON_PATH" "%ENV_FILE%"`) do set PYTHON_EXEC=%%i
REM Use the Python interpreter provided by the environment
set PYTHON_EXEC=python
if exist "%ENV_FILE%" (
for /f "usebackq tokens=1,* delims==" %%A in ("%ENV_FILE%") do if /I "%%A"=="PYTHON_PATH" set "PYTHON_EXEC=%%B"
)
REM Construct python executable path
set ENV_NAME=WINenv_%ARCHITECTURE%
set ENV_PATH=%ROOT_DIR%\%ENV_NAME%
set ENV_PATH=%CONFIG_DIR%\%ENV_NAME%
if not exist "%PYTHON_EXEC%" (
echo [ERROR] Python introuvable à: %PYTHON_EXEC%
echo Veuillez vérifier votre .env ou le dossier d'installation.
"%PYTHON_EXEC%" --version >nul 2>&1
if errorlevel 1 (
echo [ERROR] Python introuvable dans le PATH.
echo Veuillez vérifier l'environnement Python fourni par Docker.
exit /b 1
)