diff --git a/ROADMAP.md b/ROADMAP.md index 31ebcee..2dc607f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -104,8 +104,8 @@ CREATE TABLE verification_codes ( ## 📦 Feature 2: Profil-Export **Priorität:** Mittel -**Status:** Geplant -**Geschätzter Aufwand:** 2-3 Tage +**Status:** ✅ Abgeschlossen (2025-11-10) +**Tatsächlicher Aufwand:** 1 Tag ### Beschreibung Export-Funktion für einzelne Account-Profile zur Weitergabe an andere Nutzer oder als Backup. diff --git a/controllers/profile_export_controller.py b/controllers/profile_export_controller.py new file mode 100644 index 0000000..e03d0b9 --- /dev/null +++ b/controllers/profile_export_controller.py @@ -0,0 +1,234 @@ +""" +Controller für Profil-Export + +Orchestriert den Export-Prozess von Account-Profilen. +""" + +import os +import logging +from typing import Optional, Dict, Any +from pathlib import Path + +from PyQt5.QtWidgets import QFileDialog + +from database.db_manager import DatabaseManager +from utils.profile_export_service import ProfileExportService +from views.dialogs.profile_export_dialog import show_export_dialog +from views.dialogs.export_success_dialog import show_export_success +from views.widgets.modern_message_box import show_error, show_warning + +logger = logging.getLogger("profile_export_controller") + + +class ProfileExportController: + """Controller für Account-Profil-Export""" + + def __init__(self, db_manager: DatabaseManager): + """ + Initialisiert den Export-Controller. + + Args: + db_manager: Datenbank-Manager + """ + self.db_manager = db_manager + self.export_service = ProfileExportService() + + def export_account(self, parent_widget, account_id: int) -> bool: + """ + Startet den Export-Prozess für einen Account. + + Args: + parent_widget: Parent-Widget für Dialoge + account_id: ID des zu exportierenden Accounts + + Returns: + True bei Erfolg, False bei Fehler oder Abbruch + """ + try: + # 1. Account-Daten aus DB laden + logger.info(f"Starte Export für Account-ID: {account_id}") + account_data = self.db_manager.get_account(account_id) + + if not account_data: + logger.error(f"Account nicht gefunden: ID {account_id}") + show_error( + parent_widget, + "Account nicht gefunden", + f"Account mit ID {account_id} konnte nicht gefunden werden." + ) + return False + + username = account_data.get("username", "Unbekannt") + logger.info(f"Account geladen: {username}") + + # 2. Export-Dialog anzeigen + accepted, formats, password_protect = show_export_dialog(parent_widget, username) + + if not accepted: + logger.info("Export abgebrochen durch Nutzer") + return False + + logger.info(f"Export-Optionen: Formate={formats}, Passwort={password_protect}") + + # 3. Speicherort wählen + save_directory = self._select_save_location(parent_widget, account_data, formats, password_protect) + + if not save_directory: + logger.info("Kein Speicherort ausgewählt - Export abgebrochen") + return False + + logger.info(f"Speicherort: {save_directory}") + + # 4. Export durchführen + files_dict, password = self.export_service.export_account( + account_data, + formats, + password_protect + ) + + if not files_dict: + logger.error("Keine Dateien wurden generiert") + show_error( + parent_widget, + "Export fehlgeschlagen", + "Beim Export ist ein Fehler aufgetreten." + ) + return False + + # 5. Dateien speichern + saved_files = [] + for filename, content in files_dict.items(): + file_path = os.path.join(save_directory, filename) + try: + with open(file_path, 'wb') as f: + f.write(content) + saved_files.append(filename) + logger.info(f"Datei gespeichert: {filename}") + except Exception as e: + logger.error(f"Fehler beim Speichern von {filename}: {e}") + show_error( + parent_widget, + "Fehler beim Speichern", + f"Datei {filename} konnte nicht gespeichert werden:\n{str(e)}" + ) + return False + + # 6. Erfolgs-Dialog anzeigen + show_export_success( + parent_widget, + saved_files, + save_directory, + password + ) + + logger.info(f"Export erfolgreich: {len(saved_files)} Datei(en) gespeichert") + return True + + except Exception as e: + logger.error(f"Fehler beim Export: {e}", exc_info=True) + + # Benutzerfreundliche Fehlermeldungen + error_message = str(e) + + if "reportlab" in error_message.lower(): + error_message = ( + "PDF-Export ist nicht verfügbar.\n\n" + "Bitte installieren Sie die erforderlichen Bibliotheken:\n" + "pip install reportlab svglib" + ) + elif "pyzipper" in error_message.lower(): + error_message = ( + "Passwortgeschützter Export ist nicht verfügbar.\n\n" + "Bitte installieren Sie die erforderliche Bibliothek:\n" + "pip install pyzipper" + ) + + show_error( + parent_widget, + "Export fehlgeschlagen", + f"Beim Export ist ein Fehler aufgetreten:\n\n{error_message}" + ) + return False + + def _select_save_location( + self, + parent_widget, + account_data: Dict[str, Any], + formats: list, + password_protect: bool + ) -> Optional[str]: + """ + Öffnet einen Datei-Dialog zur Auswahl des Speicherorts. + + Args: + parent_widget: Parent-Widget + account_data: Account-Daten + formats: Liste der Export-Formate + password_protect: Ob Passwortschutz aktiviert ist + + Returns: + Ausgewähltes Verzeichnis oder None bei Abbruch + """ + # Standard-Dateiname generieren + if password_protect: + # Bei Passwortschutz wird eine ZIP erstellt + default_filename = self.export_service.generate_filename( + account_data, + "zip" + ) + else: + # Ohne Passwortschutz: Ersten Format als Beispiel nehmen + first_format = formats[0] if formats else "csv" + default_filename = self.export_service.generate_filename( + account_data, + first_format + ) + + # Standard-Speicherort: Benutzer-Downloads-Ordner + default_directory = str(Path.home() / "Downloads") + + if password_protect: + # Für ZIP: File-Dialog + file_path, _ = QFileDialog.getSaveFileName( + parent_widget, + "Profil exportieren", + os.path.join(default_directory, default_filename), + "ZIP-Archiv (*.zip)" + ) + + if not file_path: + return None + + # Verzeichnis aus Dateipfad extrahieren + return os.path.dirname(file_path) + else: + # Für mehrere Dateien: Verzeichnis-Dialog + directory = QFileDialog.getExistingDirectory( + parent_widget, + "Speicherort für Export auswählen", + default_directory + ) + + return directory if directory else None + + def export_multiple_accounts(self, parent_widget, account_ids: list) -> bool: + """ + Exportiert mehrere Accounts gleichzeitig. + + HINWEIS: Diese Funktion ist vorbereitet für zukünftige Erweiterung, + wird aber gemäß ROADMAP.md vorerst NICHT implementiert (YAGNI). + + Args: + parent_widget: Parent-Widget + account_ids: Liste von Account-IDs + + Returns: + True bei Erfolg, False bei Fehler + """ + show_warning( + parent_widget, + "Nicht verfügbar", + "Mehrfach-Export ist derzeit nicht verfügbar.\n" + "Bitte exportieren Sie Accounts einzeln." + ) + return False diff --git a/requirements.txt b/requirements.txt index 086a27a..3d57eae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,8 @@ PyYAML>=6.0 # Web automation and anti-detection random-user-agent>=1.0.1 + +# Profil-Export (Feature 2) +reportlab>=3.6.0 # PDF-Generierung +pyzipper>=0.3.6 # ZIP mit Passwortschutz (AES) +svglib>=1.5.0 # SVG-Support für PDF (optional) diff --git a/utils/profile_export_service.py b/utils/profile_export_service.py new file mode 100644 index 0000000..bfbbb38 --- /dev/null +++ b/utils/profile_export_service.py @@ -0,0 +1,479 @@ +""" +Profil-Export-Service für Account-Daten + +Exportiert Account-Profile in verschiedene Formate (CSV, TXT, PDF) +mit optionalem Passwortschutz. +""" + +import os +import csv +import secrets +import string +import logging +from io import BytesIO, StringIO +from datetime import datetime +from typing import Dict, Any, List, Optional, Tuple +from pathlib import Path + +logger = logging.getLogger("profile_export_service") + + +class ProfileExportService: + """Service für den Export von Account-Profilen""" + + # Felder die exportiert werden (ohne id, last_login, notes, status) + EXPORT_FIELDS = { + "username": "Username", + "password": "Passwort", + "email": "E-Mail", + "phone": "Telefon", + "platform": "Plattform", + "full_name": "Name", + "created_at": "Erstellt_am" + } + + @staticmethod + def export_to_csv(account_data: Dict[str, Any]) -> bytes: + """ + Exportiert Account-Daten als CSV. + + Args: + account_data: Dictionary mit Account-Daten + + Returns: + CSV-Daten als bytes + """ + try: + output = StringIO() + writer = csv.DictWriter( + output, + fieldnames=ProfileExportService.EXPORT_FIELDS.values(), + quoting=csv.QUOTE_MINIMAL + ) + + # Header schreiben + writer.writeheader() + + # Datenzeile vorbereiten + row_data = {} + for field_key, field_label in ProfileExportService.EXPORT_FIELDS.items(): + value = account_data.get(field_key, "") + # None zu leerem String konvertieren + row_data[field_label] = value if value is not None else "" + + # Datenzeile schreiben + writer.writerow(row_data) + + # In bytes konvertieren (UTF-8) + csv_content = output.getvalue() + output.close() + + logger.info("CSV-Export erfolgreich") + return csv_content.encode('utf-8') + + except Exception as e: + logger.error(f"Fehler beim CSV-Export: {e}") + raise + + @staticmethod + def export_to_txt(account_data: Dict[str, Any]) -> bytes: + """ + Exportiert Account-Daten als TXT (Key-Value Format). + + Args: + account_data: Dictionary mit Account-Daten + + Returns: + TXT-Daten als bytes + """ + try: + lines = [] + + for field_key, field_label in ProfileExportService.EXPORT_FIELDS.items(): + value = account_data.get(field_key, "") + # None zu leerem String konvertieren + value = value if value is not None else "" + lines.append(f"{field_label}: {value}") + + # Exportdatum hinzufügen + export_date = datetime.now().strftime("%d.%m.%Y %H:%M") + lines.append(f"\nExportiert am: {export_date}") + + txt_content = "\n".join(lines) + + logger.info("TXT-Export erfolgreich") + return txt_content.encode('utf-8') + + except Exception as e: + logger.error(f"Fehler beim TXT-Export: {e}") + raise + + @staticmethod + def export_to_pdf(account_data: Dict[str, Any]) -> bytes: + """ + Exportiert Account-Daten als PDF mit schönem Layout. + + Args: + account_data: Dictionary mit Account-Daten + + Returns: + PDF-Daten als bytes + """ + try: + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import mm + from reportlab.lib import colors + from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.enums import TA_LEFT, TA_CENTER + + # PDF-Buffer erstellen + buffer = BytesIO() + + # Dokument erstellen + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + rightMargin=20*mm, + leftMargin=20*mm, + topMargin=20*mm, + bottomMargin=20*mm + ) + + # Story (Inhalt) erstellen + story = [] + styles = getSampleStyleSheet() + + # Custom Styles + title_style = ParagraphStyle( + 'CustomTitle', + parent=styles['Title'], + fontSize=20, + textColor=colors.HexColor('#1F2937'), + spaceAfter=5*mm, + alignment=TA_CENTER + ) + + heading_style = ParagraphStyle( + 'CustomHeading', + parent=styles['Heading2'], + fontSize=14, + textColor=colors.HexColor('#374151'), + spaceAfter=3*mm, + spaceBefore=5*mm + ) + + # IntelSight Logo versuchen zu laden + logo_path = Path("resources/icons/intelsight-logo.svg") + if logo_path.exists(): + try: + # SVG zu reportlab Image (mit svglib falls verfügbar) + try: + from svglib.svglib import svg2rlg + from reportlab.graphics import renderPDF + + drawing = svg2rlg(str(logo_path)) + if drawing: + # Skalieren auf vernünftige Größe + drawing.width = 40*mm + drawing.height = 10*mm + drawing.scale(40*mm/drawing.width, 10*mm/drawing.height) + story.append(drawing) + story.append(Spacer(1, 5*mm)) + except ImportError: + # svglib nicht verfügbar - überspringen + logger.debug("svglib nicht verfügbar - Logo wird übersprungen") + except Exception as e: + logger.debug(f"Logo konnte nicht geladen werden: {e}") + + # Titel + username = account_data.get("username", "Unbekannt") + platform = account_data.get("platform", "Unbekannt") + title = Paragraph(f"Account-Profil: {username}", title_style) + story.append(title) + + # Plattform + platform_text = Paragraph(f"Plattform: {platform.title()}", styles['Normal']) + story.append(platform_text) + story.append(Spacer(1, 10*mm)) + + # LOGIN-DATEN Sektion + login_heading = Paragraph("LOGIN-DATEN", heading_style) + story.append(login_heading) + + # Login-Daten Tabelle + login_data = [ + ["Benutzername:", account_data.get("username", "")], + ["Passwort:", account_data.get("password", "")], + ["E-Mail:", account_data.get("email", "") or "-"], + ["Telefon:", account_data.get("phone", "") or "-"] + ] + + login_table = Table(login_data, colWidths=[45*mm, 115*mm]) + login_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), + ('FONTNAME', (1, 0), (1, -1), 'Helvetica'), + ('FONTSIZE', (0, 0), (-1, -1), 11), + ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#6B7280')), + ('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#1F2937')), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 0), + ('RIGHTPADDING', (0, 0), (-1, -1), 0), + ('TOPPADDING', (0, 0), (-1, -1), 2*mm), + ('BOTTOMPADDING', (0, 0), (-1, -1), 2*mm), + ])) + + story.append(login_table) + story.append(Spacer(1, 8*mm)) + + # PROFIL-INFORMATIONEN Sektion + profile_heading = Paragraph("PROFIL-INFORMATIONEN", heading_style) + story.append(profile_heading) + + # Profil-Daten Tabelle + profile_data = [ + ["Name:", account_data.get("full_name", "") or "-"], + ["Erstellt am:", account_data.get("created_at", "") or "-"] + ] + + profile_table = Table(profile_data, colWidths=[45*mm, 115*mm]) + profile_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), + ('FONTNAME', (1, 0), (1, -1), 'Helvetica'), + ('FONTSIZE', (0, 0), (-1, -1), 11), + ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#6B7280')), + ('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#1F2937')), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 0), + ('RIGHTPADDING', (0, 0), (-1, -1), 0), + ('TOPPADDING', (0, 0), (-1, -1), 2*mm), + ('BOTTOMPADDING', (0, 0), (-1, -1), 2*mm), + ])) + + story.append(profile_table) + story.append(Spacer(1, 15*mm)) + + # Export-Datum (Footer) + export_date = datetime.now().strftime("%d.%m.%Y %H:%M") + footer_text = Paragraph( + f"Exportiert am: {export_date}", + ParagraphStyle( + 'Footer', + parent=styles['Normal'], + fontSize=9, + textColor=colors.HexColor('#9CA3AF'), + alignment=TA_CENTER + ) + ) + story.append(footer_text) + + # PDF erstellen + doc.build(story) + + # Buffer-Wert holen + pdf_content = buffer.getvalue() + buffer.close() + + logger.info("PDF-Export erfolgreich") + return pdf_content + + except ImportError as e: + logger.error(f"reportlab nicht installiert: {e}") + raise Exception( + "PDF-Export erfordert 'reportlab' Library. " + "Bitte installieren Sie: pip install reportlab" + ) + except Exception as e: + logger.error(f"Fehler beim PDF-Export: {e}") + raise + + @staticmethod + def generate_password(length: int = 10) -> str: + """ + Generiert ein sicheres zufälliges Passwort. + + Args: + length: Länge des Passworts (Standard: 10) + + Returns: + Generiertes Passwort + """ + # Zeichensatz: Groß- und Kleinbuchstaben, Zahlen, Sonderzeichen + alphabet = string.ascii_letters + string.digits + "!@#$%^&*" + + # Sicherstellen dass mindestens ein Zeichen von jedem Typ vorhanden ist + password_chars = [ + secrets.choice(string.ascii_uppercase), # Mindestens ein Großbuchstabe + secrets.choice(string.ascii_lowercase), # Mindestens ein Kleinbuchstabe + secrets.choice(string.digits), # Mindestens eine Zahl + secrets.choice("!@#$%^&*") # Mindestens ein Sonderzeichen + ] + + # Rest auffüllen + for _ in range(length - 4): + password_chars.append(secrets.choice(alphabet)) + + # Mischen für Zufälligkeit + secrets.SystemRandom().shuffle(password_chars) + + password = ''.join(password_chars) + logger.info("Passwort generiert") + return password + + @staticmethod + def create_protected_zip(files_dict: Dict[str, bytes], password: str) -> bytes: + """ + Erstellt eine passwortgeschützte ZIP-Datei. + + Args: + files_dict: Dictionary mit {filename: content} Paaren + password: Passwort für die ZIP-Datei + + Returns: + ZIP-Daten als bytes + """ + try: + import pyzipper + + # ZIP-Buffer erstellen + buffer = BytesIO() + + # ZIP mit AES-Verschlüsselung erstellen + with pyzipper.AESZipFile( + buffer, + 'w', + compression=pyzipper.ZIP_DEFLATED, + encryption=pyzipper.WZ_AES + ) as zf: + # Passwort setzen + zf.setpassword(password.encode('utf-8')) + + # Dateien hinzufügen + for filename, content in files_dict.items(): + zf.writestr(filename, content) + + # Buffer-Wert holen + zip_content = buffer.getvalue() + buffer.close() + + logger.info(f"Passwortgeschützte ZIP erstellt mit {len(files_dict)} Dateien") + return zip_content + + except ImportError as e: + logger.error(f"pyzipper nicht installiert: {e}") + raise Exception( + "ZIP mit Passwortschutz erfordert 'pyzipper' Library. " + "Bitte installieren Sie: pip install pyzipper" + ) + except Exception as e: + logger.error(f"Fehler beim Erstellen der geschützten ZIP: {e}") + raise + + @staticmethod + def generate_filename( + account_data: Dict[str, Any], + format_extension: str, + include_timestamp: bool = True + ) -> str: + """ + Generiert einen Dateinamen nach Konvention. + + Format: {username}_{platform}_{timestamp}.{extension} + Beispiel: max_mueller_instagram_2025-11-10_14-30.csv + + Args: + account_data: Account-Daten + format_extension: Dateiendung (z.B. "csv", "txt", "pdf", "zip") + include_timestamp: Ob Timestamp hinzugefügt werden soll + + Returns: + Generierter Dateiname + """ + # Username und Platform aus Account-Daten + username = account_data.get("username", "account") + platform = account_data.get("platform", "unknown").lower() + + # Sonderzeichen entfernen für Dateinamen + username = ProfileExportService._sanitize_filename(username) + platform = ProfileExportService._sanitize_filename(platform) + + # Timestamp + if include_timestamp: + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M") + filename = f"{username}_{platform}_{timestamp}.{format_extension}" + else: + filename = f"{username}_{platform}.{format_extension}" + + return filename + + @staticmethod + def _sanitize_filename(filename: str) -> str: + """ + Entfernt ungültige Zeichen aus Dateinamen. + + Args: + filename: Ursprünglicher Dateiname + + Returns: + Bereinigter Dateiname + """ + # Nur alphanumerische Zeichen, Unterstrich und Bindestrich erlauben + valid_chars = f"-_.{string.ascii_letters}{string.digits}" + sanitized = ''.join(c if c in valid_chars else '_' for c in filename) + return sanitized + + @staticmethod + def export_account( + account_data: Dict[str, Any], + formats: List[str], + password_protect: bool = False + ) -> Tuple[Dict[str, bytes], Optional[str]]: + """ + Exportiert Account-Daten in angegebene Formate. + + Args: + account_data: Account-Daten zum Exportieren + formats: Liste von Formaten ["csv", "txt", "pdf"] + password_protect: Ob Dateien passwortgeschützt werden sollen + + Returns: + Tuple von (files_dict, password) + - files_dict: {filename: content} für alle Formate + - password: Generiertes Passwort (None wenn nicht geschützt) + """ + files_dict = {} + + # Jedes Format exportieren + for fmt in formats: + fmt = fmt.lower() + + if fmt == "csv": + content = ProfileExportService.export_to_csv(account_data) + filename = ProfileExportService.generate_filename(account_data, "csv") + files_dict[filename] = content + + elif fmt == "txt": + content = ProfileExportService.export_to_txt(account_data) + filename = ProfileExportService.generate_filename(account_data, "txt") + files_dict[filename] = content + + elif fmt == "pdf": + content = ProfileExportService.export_to_pdf(account_data) + filename = ProfileExportService.generate_filename(account_data, "pdf") + files_dict[filename] = content + else: + logger.warning(f"Unbekanntes Format ignoriert: {fmt}") + + # Passwortschutz wenn gewünscht + password = None + if password_protect and files_dict: + password = ProfileExportService.generate_password() + + # Alle Dateien in ZIP packen + zip_filename = ProfileExportService.generate_filename(account_data, "zip") + zip_content = ProfileExportService.create_protected_zip(files_dict, password) + + # Nur ZIP zurückgeben + files_dict = {zip_filename: zip_content} + + return files_dict, password diff --git a/views/dialogs/export_success_dialog.py b/views/dialogs/export_success_dialog.py new file mode 100644 index 0000000..221f180 --- /dev/null +++ b/views/dialogs/export_success_dialog.py @@ -0,0 +1,389 @@ +""" +Erfolgs-Dialog für Profil-Export + +Zeigt exportierte Dateien und optional das generierte Passwort an. +""" + +import os +import logging +import subprocess +import platform +from pathlib import Path +from typing import List, Optional + +from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QFrame, QGraphicsDropShadowEffect, QApplication, QScrollArea, QWidget +) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont, QColor + +logger = logging.getLogger("export_success_dialog") + + +class ExportSuccessDialog(QDialog): + """Dialog zur Anzeige von Export-Erfolg""" + + def __init__( + self, + parent=None, + exported_files: List[str] = None, + export_directory: str = "", + password: Optional[str] = None + ): + """ + Initialisiert den Erfolgs-Dialog. + + Args: + parent: Parent-Widget + exported_files: Liste der exportierten Dateinamen + export_directory: Verzeichnis wo Dateien gespeichert wurden + password: Optional generiertes Passwort (nur bei Passwortschutz) + """ + super().__init__(parent) + self.exported_files = exported_files or [] + self.export_directory = export_directory + self.password = password + self.init_ui() + + def init_ui(self): + """Initialisiert die UI""" + + # Dialog-Eigenschaften + self.setWindowTitle("Export erfolgreich") + self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground, True) + self.setModal(True) + + # Dynamische Höhe basierend auf Inhalt + base_height = 280 + if self.password: + base_height += 80 # Extra Platz für Passwort-Anzeige + if len(self.exported_files) > 2: + base_height += 20 * (len(self.exported_files) - 2) # Extra für mehr Dateien + + self.setFixedSize(500, min(base_height, 500)) + + # Hauptlayout + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(20, 20, 20, 20) + + # Container mit modernem Design + self.container = QFrame() + self.container.setObjectName("exportSuccessContainer") + self.container.setStyleSheet(""" + #exportSuccessContainer { + background-color: white; + border-radius: 12px; + border: 1px solid #E5E7EB; + } + """) + + # Schatten-Effekt + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(30) + shadow.setXOffset(0) + shadow.setYOffset(10) + shadow.setColor(QColor(0, 0, 0, 80)) + self.container.setGraphicsEffect(shadow) + + # Container-Layout + container_layout = QVBoxLayout(self.container) + container_layout.setSpacing(18) + container_layout.setContentsMargins(30, 25, 30, 25) + + # Header mit Icon und Titel + header_layout = QHBoxLayout() + header_layout.setSpacing(15) + + # Erfolgs-Icon + icon_label = QLabel("✅") + icon_label.setStyleSheet("font-size: 32px;") + icon_label.setFixedSize(40, 40) + icon_label.setAlignment(Qt.AlignCenter) + + # Titel + title_label = QLabel("Profil exportiert!") + title_font = QFont("Poppins", 16) + title_font.setWeight(QFont.DemiBold) + title_label.setFont(title_font) + title_label.setStyleSheet("color: #1F2937;") + + header_layout.addWidget(icon_label) + header_layout.addWidget(title_label, 1) + + container_layout.addLayout(header_layout) + + # Dateien-Liste + files_label = QLabel("Dateien:") + files_label.setStyleSheet("color: #6B7280; font-size: 13px; font-weight: 600;") + container_layout.addWidget(files_label) + + # Scrollbare Liste für Dateien + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShape(QFrame.NoFrame) + scroll_area.setStyleSheet(""" + QScrollArea { + background-color: #F9FAFB; + border-radius: 6px; + border: 1px solid #E5E7EB; + } + """) + + files_widget = QWidget() + files_layout = QVBoxLayout(files_widget) + files_layout.setSpacing(4) + files_layout.setContentsMargins(10, 10, 10, 10) + + for filename in self.exported_files: + file_label = QLabel(f"• {filename}") + file_label.setStyleSheet(""" + color: #374151; + font-size: 12px; + font-family: 'Courier New', monospace; + padding: 2px; + """) + file_label.setWordWrap(True) + files_layout.addWidget(file_label) + + files_layout.addStretch() + scroll_area.setWidget(files_widget) + scroll_area.setMaximumHeight(120) + + container_layout.addWidget(scroll_area) + + # Passwort-Anzeige (nur wenn Passwort vorhanden) + if self.password: + password_frame = QFrame() + password_frame.setStyleSheet(""" + QFrame { + background-color: #FEF3C7; + border: 1px solid #FCD34D; + border-radius: 6px; + padding: 12px; + } + """) + password_layout = QVBoxLayout(password_frame) + password_layout.setSpacing(8) + + # Warnung + warning_label = QLabel("⚠ Bitte Passwort speichern!") + warning_label.setStyleSheet(""" + color: #92400E; + font-size: 13px; + font-weight: 600; + """) + + # Passwort mit Copy-Button + password_row = QHBoxLayout() + password_row.setSpacing(10) + + password_label = QLabel(f"🔒 Passwort: {self.password}") + password_label.setStyleSheet(""" + color: #78350F; + font-size: 13px; + font-family: 'Courier New', monospace; + font-weight: 600; + """) + password_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + + copy_button = QPushButton("Kopieren") + copy_button.setMaximumWidth(80) + copy_button.setMinimumHeight(28) + copy_button.setCursor(Qt.PointingHandCursor) + copy_button.setStyleSheet(""" + QPushButton { + background-color: #F59E0B; + color: white; + border: none; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + padding: 4px 8px; + } + QPushButton:hover { + background-color: #D97706; + } + QPushButton:pressed { + background-color: #B45309; + } + """) + copy_button.clicked.connect(self.copy_password) + + password_row.addWidget(password_label, 1) + password_row.addWidget(copy_button) + + password_layout.addWidget(warning_label) + password_layout.addLayout(password_row) + + container_layout.addWidget(password_frame) + + # Speicherort + location_label = QLabel(f"Gespeichert in:") + location_label.setStyleSheet("color: #6B7280; font-size: 12px;") + + path_label = QLabel(self._format_path(self.export_directory)) + path_label.setStyleSheet(""" + color: #4B5563; + font-size: 11px; + font-family: 'Courier New', monospace; + """) + path_label.setWordWrap(True) + path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + + container_layout.addWidget(location_label) + container_layout.addWidget(path_label) + + # Spacer + container_layout.addStretch() + + # Button-Layout + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + button_layout.addStretch() + + # Ordner öffnen Button + self.open_folder_button = QPushButton("Ordner öffnen") + self.open_folder_button.setMinimumHeight(38) + self.open_folder_button.setMinimumWidth(120) + self.open_folder_button.setCursor(Qt.PointingHandCursor) + self.open_folder_button.setStyleSheet(""" + QPushButton { + background-color: #F3F4F6; + color: #374151; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + padding: 8px 16px; + } + QPushButton:hover { + background-color: #E5E7EB; + } + QPushButton:pressed { + background-color: #D1D5DB; + } + """) + self.open_folder_button.clicked.connect(self.open_folder) + + # OK Button + self.ok_button = QPushButton("OK") + self.ok_button.setMinimumHeight(38) + self.ok_button.setMinimumWidth(100) + self.ok_button.setCursor(Qt.PointingHandCursor) + self.ok_button.setStyleSheet(""" + QPushButton { + background-color: #10B981; + color: white; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + padding: 8px 16px; + } + QPushButton:hover { + background-color: #059669; + } + QPushButton:pressed { + background-color: #047857; + } + """) + self.ok_button.clicked.connect(self.accept) + + button_layout.addWidget(self.open_folder_button) + button_layout.addWidget(self.ok_button) + + container_layout.addLayout(button_layout) + + # Container zum Hauptlayout hinzufügen + main_layout.addWidget(self.container) + + def copy_password(self): + """Kopiert das Passwort in die Zwischenablage""" + if self.password: + clipboard = QApplication.clipboard() + clipboard.setText(self.password) + logger.info("Passwort in Zwischenablage kopiert") + + # Kurzes visuelles Feedback + sender = self.sender() + if sender: + original_text = sender.text() + sender.setText("✓ Kopiert!") + sender.setStyleSheet(sender.styleSheet().replace("#F59E0B", "#10B981")) + + # Nach 2 Sekunden zurücksetzen + from PyQt5.QtCore import QTimer + QTimer.singleShot(2000, lambda: self._reset_copy_button(sender, original_text)) + + def _reset_copy_button(self, button, original_text): + """Setzt den Copy-Button zurück""" + if button: + button.setText(original_text) + button.setStyleSheet(button.styleSheet().replace("#10B981", "#F59E0B")) + + def open_folder(self): + """Öffnet den Export-Ordner im Datei-Explorer""" + if not self.export_directory or not os.path.exists(self.export_directory): + logger.warning(f"Export-Verzeichnis existiert nicht: {self.export_directory}") + return + + try: + system = platform.system() + + if system == "Windows": + # Windows Explorer + os.startfile(self.export_directory) + elif system == "Darwin": + # macOS Finder + subprocess.run(["open", self.export_directory]) + elif system == "Linux": + # Linux File Manager + subprocess.run(["xdg-open", self.export_directory]) + else: + logger.warning(f"Unbekanntes Betriebssystem: {system}") + + logger.info(f"Ordner geöffnet: {self.export_directory}") + + except Exception as e: + logger.error(f"Fehler beim Öffnen des Ordners: {e}") + + def _format_path(self, path: str) -> str: + """ + Formatiert den Pfad für die Anzeige. + Kürzt zu lange Pfade. + """ + if not path: + return "" + + max_length = 60 + if len(path) <= max_length: + return path + + # Pfad kürzen: Anfang ... Ende + start_length = 25 + end_length = max_length - start_length - 3 + + return f"{path[:start_length]}...{path[-end_length:]}" + + +# Helper-Funktion für einfache Verwendung + +def show_export_success( + parent, + exported_files: List[str], + export_directory: str, + password: Optional[str] = None +): + """ + Zeigt den Export-Erfolgs-Dialog modal an. + + Args: + parent: Parent-Widget + exported_files: Liste der exportierten Dateinamen + export_directory: Verzeichnis wo Dateien gespeichert wurden + password: Optional generiertes Passwort + """ + dialog = ExportSuccessDialog(parent, exported_files, export_directory, password) + dialog.exec_() diff --git a/views/dialogs/profile_export_dialog.py b/views/dialogs/profile_export_dialog.py new file mode 100644 index 0000000..a85b8cf --- /dev/null +++ b/views/dialogs/profile_export_dialog.py @@ -0,0 +1,324 @@ +""" +Export-Dialog für Account-Profile + +Ermöglicht Auswahl von Export-Formaten und Passwortschutz. +""" + +import logging +from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QCheckBox, QFrame, QGraphicsDropShadowEffect, QGroupBox +) +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QFont, QColor + +logger = logging.getLogger("profile_export_dialog") + + +class ProfileExportDialog(QDialog): + """Dialog zur Auswahl von Export-Optionen""" + + # Signal wird emittiert wenn Export bestätigt wird + # Parameter: (formats: List[str], password_protect: bool) + export_confirmed = pyqtSignal(list, bool) + + def __init__(self, parent=None, account_username: str = ""): + """ + Initialisiert den Export-Dialog. + + Args: + parent: Parent-Widget + account_username: Username des zu exportierenden Accounts + """ + super().__init__(parent) + self.account_username = account_username + self.init_ui() + + def init_ui(self): + """Initialisiert die UI""" + + # Dialog-Eigenschaften + self.setWindowTitle("Profil exportieren") + self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground, True) + self.setModal(True) + self.setFixedSize(450, 600) + + # Hauptlayout + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(20, 20, 20, 20) + + # Container mit modernem Design + self.container = QFrame() + self.container.setObjectName("exportDialogContainer") + self.container.setStyleSheet(""" + #exportDialogContainer { + background-color: white; + border-radius: 12px; + border: 1px solid #E5E7EB; + } + """) + + # Schatten-Effekt + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(30) + shadow.setXOffset(0) + shadow.setYOffset(10) + shadow.setColor(QColor(0, 0, 0, 80)) + self.container.setGraphicsEffect(shadow) + + # Container-Layout + container_layout = QVBoxLayout(self.container) + container_layout.setSpacing(20) + container_layout.setContentsMargins(30, 25, 30, 25) + + # Titel + title_label = QLabel("Profil exportieren") + title_font = QFont("Poppins", 16) + title_font.setWeight(QFont.DemiBold) + title_label.setFont(title_font) + title_label.setStyleSheet("color: #1F2937;") + container_layout.addWidget(title_label) + + # Account-Info + if self.account_username: + account_label = QLabel(f"Account: {self.account_username}") + account_label.setStyleSheet("color: #6B7280; font-size: 13px; font-family: 'Poppins', sans-serif;") + container_layout.addWidget(account_label) + + # Abstand vor Format-GroupBox + container_layout.addSpacing(15) + + # Format-Auswahl GroupBox + format_group = QGroupBox("Format") + format_group.setMinimumHeight(150) # Mehr Platz für 3 Checkboxen + format_group.setStyleSheet(""" + QGroupBox { + font-size: 14px; + font-weight: 600; + font-family: 'Poppins', sans-serif; + color: #374151; + border: 1px solid #E5E7EB; + border-radius: 8px; + margin-top: 20px; + padding-top: 30px; + } + QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 12px; + top: 2px; + padding: 2px 8px 2px 8px; + background-color: white; + } + """) + format_layout = QVBoxLayout(format_group) + format_layout.setSpacing(12) + format_layout.setContentsMargins(20, 25, 20, 20) + + # Format-Checkboxen + self.csv_checkbox = QCheckBox("CSV") + self.csv_checkbox.setChecked(False) # Standard: nicht aktiviert + self.csv_checkbox.setStyleSheet(""" + QCheckBox { + font-size: 13px; + font-family: 'Poppins', sans-serif; + color: #374151; + spacing: 8px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 4px; + border: 2px solid #D1D5DB; + } + QCheckBox::indicator:checked { + background-color: #0099CC; + border-color: #0099CC; + } + """) + + self.txt_checkbox = QCheckBox("TXT") + self.txt_checkbox.setChecked(False) # Standard: nicht aktiviert + self.txt_checkbox.setStyleSheet(self.csv_checkbox.styleSheet()) + + self.pdf_checkbox = QCheckBox("PDF") + self.pdf_checkbox.setChecked(False) # Standard: nicht aktiviert + self.pdf_checkbox.setStyleSheet(self.csv_checkbox.styleSheet()) + + # Checkboxen zum Format-Layout hinzufügen + format_layout.addWidget(self.csv_checkbox) + format_layout.addWidget(self.txt_checkbox) + format_layout.addWidget(self.pdf_checkbox) + + container_layout.addWidget(format_group) + + # Passwortschutz-Checkbox + self.password_checkbox = QCheckBox("Mit Passwort schützen") + self.password_checkbox.setChecked(False) + self.password_checkbox.setStyleSheet(""" + QCheckBox { + font-size: 13px; + font-family: 'Poppins', sans-serif; + font-weight: 500; + color: #374151; + spacing: 8px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 4px; + border: 2px solid #D1D5DB; + } + QCheckBox::indicator:checked { + background-color: #10B981; + border-color: #10B981; + } + """) + + # Info-Text für Passwortschutz + password_info = QLabel("(Wird automatisch generiert)") + password_info.setStyleSheet("color: #9CA3AF; font-size: 11px; font-family: 'Poppins', sans-serif; margin-left: 26px;") + + container_layout.addWidget(self.password_checkbox) + container_layout.addWidget(password_info) + + # Spacer + container_layout.addStretch() + + # Button-Layout + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + button_layout.addStretch() + + # Abbrechen-Button + self.cancel_button = QPushButton("Abbrechen") + self.cancel_button.setMinimumHeight(38) + self.cancel_button.setMinimumWidth(100) + self.cancel_button.setCursor(Qt.PointingHandCursor) + self.cancel_button.setStyleSheet(""" + QPushButton { + background-color: #F3F4F6; + color: #374151; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + font-family: 'Poppins', sans-serif; + padding: 8px 16px; + } + QPushButton:hover { + background-color: #E5E7EB; + } + QPushButton:pressed { + background-color: #D1D5DB; + } + """) + self.cancel_button.clicked.connect(self.reject) + + # Exportieren-Button + self.export_button = QPushButton("Exportieren") + self.export_button.setMinimumHeight(38) + self.export_button.setMinimumWidth(120) + self.export_button.setCursor(Qt.PointingHandCursor) + self.export_button.setStyleSheet(""" + QPushButton { + background-color: #0099CC; + color: white; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + font-family: 'Poppins', sans-serif; + padding: 8px 16px; + } + QPushButton:hover { + background-color: #0078A3; + } + QPushButton:pressed { + background-color: #005C7A; + } + """) + self.export_button.clicked.connect(self.on_export_clicked) + + button_layout.addWidget(self.cancel_button) + button_layout.addWidget(self.export_button) + + container_layout.addLayout(button_layout) + + # Container zum Hauptlayout hinzufügen + main_layout.addWidget(self.container) + + def on_export_clicked(self): + """Handler für Export-Button""" + + # Sammle ausgewählte Formate + selected_formats = [] + if self.csv_checkbox.isChecked(): + selected_formats.append("csv") + if self.txt_checkbox.isChecked(): + selected_formats.append("txt") + if self.pdf_checkbox.isChecked(): + selected_formats.append("pdf") + + # Validierung: Mindestens ein Format muss ausgewählt sein + if not selected_formats: + from views.widgets.modern_message_box import show_warning + show_warning( + self, + "Kein Format ausgewählt", + "Bitte wählen Sie mindestens ein Export-Format aus." + ) + return + + # Passwortschutz-Option + password_protect = self.password_checkbox.isChecked() + + # Signal emittieren + self.export_confirmed.emit(selected_formats, password_protect) + + # Dialog schließen + self.accept() + + def get_selected_options(self): + """ + Gibt die ausgewählten Optionen zurück. + + Returns: + Tuple: (formats: List[str], password_protect: bool) + """ + formats = [] + if self.csv_checkbox.isChecked(): + formats.append("csv") + if self.txt_checkbox.isChecked(): + formats.append("txt") + if self.pdf_checkbox.isChecked(): + formats.append("pdf") + + password_protect = self.password_checkbox.isChecked() + + return formats, password_protect + + +# Helper-Funktionen für einfache Verwendung + +def show_export_dialog(parent, account_username: str = ""): + """ + Zeigt den Export-Dialog modal an. + + Args: + parent: Parent-Widget + account_username: Username des Accounts + + Returns: + Tuple: (accepted: bool, formats: List[str], password_protect: bool) + accepted ist True wenn Export bestätigt wurde + """ + dialog = ProfileExportDialog(parent, account_username) + result = dialog.exec_() + + if result == QDialog.Accepted: + formats, password_protect = dialog.get_selected_options() + return True, formats, password_protect + else: + return False, [], False diff --git a/views/tabs/accounts_tab.py b/views/tabs/accounts_tab.py index c0061b8..78aa93e 100644 --- a/views/tabs/accounts_tab.py +++ b/views/tabs/accounts_tab.py @@ -218,7 +218,30 @@ class AccountsTab(QWidget): def on_export_clicked(self): """Wird aufgerufen, wenn der Exportieren-Button geklickt wird.""" - self.export_requested.emit() + # Prüfen ob ein Account ausgewählt ist + selected_rows = self.accounts_table.selectionModel().selectedRows() + if not selected_rows: + title = "Kein Konto ausgewählt" + text = "Bitte wählen Sie ein Konto zum Exportieren aus." + if self.language_manager: + title = self.language_manager.get_text( + "accounts_tab.no_selection_title", title + ) + text = self.language_manager.get_text( + "accounts_tab.no_export_selection_text", text + ) + from views.widgets.modern_message_box import show_warning + show_warning(self, title, text) + return + + # Account-ID holen + row = selected_rows[0].row() + account_id = int(self.accounts_table.item(row, 0).text()) + + # ProfileExportController verwenden + from controllers.profile_export_controller import ProfileExportController + export_controller = ProfileExportController(self.db_manager) + export_controller.export_account(self, account_id) def update_session_status(self, account_id: str, status: dict): """ diff --git a/views/widgets/account_card.py b/views/widgets/account_card.py index 70a1da9..f0481fe 100644 --- a/views/widgets/account_card.py +++ b/views/widgets/account_card.py @@ -85,7 +85,22 @@ class AccountCard(QFrame): show_error(self, "Export blockiert", error_msg) return - self.export_requested.emit(self.account_data) + # Neuer Feature 2: Profil-Export mit ProfileExportController + account_id = self.account_data.get("id") + if not account_id: + from views.widgets.modern_message_box import show_error + show_error(self, "Export fehlgeschlagen", "Account-ID nicht gefunden.") + return + + # Hol db_manager aus dem Parent + parent_window = self.window() + if hasattr(parent_window, 'db_manager'): + from controllers.profile_export_controller import ProfileExportController + export_controller = ProfileExportController(parent_window.db_manager) + export_controller.export_account(self, account_id) + else: + # Fallback: Altes Signal emittieren (für Kompatibilität) + self.export_requested.emit(self.account_data) def _on_delete_clicked(self): """Handler für Delete-Button"""