From 4ee7f50d65216821d069c701f1c51530e794626a Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Wed, 22 Oct 2025 18:34:02 +0200 Subject: [PATCH] license possibility --- .env.example | 5 + app/core/license_manager.py | 220 +++++++++++++++++++++++++ app/core/main_manager.py | 9 +- app/core/theme_manager.py | 12 +- app/ui/main_window.py | 104 +++++++++++- app/ui/widgets/tabs_widget.py | 5 +- app/ui/windows/activation_window.py | 241 ++++++++++++++++++++++++++++ app/ui/windows/settings_window.py | 20 ++- app/ui/windows/suggestion_window.py | 26 +-- app/utils/licence.py | 66 ++++++++ config.json | 18 ++- data/assets/license.svg | 4 + data/assets/settings.svg | 4 +- data/lang/en.json | 31 +++- data/lang/fr.json | 31 +++- main.py | 9 +- 16 files changed, 771 insertions(+), 34 deletions(-) create mode 100644 app/core/license_manager.py create mode 100644 app/ui/windows/activation_window.py create mode 100644 app/utils/licence.py create mode 100644 data/assets/license.svg diff --git a/.env.example b/.env.example index 16bd4e2..03ef33d 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,8 @@ EMAIL_ADDRESS=your_email@gmail.com EMAIL_PASSWORD=your_app_password EMAIL_SMTP_SERVER=smtp.gmail.com EMAIL_SMTP_PORT=587 + +# Licensing configuration +LICENSE_API_URL=https://your-server.com/api/licenses +PURCHASE_URL=https://your-website.com/buy +LICENSE_API_KEY=your_license_api_key \ No newline at end of file diff --git a/app/core/license_manager.py b/app/core/license_manager.py new file mode 100644 index 0000000..42f97f5 --- /dev/null +++ b/app/core/license_manager.py @@ -0,0 +1,220 @@ +import hashlib +import platform +import uuid +import requests +from datetime import datetime +from typing import Optional, 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 "free" + + if not self.license_data: + return "free" + return self.license_data.get("type", "free") + + 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("free", []) + 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 or feature_id in self.settings_manager.get_config("free_features") + + def get_license_info(self) -> Dict: + """Retourne les informations de la licence""" + if not self.license_data: + return { + "type": "free", + "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") + } \ No newline at end of file diff --git a/app/core/main_manager.py b/app/core/main_manager.py index 439b5c4..b6f9077 100644 --- a/app/core/main_manager.py +++ b/app/core/main_manager.py @@ -4,6 +4,8 @@ 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 class MainManager: @@ -20,7 +22,7 @@ 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: @@ -43,4 +45,7 @@ class MainManager: return self.alert_manager def get_update_manager(self) -> UpdateManager: - return self.update_manager \ No newline at end of file + return self.update_manager + + def get_license_manager(self) -> LicenseManager: + return self.license_manager \ No newline at end of file diff --git a/app/core/theme_manager.py b/app/core/theme_manager.py index 6cd9f9f..ceb36d3 100644 --- a/app/core/theme_manager.py +++ b/app/core/theme_manager.py @@ -66,9 +66,17 @@ class ThemeManager: border-radius: 3px; }} QTextEdit {{ - border: 1px solid {self.current_theme.get_color("border_color")}; + border: 2px solid {self.current_theme.get_color("border_color")}; border-radius: 8px; - padding: 10px; + padding: 5px; + font-size: 14px; + background-color: {self.current_theme.get_color("background_secondary_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_secondary_color")}; color: {self.current_theme.get_color("text_color")}; diff --git a/app/ui/main_window.py b/app/ui/main_window.py index c380d74..6751649 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -1,10 +1,11 @@ -from PyQt6.QtWidgets import QApplication, QMainWindow +from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel from PyQt6.QtGui import QResizeEvent, QCloseEvent from PyQt6.QtCore import QSize, QEvent from app.core.main_manager import MainManager, NotificationType from app.ui.widgets.tabs_widget import TabsWidget, MenuDirection, ButtonPosition, BorderSide, TabSide 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 @@ -31,18 +32,24 @@ class MainWindow(QMainWindow): self.current_size: QSize = QSize(window_size["width"], window_size["height"]) self.previous_size: QSize = QSize(window_size["width"], window_size["height"]) + # Configuration des tailles de police de référence + self.base_width = 600 # Largeur de référence + self.base_height = 450 # Hauteur de référence + + # Cache pour stocker les font-sizes de base de chaque widget + self._base_font_sizes = {} + # UI elements self.side_menu: TabsWidget self.settings_window: SettingsWindow self.suggestion_window: SuggestionWindow - self.setMinimumSize(600, 400) + self.setMinimumSize(600, 450) # Initialiser l'UI immédiatement (sera fait pendant le splash) self.setup_ui() # Différer l'application des paramètres de fenêtre jusqu'à l'affichage réel - # (cela évite des bugs de taille pendant le préchargement) self._window_state_applied = False def showEvent(self, event): @@ -80,6 +87,92 @@ class MainWindow(QMainWindow): if not self.isMaximized() and not self.is_maximizing: self.previous_size = self.current_size self.current_size = self.size() + + # Ajuster dynamiquement les font-sizes + self.adjust_all_font_sizes() + + def adjust_all_font_sizes(self): + """Ajuste dynamiquement les font-sizes de tous les labels dans toutes les tabs""" + # Calculer le ratio basé sur la largeur ET la hauteur actuelle + current_width = self.width() + current_height = self.height() + + # Calculer les ratios séparément + width_ratio = current_width / self.base_width + height_ratio = current_height / self.base_height + + # Prendre le ratio le plus petit pour éviter que le texte dépasse + ratio = min(width_ratio, height_ratio) + + # Récupérer tous les widgets des tabs + all_widgets = [] + if hasattr(self, 'side_menu'): + all_widgets = self.side_menu.widgets + + # Parcourir tous les widgets et ajuster leurs labels + for widget in all_widgets: + if widget: + self._adjust_widget_labels(widget, ratio) + + def _adjust_widget_labels(self, widget, ratio): + """Ajuste récursivement tous les QLabel, QPushButton, QLineEdit, QTextEdit et QComboBox d'un widget""" + from PyQt6.QtWidgets import QPushButton, QLineEdit, QTextEdit, QComboBox + + # Types de widgets à ajuster + widget_types = [QLabel, QPushButton, QLineEdit, QTextEdit, QComboBox] + font_size_dict = self.extract_base_font_size() + for widget_type in widget_types: + for child in widget.findChildren(widget_type): + # Obtenir l'identifiant unique du widget + widget_id = id(child) + + # Si c'est la première fois qu'on voit ce widget, extraire sa font-size de base + if widget_id not in self._base_font_sizes: + base_size = font_size_dict.get(child.__class__.__name__, 14) + self._base_font_sizes[widget_id] = base_size + else: + base_size = self._base_font_sizes[widget_id] + + # Calculer la nouvelle taille + new_size = int(base_size * ratio) + + + # Appliquer le style (en préservant les autres styles existants) + current_style = child.styleSheet() + # Retirer l'ancienne font-size si elle existe + style_parts = [s.strip() for s in current_style.split(';') if s.strip()] + style_parts = [s for s in style_parts if not s.startswith('font-size')] + + # Ajouter la nouvelle font-size + style_parts.append(f'font-size: {new_size}px') + + new_style = '; '.join(style_parts) + child.setStyleSheet(new_style) + + def extract_base_font_size(self) -> dict: + """Extrait la font-size de base d'un widget depuis son stylesheet""" + base_font_sizes = {} + try: + style = self.theme_manager.get_sheet() + + # Chercher "font-size: XXpx" dans le style, puis chercher à quel widget cela correspond + lines = style.splitlines() + component = None + for line in lines: + line = line.strip() + if line.startswith("font-size:"): + size_part = line.split(":")[1].strip().rstrip(";") + if size_part.endswith("px"): + size_value = int(size_part[:-2]) + base_font_sizes[component] = size_value + elif line.startswith("Q"): + component = line.split("{")[0].strip() + return base_font_sizes + + + except Exception: + # En cas d'erreur, retourner une valeur par défaut + return {} def closeEvent(self, event: QCloseEvent) -> None: """Handle application close event""" @@ -105,6 +198,11 @@ class MainWindow(QMainWindow): self.settings_window = SettingsWindow(self) self.side_menu.add_widget(self.settings_window, "", paths.get_asset_svg_path("settings"), position=ButtonPosition.CENTER) + + # 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, "", paths.get_asset_svg_path("license"), position=ButtonPosition.END) self.setCentralWidget(self.side_menu) diff --git a/app/ui/widgets/tabs_widget.py b/app/ui/widgets/tabs_widget.py index 6e3f9e8..6ad062f 100644 --- a/app/ui/widgets/tabs_widget.py +++ b/app/ui/widgets/tabs_widget.py @@ -1,6 +1,6 @@ from PyQt6.QtWidgets import QLayout, QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QStackedWidget, QSizePolicy, QSpacerItem from PyQt6.QtGui import QIcon -from PyQt6.QtCore import QSize +from PyQt6.QtCore import QSize, Qt from enum import Enum from pathlib import Path import hashlib @@ -204,6 +204,9 @@ class TabsWidget(QWidget): # Make button square with specified ratio self._style_square_button(button) + # Configurer le widget pour qu'il soit responsive + widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + # Add to collections first widget_index = len(self.widgets) self.buttons.append(button) diff --git a/app/ui/windows/activation_window.py b/app/ui/windows/activation_window.py new file mode 100644 index 0000000..54b07a0 --- /dev/null +++ b/app/ui/windows/activation_window.py @@ -0,0 +1,241 @@ +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('license_free_mode')}" + 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() \ No newline at end of file diff --git a/app/ui/windows/settings_window.py b/app/ui/windows/settings_window.py index ede1bf2..34d4302 100644 --- a/app/ui/windows/settings_window.py +++ b/app/ui/windows/settings_window.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QLabel, QHBoxLayout +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QLabel, QHBoxLayout, QSizePolicy from PyQt6.QtCore import Qt from app.core.main_manager import MainManager, NotificationType from typing import Optional @@ -26,39 +26,43 @@ class SettingsWindow(QWidget): def setup_ui(self) -> None: layout: QVBoxLayout = QVBoxLayout(self) - layout.setAlignment(Qt.AlignmentFlag.AlignTop) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.setSpacing(20) layout.setContentsMargins(20, 20, 20, 20) - layout.addStretch() + layout.addStretch(1) self.language_layout = QHBoxLayout() # Paramètres de langue self.languageLabel = QLabel(self.language_manager.get_text("language"),self) - self.languageLabel.setFixedWidth(120) # Largeur fixe pour l'alignement + self.languageLabel.setMinimumWidth(100) + self.languageLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) self.language_layout.addWidget(self.languageLabel) self.languageCombo = self.createLanguageSelector() + self.languageCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.language_layout.addWidget(self.languageCombo) layout.addLayout(self.language_layout) - layout.addStretch() + layout.addStretch(1) # Paramètres de thème self.theme_layout = QHBoxLayout() self.themeLabel = QLabel(self.language_manager.get_text("theme"), self) - self.themeLabel.setFixedWidth(120) # Même largeur fixe pour l'alignement + self.themeLabel.setMinimumWidth(100) + self.themeLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) self.theme_layout.addWidget(self.themeLabel) self.themeCombo = self.createThemeSelector() + self.themeCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.theme_layout.addWidget(self.themeCombo) layout.addLayout(self.theme_layout) - layout.addStretch() - + layout.addStretch(1) + def createLanguageSelector(self) -> QComboBox: combo: QComboBox = QComboBox() # Ajouter toutes les langues disponibles diff --git a/app/ui/windows/suggestion_window.py b/app/ui/windows/suggestion_window.py index c418e2d..1852c0c 100644 --- a/app/ui/windows/suggestion_window.py +++ b/app/ui/windows/suggestion_window.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QPushButton, QLabel, QHBoxLayout +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QPushButton, QLabel, QHBoxLayout, QSizePolicy from PyQt6.QtCore import Qt, QThread, pyqtSignal import smtplib, os from email.mime.text import MIMEText @@ -62,6 +62,7 @@ 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) @@ -78,16 +79,16 @@ class SuggestionWindow(QWidget): # Title self.title_label: QLabel = QLabel(self.language_manager.get_text("suggestion_text"), self) - self.title_label.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;") - self.title_label.setWordWrap(True) # Permet le retour à la ligne automatique - self.title_label.setSizePolicy(self.title_label.sizePolicy().horizontalPolicy(), - self.title_label.sizePolicy().verticalPolicy()) + self.title_label.setWordWrap(True) + self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) layout.addWidget(self.title_label) # Text area for suggestion self.text_edit: QTextEdit = QTextEdit(self) self.text_edit.setPlaceholderText(self.language_manager.get_text("suggestion_placeholder")) - layout.addWidget(self.text_edit) + self.text_edit.setMinimumHeight(150) + self.text_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + layout.addWidget(self.text_edit, 1) # Button layout button_layout: QHBoxLayout = QHBoxLayout() @@ -99,7 +100,7 @@ class SuggestionWindow(QWidget): button_layout.addWidget(self.send_button) layout.addLayout(button_layout) - + def send_suggestion(self) -> None: message: str = self.text_edit.toPlainText().strip() @@ -111,9 +112,14 @@ class SuggestionWindow(QWidget): self.send_button.setEnabled(False) self.send_button.setText(self.language_manager.get_text("sending")) - # Create subject with app name - subject: str = f"Suggestion pour {self.settings_manager.get_config('app_name')}" + 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 + # Create and start email sender thread self.email_sender = EmailSender(subject, message) self.email_sender.success.connect(self.on_email_sent) @@ -134,4 +140,4 @@ class SuggestionWindow(QWidget): def update_language(self) -> None: self.title_label.setText(self.language_manager.get_text("suggestion_text")) self.text_edit.setPlaceholderText(self.language_manager.get_text("suggestion_placeholder")) - self.send_button.setText(self.language_manager.get_text("send_suggestion")) + self.send_button.setText(self.language_manager.get_text("send_suggestion")) \ No newline at end of file diff --git a/app/utils/licence.py b/app/utils/licence.py new file mode 100644 index 0000000..88623eb --- /dev/null +++ b/app/utils/licence.py @@ -0,0 +1,66 @@ +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() \ No newline at end of file diff --git a/config.json b/config.json index 960c68e..5998352 100644 --- a/config.json +++ b/config.json @@ -1,10 +1,24 @@ { - "app_name": "Applications", + "app_name": "Application", "app_os": "Windows", "app_version": "1.0.0", "architecture": "x64", "icon_path": "data/assets/icon.ico", "splash_image": "splash", "main_script": "main.py", - "git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate" + "git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate", + "enable_licensing": true, + "features_by_license": { + "free": [ + "basic_features" + ], + "premium": [ + "basic_features", + "priority_support" + ], + "enterprise": [ + "basic_features", + "priority_support" + ] + } } \ No newline at end of file diff --git a/data/assets/license.svg b/data/assets/license.svg new file mode 100644 index 0000000..d0241b2 --- /dev/null +++ b/data/assets/license.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/assets/settings.svg b/data/assets/settings.svg index b9b2280..3efc063 100644 --- a/data/assets/settings.svg +++ b/data/assets/settings.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/data/lang/en.json b/data/lang/en.json index 3e639a3..b49a508 100644 --- a/data/lang/en.json +++ b/data/lang/en.json @@ -9,7 +9,7 @@ "confirmation": "Confirmation", "information": "Information", "close": "Close", - "suggestion_text": "Do you have a question or an idea to improve this application? Send me a message!", + "suggestion_text": "Do you have a question or an idea to improve my software? Send me a message!", "suggestion_placeholder": "Type your message here...", "send_suggestion": "Send", "sending": "Sending...", @@ -30,7 +30,34 @@ "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" + "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", + "free_version": "Free Version", + "premium_version": "Premium Version", + "enterprise_version": "Enterprise Version", + "basic_features": "Basic features", + "priority_support": "Priority support", + "license_active": "License active", + "license_type": "Type", + "license_email": "Email", + "license_expires": "Expires on", + "license_free_mode": "Free mode - Activate a license for more features", + "invalid_license_key": "Invalid license key. It must contain at least 16 characters.", + "premium_feature": "Premium Feature", + "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 newline at end of file diff --git a/data/lang/fr.json b/data/lang/fr.json index 3a1a70a..c556a5e 100644 --- a/data/lang/fr.json +++ b/data/lang/fr.json @@ -9,7 +9,7 @@ "confirmation": "Confirmation", "information": "Information", "close": "Fermer", - "suggestion_text": "Vous avez une question ou une idée pour améliorer cette application ? Envoyez-moi un message !", + "suggestion_text": "Vous avez une question ou une idée pour améliorer mon logiciel ? Envoyez-moi un message !", "suggestion_placeholder": "Tapez votre message ici...", "send_suggestion": "Envoyer", "sending": "Envoi...", @@ -30,7 +30,34 @@ "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é" + "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", + "free_version": "Version Gratuite", + "premium_version": "Version Premium", + "enterprise_version": "Version Enterprise", + "basic_features": "Fonctionnalités de base", + "priority_support": "Support prioritaire", + "license_active": "Licence active", + "license_type": "Type", + "license_email": "Email", + "license_expires": "Expire le", + "license_free_mode": "Mode gratuit - 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 newline at end of file diff --git a/main.py b/main.py index ab47ef8..7d9f402 100644 --- a/main.py +++ b/main.py @@ -25,6 +25,13 @@ 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")) @@ -32,7 +39,7 @@ def preload_application(progress_callback, splash=None): return False progress_callback(language_manager.get_text("initializing")) - + preloaded_window = MainWindow() progress_callback(language_manager.get_text("loading_complete"))