feat(dokuwiki): Implement single-page DokuWiki integration

- Simplified architecture: ONE central documentation page per equipment
- Wiki ID generation from equipment name (e.g., 'sabako-laser')
- Namespace structure: werkstatt:ausruestung:doku:{name}
- View pages by area: werkstatt:ausruestung:{area}:{name}
- Smart page protection: central doku only created once, not overwritten
- View pages update on each sync (shows current Odoo metadata)
- Template detection: distinguishes real content from DokuWiki auto-templates
- JSONB multilingual name field handling
- Include Plugin integration for embedded content display
- Automatic sync option via write() override
- Configuration via System Parameters (url, user, password)
- Settings page in General Settings for easy credential management
This commit is contained in:
Matthias Lotz 2025-12-13 21:50:19 +01:00
parent 5057985024
commit e53c4028c9
10 changed files with 715 additions and 0 deletions

5
.gitignore vendored
View File

@ -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/

View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from .hooks import post_init_hook

View File

@ -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,
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
DokuWiki System Parameters werden über post_init_hook initialisiert
Dadurch werden sie bei Install und Update korrekt gehandhabt
und User-Änderungen bleiben erhalten
-->
</odoo>

View File

@ -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()

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import dokuwiki_client
from . import maintenance_equipment
from . import res_config_settings

View File

@ -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}"

View File

@ -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

View File

@ -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")

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Equipment Form View Extension -->
<record id="maintenance_equipment_view_form_dokuwiki" model="ir.ui.view">
<field name="name">maintenance.equipment.form.dokuwiki</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
<field name="arch" type="xml">
<!-- Smart Button im Header -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_open_wiki"
type="object"
class="oe_stat_button"
icon="fa-book"
invisible="not wiki_page_url">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Wiki</span>
<span class="o_stat_text text-success" invisible="not wiki_synced">Synchronisiert</span>
<span class="o_stat_text text-warning" invisible="wiki_synced">Nicht synchronisiert</span>
</div>
</button>
</xpath>
<!-- Wiki-Felder in neuem Tab -->
<xpath expr="//notebook" position="inside">
<page string="Wiki" name="wiki">
<group>
<group string="Wiki-Synchronisation">
<field name="wiki_doku_id" readonly="1"/>
<field name="wiki_page_url" widget="url" readonly="1"/>
<field name="wiki_synced" readonly="1"/>
<field name="wiki_last_sync" readonly="1"/>
<field name="wiki_auto_sync"/>
</group>
<group string="Aktionen">
<button name="action_sync_to_dokuwiki"
string="Zum Wiki synchronisieren"
type="object"
class="btn-primary"
icon="fa-refresh"/>
<button name="action_open_wiki"
string="Wiki im Browser öffnen"
type="object"
class="btn-secondary"
icon="fa-external-link"
invisible="not wiki_page_url"/>
</group>
</group>
<group string="Hilfe">
<div class="alert alert-info" role="alert">
<h4 class="alert-heading">DokuWiki-Integration</h4>
<p>
Dieses Equipment ist mit DokuWiki verbunden. Die Synchronisation erstellt:
</p>
<ul>
<li><strong>Zentrale Dokumentation:</strong> werkstatt:ausruestung:doku:<field name="wiki_doku_id" readonly="1" nolabel="1"/></li>
<li><strong>Bereichs-Ansicht:</strong> Nach Bereich gruppiert</li>
</ul>
<hr/>
<p class="mb-0">
<strong>Automatische Synchronisation:</strong> Wenn aktiviert, werden Änderungen automatisch zum Wiki übertragen.
</p>
</div>
</group>
</page>
</xpath>
</field>
</record>
</odoo>