diff --git a/.gitignore b/.gitignore index e423600..53eafe5 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,8 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# DokuWiki API Test Script (enthält Credentials) +test_dokuwiki.py + +# Python Virtual Environment +venv/ diff --git a/open_workshop_dokuwiki/__init__.py b/open_workshop_dokuwiki/__init__.py new file mode 100644 index 0000000..ab4d630 --- /dev/null +++ b/open_workshop_dokuwiki/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import models +from .hooks import post_init_hook diff --git a/open_workshop_dokuwiki/__manifest__.py b/open_workshop_dokuwiki/__manifest__.py new file mode 100644 index 0000000..4625ddb --- /dev/null +++ b/open_workshop_dokuwiki/__manifest__.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Open Workshop - DokuWiki Integration', + 'version': '18.0.1.0.0', + 'category': 'Maintenance', + 'summary': 'Synchronisiert Equipment-Daten mit DokuWiki für Dokumentation', + 'description': """ +Open Workshop DokuWiki Integration +=================================== + +Dieses Modul synchronisiert Equipment-Daten aus Odoo mit einem DokuWiki System. + +Features: +--------- +* Automatische Erstellung von Wiki-Seiten für Equipment +* Multi-View Architektur (nach Bereich, nach Einsatzzweck) +* Zentrale Dokumentation mit Unterseiten +* Include-basierte Struktur für flexible Darstellung +* Smart Button "Wiki öffnen" im Equipment-Formular +* Automatische Synchronisation bei Equipment-Änderungen + +Architektur: +------------ +* Odoo generiert Hauptseiten (read-only für Users) +* Zentrale Dokumentation unter werkstatt:ausruestung:_doku:{equipment_id} +* Users editieren Unterseiten: _doku:{equipment_id}:bedienung, :wartung, :tipps +* Hauptseiten inkludieren zentrale Dokumentation via DokuWiki Include Plugin + +ACL: +---- +* Phase 1: Nur @odoo Gruppe hat Zugriff +* Phase 2: @werkstatt bekommt Lesezugriff auf Hauptseiten, Edit-Zugriff auf Doku-Seiten + """, + 'author': 'Open Workshop', + 'website': 'https://hobbyhimmel.de', + 'license': 'LGPL-3', + 'depends': [ + 'open_workshop_base', + 'maintenance', + ], + 'external_dependencies': { + 'python': ['dokuwiki'], + }, + 'data': [ + 'data/ir_config_parameter.xml', + 'views/maintenance_equipment_views.xml', + ], + 'post_init_hook': 'post_init_hook', + 'installable': True, + 'application': False, + 'auto_install': False, +} diff --git a/open_workshop_dokuwiki/data/ir_config_parameter.xml b/open_workshop_dokuwiki/data/ir_config_parameter.xml new file mode 100644 index 0000000..c5bdda6 --- /dev/null +++ b/open_workshop_dokuwiki/data/ir_config_parameter.xml @@ -0,0 +1,8 @@ + + + + diff --git a/open_workshop_dokuwiki/hooks.py b/open_workshop_dokuwiki/hooks.py new file mode 100644 index 0000000..56dba60 --- /dev/null +++ b/open_workshop_dokuwiki/hooks.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +def post_init_hook(env): + """ + Hook der nach der Modul-Installation ausgeführt wird. + Initialisiert die DokuWiki-Parameter falls sie nicht existieren. + """ + env['res.config.settings']._init_dokuwiki_parameters() diff --git a/open_workshop_dokuwiki/models/__init__.py b/open_workshop_dokuwiki/models/__init__.py new file mode 100644 index 0000000..6d09f64 --- /dev/null +++ b/open_workshop_dokuwiki/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +from . import dokuwiki_client +from . import maintenance_equipment +from . import res_config_settings diff --git a/open_workshop_dokuwiki/models/dokuwiki_client.py b/open_workshop_dokuwiki/models/dokuwiki_client.py new file mode 100644 index 0000000..b5b4954 --- /dev/null +++ b/open_workshop_dokuwiki/models/dokuwiki_client.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +import logging +from dokuwiki import DokuWiki, DokuWikiError +from odoo import api, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class DokuWikiClient(models.AbstractModel): + """ + Wrapper für DokuWiki XML-RPC API Client. + Managed die Verbindung zum DokuWiki und stellt Methoden für Seitenoperationen bereit. + """ + _name = 'dokuwiki.client' + _description = 'DokuWiki API Client' + + @api.model + def _get_wiki_connection(self): + """ + Erstellt eine DokuWiki-Verbindung mit Credentials aus System-Parametern. + + Returns: + DokuWiki: Verbundenes DokuWiki-Client-Objekt + + Raises: + UserError: Wenn Konfiguration fehlt oder Verbindung fehlschlägt + """ + IrConfigParameter = self.env['ir.config_parameter'].sudo() + + # Credentials aus System-Parametern holen + wiki_url = IrConfigParameter.get_param('dokuwiki.url') + wiki_user = IrConfigParameter.get_param('dokuwiki.user') + wiki_password = IrConfigParameter.get_param('dokuwiki.password') + + if not all([wiki_url, wiki_user, wiki_password]): + raise UserError( + "DokuWiki-Konfiguration unvollständig. " + "Bitte unter Einstellungen → Technisch → Parameter → Systemparameter konfigurieren:\n" + "- dokuwiki.url\n" + "- dokuwiki.user\n" + "- dokuwiki.password" + ) + + try: + wiki = DokuWiki(wiki_url, wiki_user, wiki_password) + # Test-Verbindung + version = wiki.version + _logger.info(f"DokuWiki-Verbindung erfolgreich: {version}") + return wiki + except DokuWikiError as e: + _logger.error(f"DokuWiki-Verbindung fehlgeschlagen: {e}") + raise UserError(f"Verbindung zu DokuWiki fehlgeschlagen: {e}") + + @api.model + def create_page(self, page_id, content, summary="Erstellt von Odoo"): + """ + Erstellt oder aktualisiert eine DokuWiki-Seite. + + Args: + page_id (str): DokuWiki Page-ID (z.B. "werkstatt:ausruestung:holzwerkstatt:maschine1") + content (str): Wiki-Markup-Inhalt der Seite + summary (str): Änderungskommentar für die Wiki-History + + Returns: + bool: True bei Erfolg + + Raises: + UserError: Bei API-Fehlern + """ + wiki = self._get_wiki_connection() + + try: + wiki.pages.set(page_id, content, summary=summary) + _logger.info(f"Wiki-Seite erstellt/aktualisiert: {page_id}") + return True + except DokuWikiError as e: + _logger.error(f"Fehler beim Erstellen der Wiki-Seite {page_id}: {e}") + raise UserError(f"Fehler beim Erstellen der Wiki-Seite: {e}") + + @api.model + def get_page(self, page_id): + """ + Liest den Inhalt einer DokuWiki-Seite. + + Args: + page_id (str): DokuWiki Page-ID + + Returns: + str: Wiki-Markup-Inhalt der Seite (leer wenn Seite nicht existiert) + """ + wiki = self._get_wiki_connection() + + try: + content = wiki.pages.get(page_id) + return content or "" + except DokuWikiError as e: + _logger.warning(f"Fehler beim Lesen der Wiki-Seite {page_id}: {e}") + return "" + + @api.model + def page_exists(self, page_id): + """ + Prüft ob eine DokuWiki-Seite existiert. + + Args: + page_id (str): DokuWiki Page-ID + + Returns: + bool: True wenn Seite existiert + """ + content = self.get_page(page_id) + return bool(content.strip()) + + @api.model + def delete_page(self, page_id, summary="Gelöscht von Odoo"): + """ + Löscht eine DokuWiki-Seite (setzt Inhalt auf leer). + + Args: + page_id (str): DokuWiki Page-ID + summary (str): Änderungskommentar für die Wiki-History + + Returns: + bool: True bei Erfolg + """ + wiki = self._get_wiki_connection() + + try: + wiki.pages.set(page_id, "", summary=summary) + _logger.info(f"Wiki-Seite gelöscht: {page_id}") + return True + except DokuWikiError as e: + _logger.error(f"Fehler beim Löschen der Wiki-Seite {page_id}: {e}") + raise UserError(f"Fehler beim Löschen der Wiki-Seite: {e}") + + @api.model + def list_pages(self, namespace): + """ + Listet alle Seiten in einem DokuWiki-Namespace auf. + + Args: + namespace (str): DokuWiki-Namespace (z.B. "werkstatt:ausruestung") + + Returns: + list: Liste von Dictionaries mit Page-Informationen + """ + wiki = self._get_wiki_connection() + + try: + pages = wiki.pages.list(namespace) + return pages + except DokuWikiError as e: + _logger.error(f"Fehler beim Auflisten des Namespace {namespace}: {e}") + return [] + + @api.model + def get_wiki_url(self, page_id): + """ + Generiert die vollständige URL zu einer DokuWiki-Seite. + + Args: + page_id (str): DokuWiki Page-ID + + Returns: + str: Vollständige URL zur Wiki-Seite + """ + IrConfigParameter = self.env['ir.config_parameter'].sudo() + wiki_url = IrConfigParameter.get_param('dokuwiki.url', '') + + if not wiki_url: + return "" + + # Entferne trailing slash falls vorhanden + wiki_url = wiki_url.rstrip('/') + + return f"{wiki_url}/doku.php?id={page_id}" diff --git a/open_workshop_dokuwiki/models/maintenance_equipment.py b/open_workshop_dokuwiki/models/maintenance_equipment.py new file mode 100644 index 0000000..370a334 --- /dev/null +++ b/open_workshop_dokuwiki/models/maintenance_equipment.py @@ -0,0 +1,354 @@ +# -*- coding: utf-8 -*- +import logging +import re +from datetime import datetime +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class MaintenanceEquipment(models.Model): + _inherit = 'maintenance.equipment' + + # Wiki-Felder + wiki_doku_id = fields.Char( + string='Wiki Dokumentations-ID', + readonly=True, + help='Eindeutige ID für die zentrale Wiki-Dokumentation (generiert aus Equipment-Namen, z.B. "formatkreissaege")' + ) + wiki_page_url = fields.Char( + string='Wiki-Seiten URL', + compute='_compute_wiki_page_url', + help='URL zur Wiki-Seite im Browser' + ) + wiki_synced = fields.Boolean( + string='Wiki synchronisiert', + default=False, + help='Gibt an, ob die Wiki-Seite aktuell ist' + ) + wiki_last_sync = fields.Datetime( + string='Letzte Wiki-Synchronisation', + readonly=True, + help='Zeitpunkt der letzten erfolgreichen Wiki-Synchronisation' + ) + wiki_auto_sync = fields.Boolean( + string='Automatische Wiki-Synchronisation', + default=True, + help='Bei Änderungen automatisch zum Wiki synchronisieren' + ) + + @api.depends('ows_area_id') + def _compute_wiki_page_url(self): + """ + Berechnet die URL zur Haupt-Wiki-Seite (nach Bereich). + """ + dokuwiki_client = self.env['dokuwiki.client'] + + for record in self: + wiki_doku_id = record._get_wiki_doku_id() + if wiki_doku_id and record.ows_area_id: + page_id = record._get_wiki_page_id_by_area() + record.wiki_page_url = dokuwiki_client.get_wiki_url(page_id) + else: + record.wiki_page_url = False + + def _get_wiki_doku_id(self): + """ + Generiert die Wiki-Dokumentations-ID aus dem Equipment-Namen. + Format: normalisierter_name (z.B. "formatkreissaege", "tischkreissaege") + + Returns: + str: Wiki-Doku-ID oder False wenn keine ID vorhanden + """ + self.ensure_one() + + # Verwende wiki_doku_id falls bereits gesetzt + if self.wiki_doku_id: + return self.wiki_doku_id + + # Generiere aus Equipment-Namen + if self.name: + return self._normalize_wiki_name(self.name) + + return False + + def _get_wiki_page_id_by_area(self): + """ + Generiert die Wiki-Page-ID für die Bereichs-Ansicht. + Format: werkstatt:ausruestung:{area_name}:{wiki_doku_id} + + Returns: + str: Page-ID + """ + self.ensure_one() + + if not self.ows_area_id: + return False + + # Area-Name normalisieren (Umlaute, Leerzeichen, Sonderzeichen) + area_name = self._normalize_wiki_name(self.ows_area_id.name) + + wiki_doku_id = self._get_wiki_doku_id() + return f"werkstatt:ausruestung:{area_name}:{wiki_doku_id}" + + def _get_wiki_doku_page_id(self): + """ + Generiert die Wiki-Page-ID für die zentrale Dokumentation. + Format: werkstatt:ausruestung:doku:{wiki_doku_id} + + Returns: + str: Page-ID der zentralen Doku + """ + self.ensure_one() + wiki_doku_id = self._get_wiki_doku_id() + return f"werkstatt:ausruestung:doku:{wiki_doku_id}" + + def _normalize_wiki_name(self, name): + """ + Normalisiert einen Namen für DokuWiki-Page-IDs. + - Umlaute ersetzen + - Leerzeichen und Sonderzeichen durch Bindestriche ersetzen + - Kleinbuchstaben + + Args: + name (str): Original-Name + + Returns: + str: Normalisierter Name + """ + if not name: + return "unnamed" + + # Umlaute ersetzen + replacements = { + 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', + 'Ä': 'ae', 'Ö': 'oe', 'Ü': 'ue', + 'ß': 'ss' + } + for old, new in replacements.items(): + name = name.replace(old, new) + + # Nur alphanumerische Zeichen und Bindestriche + name = re.sub(r'[^a-zA-Z0-9\-_]', '-', name) + + # Mehrfache Bindestriche durch einen ersetzen + name = re.sub(r'-+', '-', name) + + # Führende/Trailing Bindestriche entfernen + name = name.strip('-') + + # Kleinbuchstaben + name = name.lower() + + return name or "unnamed" + + def _generate_wiki_main_page_content(self, view_type='area'): + """ + Generiert den Wiki-Markup-Inhalt für eine Haupt-Ansichtsseite. + + Args: + view_type (str): 'area' oder 'purpose' + + Returns: + str: Wiki-Markup-Inhalt + """ + self.ensure_one() + + # Zentrale Doku-Seite einbinden + doku_page_id = self._get_wiki_doku_page_id() + + # Header je nach View-Typ + if view_type == 'area': + view_name = self.ows_area_id.name if self.ows_area_id else "Unbekannt" + view_label = "Bereich" + else: + # Später für Einsatzzweck + view_name = "TODO: Einsatzzweck" + view_label = "Einsatzzweck" + + content = f"""====== {self.name} ====== + +**{view_label}:** {view_name} +**Kategorie:** {self.category_id.name if self.category_id else 'Keine'} +**Seriennummer:** {self.serial_no or 'Keine'} + +===== Dokumentation ===== +{{{{page>{doku_page_id}&noheader}}}} + +---- +[[{doku_page_id}|✏️ Zentrale Dokumentation bearbeiten]] + +//Letzte Synchronisation: {datetime.now().strftime('%d.%m.%Y %H:%M')} von Odoo// +""" + return content + + def _generate_wiki_doku_page_content(self): + """ + Generiert den initialen Wiki-Markup-Inhalt für die zentrale Dokumentationsseite. + Einfacher Basis-Inhalt, den der Nutzer nach Belieben erweitern kann. + + Returns: + str: Wiki-Markup-Inhalt + """ + self.ensure_one() + + content = f"""====== {self.name} - Dokumentation ====== + +**Bereich:** {self.ows_area_id.name if self.ows_area_id else 'Nicht zugeordnet'} +**Kategorie:** {self.category_id.name if self.category_id else 'Keine'} +**Seriennummer:** {self.serial_no or 'Keine'} + +===== Beschreibung ===== + +Hier kann die Dokumentation für {self.name} geschrieben werden. + +**Mögliche Inhalte:** + * Bedienungsanleitung + * Wartungshinweise + * Sicherheitshinweise + * Tipps & Tricks + +//Hinweis: Diese Seite kann beliebig strukturiert werden. Unterseiten können bei Bedarf manuell angelegt werden.// + +---- +//Erstellt: {datetime.now().strftime('%d.%m.%Y %H:%M')} von Odoo// +""" + return content + + def sync_to_dokuwiki(self): + """ + Synchronisiert Equipment-Daten zum DokuWiki. + Erstellt/aktualisiert: + - wiki_doku_id beim ersten Sync (falls noch nicht gesetzt) + - Zentrale Dokumentationsseite (_doku:) - nur wenn sie noch nicht existiert + - Haupt-Ansichtsseite nach Bereich (wird immer aktualisiert) + - TODO: Später auch nach Einsatzzweck + + Returns: + bool: True bei Erfolg + """ + self.ensure_one() + + if not self.ows_area_id: + raise UserError("Bereich muss gesetzt sein für Wiki-Synchronisation!") + + if not self.name: + raise UserError("Equipment-Name muss gesetzt sein für Wiki-Synchronisation!") + + # wiki_doku_id beim ersten Sync setzen (aus Equipment-Namen generieren) + if not self.wiki_doku_id: + generated_id = self._normalize_wiki_name(self.name) + self.write({'wiki_doku_id': generated_id}) + _logger.info(f"Wiki-Doku-ID für {self.name} gesetzt: {generated_id}") + + dokuwiki_client = self.env['dokuwiki.client'] + + try: + # 1. Zentrale Dokumentationsseite erstellen (immer beim ersten Sync) + doku_page_id = self._get_wiki_doku_page_id() + + # Prüfen ob Seite mit echtem Inhalt existiert (nicht nur leere Template-Seite) + page_has_content = False + try: + existing_content = dokuwiki_client.get_page(doku_page_id) + # Prüfen ob es echten Inhalt hat (mehr als 100 Zeichen und nicht nur Template) + if existing_content and len(existing_content) > 100 and 'catlist' not in existing_content.lower(): + page_has_content = True + _logger.info(f"Zentrale Doku-Seite hat bereits Inhalt: {doku_page_id}") + except Exception as e: + _logger.debug(f"Seite existiert noch nicht: {e}") + + if not page_has_content: + doku_content = self._generate_wiki_doku_page_content() + dokuwiki_client.create_page( + doku_page_id, + doku_content, + f"Initial erstellt von Odoo: {self.name}" + ) + _logger.info(f"Zentrale Doku-Seite für {self.name} erstellt: {doku_page_id}") + else: + _logger.info(f"Zentrale Doku-Seite für {self.name} existiert bereits: {doku_page_id}") + + # 2. Haupt-Ansichtsseite nach Bereich erstellen/aktualisieren + area_page_id = self._get_wiki_page_id_by_area() + area_content = self._generate_wiki_main_page_content(view_type='area') + dokuwiki_client.create_page( + area_page_id, + area_content, + f"Synchronisiert von Odoo: {self.name}" + ) + + # TODO: Später auch nach Einsatzzweck synchronisieren + + # Sync-Status aktualisieren + self.write({ + 'wiki_synced': True, + 'wiki_last_sync': fields.Datetime.now(), + }) + + _logger.info(f"Equipment {self.name} erfolgreich zu DokuWiki synchronisiert") + + return True + + except Exception as e: + _logger.error(f"Fehler bei Wiki-Synchronisation für {self.name}: {e}") + self.write({'wiki_synced': False}) + raise UserError(f"Wiki-Synchronisation fehlgeschlagen: {e}") + + def action_sync_to_dokuwiki(self): + """ + Button-Action für manuelle Wiki-Synchronisation. + """ + for record in self: + record.sync_to_dokuwiki() + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Wiki-Synchronisation'), + 'message': _('Equipment wurde erfolgreich zum Wiki synchronisiert.'), + 'type': 'success', + 'sticky': False, + } + } + + def action_open_wiki(self): + """ + Button-Action zum Öffnen der Wiki-Seite im Browser. + """ + self.ensure_one() + + if not self.wiki_page_url: + raise UserError("Wiki-URL nicht verfügbar. Bitte erst zum Wiki synchronisieren.") + + return { + 'type': 'ir.actions.act_url', + 'url': self.wiki_page_url, + 'target': 'new', + } + + def write(self, vals): + """ + Override write um automatische Synchronisation zu triggern. + """ + result = super().write(vals) + + # Felder die eine Synchronisation auslösen + sync_fields = {'name', 'serial_no', 'area_id', 'category_id'} + + if sync_fields & set(vals.keys()): + # Nur synchronisieren wenn auto_sync aktiviert und bereits synced + for record in self: + if record.wiki_auto_sync and record.wiki_synced: + try: + record.sync_to_dokuwiki() + except Exception as e: + _logger.warning(f"Automatische Wiki-Sync fehlgeschlagen: {e}") + # Nicht abbrechen, nur loggen + else: + # Sync-Status zurücksetzen + record.write({'wiki_synced': False}) + + return result diff --git a/open_workshop_dokuwiki/models/res_config_settings.py b/open_workshop_dokuwiki/models/res_config_settings.py new file mode 100644 index 0000000..1c9edc2 --- /dev/null +++ b/open_workshop_dokuwiki/models/res_config_settings.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +import logging +from odoo import api, models + +_logger = logging.getLogger(__name__) + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + @api.model + def _init_dokuwiki_parameters(self): + """ + Initialisiert DokuWiki-Parameter beim Modul-Install falls sie nicht existieren. + Wird automatisch beim Laden des Moduls aufgerufen. + """ + IrConfigParameter = self.env['ir.config_parameter'].sudo() + + # Default-Parameter + defaults = { + 'dokuwiki.url': 'https://wiki.hobbyhimmel.de', + 'dokuwiki.user': 'odoo.odoo', + 'dokuwiki.password': 'CHANGE_ME', + } + + # Nur fehlende Parameter anlegen + for key, value in defaults.items(): + if not IrConfigParameter.get_param(key): + IrConfigParameter.set_param(key, value) + _logger.info(f"DokuWiki parameter '{key}' initialized with default value") diff --git a/open_workshop_dokuwiki/views/maintenance_equipment_views.xml b/open_workshop_dokuwiki/views/maintenance_equipment_views.xml new file mode 100644 index 0000000..49f4e5c --- /dev/null +++ b/open_workshop_dokuwiki/views/maintenance_equipment_views.xml @@ -0,0 +1,74 @@ + + + + + + maintenance.equipment.form.dokuwiki + maintenance.equipment + + + + + + + + + + + + + + + + + + + + +