Compare commits

..

No commits in common. "18.0" and "18.0-MultiModulOpenWorkshop" have entirely different histories.

195 changed files with 2242 additions and 17930 deletions

11
.gitignore vendored
View File

@ -1,7 +1,3 @@
# Test Logs
test_*.log
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
@ -19,7 +15,7 @@ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
#lib/ lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/
@ -163,8 +159,3 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# DokuWiki API Test Script (enthält Credentials)
test_dokuwiki.py
# Python Virtual Environment
venv/

View File

@ -1,46 +0,0 @@
# ✅ OpenWorkshop Test Checkliste
## 🔹 1. Migration
- [x] `migrate_existing_partners()` erzeugt zu jedem Partner genau einen `ows.user`.
- [x] `migrate_existing_partners()` übernimmt korrekt alte `vvow_*`-Felder.
- [x] `migrate_machine_access_from_old_fields()` erstellt korrekte Einträge in `ows.machine.access`.
- [x] `migrate_machine_access_from_old_fields()` übernimmt das Änderungsdatum aus `mail.tracking.value`.
## 🔹 2. Kontakte Backend
- [x] Beim Anlegen eines neuen Partners wird automatisch ein `ows.user` angelegt.
- [ ] Beim Speichern eines Partners wird überprüft, ob ein `ows.user` existiert, und ggf. angelegt.
- [x] Änderungen an Geburtstag, RFID, Haftung in Partner-Formular schreiben korrekt in `ows.user`.
- [x] Die Werte aus `ows.user` werden korrekt im Partnerformular angezeigt (via `compute`).
- [x] Das HTML-Widget mit Maschinenfreigaben (`machine_access_html`) wird korrekt dargestellt.
## 🔹 3. POS-Integration
- [x] Felder aus `ows.user` (Geburtstag, RFID etc.) erscheinen im POS-Kunden-Popup.
- [x] Maschinenfreigaben erscheinen im POS-Layout korrekt gruppiert nach Bereichen.
- [x] Farben der Maschinenbereiche werden korrekt aus `color_hex` übernommen.
## 🔹 4. Maschinenverwaltung
- [x] Maschinen-Formular zeigt Nutzungs- und Einweisungsprodukte korrekt an.
- [?] Drop-downs in den Produktlisten zeigen nur Produkte der richtigen Kategorie.
- [?] Neue Zuordnungen können direkt in den Tree-Ansichten editiert werden.
- [?] Filter greifen korrekt (Maschinennutzung / Einweisungen).
- [ ] In der Ansicht Wartung - Ausrüstung - Tab Offene Werkstatt (hobbyhimmel) wird unter Nutzungsprodukte und Einweisungsprodukte nur die Auswahl des Produkts angezeigt, eine Zuordnung zu einer Maschine ist automatisch die aktelle ausgewählte Maschine des Formulars.
## 🔹 5. Menüstruktur
- [ ] Menüeinträge "Nutzungsprodukte" und "Einweisungsprodukte" erscheinen unter Wartung > Konfiguration > Zuordnungen.
## 🔹 6. CSV/XML Demo-/Initialdaten
- [x] Maschinenbereiche (`ows.machine.area`) sind korrekt aus `data.xml` geladen.
- [x] Maschinen und ihre Produkt-Zuordnungen sind vollständig.
- [x] Kategorien und Produkte sind korrekt verknüpft (`product.category`, `product.product`).
## 🔹 7. Systemweite Konsistenz
- [x] Es gibt keine doppelten `ows.user`-Einträge.
- [bedingt] Kein Partner existiert ohne zugehörigen `ows.user`.
- [bedingt] `res.partner.ows_user_id` ist immer gefüllt.
## 🔹 8. Technische Qualität
- [ ] Kein `@api.depends('id')` mehr vorhanden.
- [ ] Commit wird in Migrationsfunktionen korrekt gesetzt (`self.env.cr.commit()`).
- [ ] Keine toten `vvow_*` Felder mehr im Modell (wenn auf ows.user umgestellt).
- [ ] post-init und pre-load Skripte laufen fehlerfrei bei Neuinstallation.

View File

@ -1,323 +0,0 @@
# Projektplan: MQTT-basierte IoT-Events in Odoo 18 Community (Simulation-first)
Stand: 2026-01-10
Ziel: **Odoo-18-Community (self-hosted)** soll **Geräte-Events** (Timer/Maschinenlaufzeit, Waage, Zustandswechsel) über **MQTT** aufnehmen und in Odoo als **saubere, nachvollziehbare Sessions/Events** verarbeiten.
Wichtig: **Hardware wird zunächst vollständig simuliert** (Software-Simulatoren), damit die Odoo-Schnittstelle stabil steht, bevor echte Geräte angebunden werden.
---
## 1. Ziele und Nicht-Ziele
### 1.1 Ziele (MVP)
- Einheitliche **Device-Event-Schnittstelle** in Odoo (REST/Webhook) inkl. Authentifizierung
- **Event-Log** in Odoo (persistente Rohereignisse + Normalisierung)
- **Session-Logik** für Timer/Maschinenlaufzeit (Start/Stop/Heartbeat)
- **Simulation** von:
- Maschinenzustand (running/idle/fault)
- Timer-Events (run_start/run_stop)
- Waage (stable_weight/tare)
- Reproduzierbare Tests (Unit/Integration) und eindeutige Fehlerdiagnostik (Logging)
### 1.2 Nicht-Ziele (für Phase 1)
- Keine Enterprise-IoT-Box, keine Enterprise-Module
- Keine echte Hardware-Treiberentwicklung in Phase 1
- Kein POS-Frontend-Live-Widget (optional erst in späterer Phase)
- Keine Abrechnungslogik/Preisregeln (kann vorbereitet, aber nicht umgesetzt werden)
---
## 2. Zielarchitektur (Simulation-first)
### 2.1 Komponenten
1. **MQTT Broker** (z. B. Mosquitto in Docker)
2. **Device Simulator(s)** (Python/Node)
- veröffentlicht MQTT Topics wie echte Geräte
3. **Gateway/Bridge (Software)**
- abonniert MQTT Topics
- validiert/normalisiert Payload
- sendet Events via HTTPS an Odoo (Webhook)
4. **Odoo Modul** `ows_iot_bridge`
- REST Controller `/ows/iot/event`
- Modelle für Devices, Events, Sessions
- Business-Regeln für Session-Start/Stop/Hysterese (softwareseitig)
5. Optional später: **Realtime-Feed** (WebSocket) für POS/Display
### 2.2 Datenfluss
1. Simulator publiziert MQTT → `hobbyhimmel/machines/<machine_id>/state`
2. Bridge konsumiert MQTT, mappt auf Event-Format
3. Bridge POSTet JSON an Odoo Endpoint
4. Odoo speichert Roh-Event + erzeugt ggf. Session-Updates
---
## 3. Schnittstelle zu Odoo (Kern des Projekts)
### 3.1 Authentifizierung
- Pro Device oder pro Bridge ein **API-Token** (Bearer)
- Header: `Authorization: Bearer <token>`
- Token in Odoo in `ows.iot.device` oder `ir.config_parameter` hinterlegt
- Optional: IP-Allowlist / Rate-Limit (in Reverse Proxy)
### 3.2 Endpoint (REST/Webhook)
- `POST /ows/iot/event`
- Content-Type: `application/json`
- Antwort:
- `200 OK` mit `{ "status": "ok", "event_id": "...", "session_id": "..." }`
- Fehler: `400` (Schema), `401/403` (Auth), `409` (Duplikat), `500` (Server)
### 3.3 Idempotenz / Duplikaterkennung
- Jedes Event enthält eine eindeutige `event_uid`
- Odoo legt Unique-Constraint auf `event_uid` (pro device)
- Wiederholte Events liefern `409 Conflict` oder `200 OK (duplicate=true)` (Designentscheidung)
---
## 4. Ereignis- und Topic-Standard (Versioniert)
### 4.1 MQTT Topics (v1)
- Maschinenzustand:
`hobbyhimmel/machines/<machine_id>/state`
- Maschinenereignisse:
`hobbyhimmel/machines/<machine_id>/event`
- Waage:
`hobbyhimmel/scales/<scale_id>/event`
- Geräte-Status (optional):
`hobbyhimmel/devices/<device_id>/status`
### 4.2 Gemeinsames JSON Event Schema (v1)
Pflichtfelder:
- `schema_version`: `"v1"`
- `event_uid`: UUID/string
- `ts`: ISO-8601 UTC (z. B. `"2026-01-10T12:34:56Z"`)
- `source`: `"simulator" | "device" | "gateway"`
- `device_id`: string
- `entity_type`: `"machine" | "scale" | "sensor"`
- `entity_id`: string (z. B. machine_id)
- `event_type`: string (siehe unten)
- `payload`: object
- `confidence`: `"high" | "medium" | "low"` (für Sensorfusion)
### 4.3 Event-Typen (v1)
**Maschine/Timer**
- `run_start` (Start einer Laufphase)
- `run_stop` (Ende einer Laufphase)
- `heartbeat` (optional, laufend während running)
- `state_changed` (idle/running/fault)
- `fault` (Fehler mit Code/Severity)
**Waage**
- `stable_weight` (stabiler Messwert)
- `weight` (laufend)
- `tare`
- `zero`
- `error`
---
## 5. Odoo Datenmodell (Vorschlag)
### 5.1 `ows.iot.device`
- `name`
- `device_id` (unique)
- `token_hash` (oder Token in separater Tabelle)
- `device_type` (machine/scale/...)
- `active`
- `last_seen`
- `notes`
### 5.2 `ows.iot.event`
- `event_uid` (unique)
- `device_id` (m2o -> device)
- `entity_type`, `entity_id`
- `event_type`
- `timestamp`
- `payload_json` (Text/JSON)
- `confidence`
- `processing_state` (new/processed/error)
- `session_id` (m2o optional)
### 5.3 `ows.machine.session` (Timer-Sessions)
- `machine_id` (Char oder m2o auf bestehendes Maschinenmodell)
- `start_ts`, `stop_ts`
- `duration_s` (computed)
- `state` (running/stopped/aborted)
- `origin` (sensor/manual/sim)
- `confidence_summary`
- `event_ids` (o2m)
> Hinweis: Wenn du bereits `ows.machine` aus deinem open_workshop nutzt, referenziert `machine_id` direkt dieses Modell.
---
## 6. Verarbeitungslogik (Phase 1: minimal, robust)
### 6.1 Session-Automat (State Machine)
- Eingang: Events `run_start`, `run_stop`, optional `heartbeat`
- Regeln:
- `run_start` erstellt neue Session, wenn keine läuft
- `run_start` während laufender Session → nur Log, keine neue Session
- `run_stop` schließt laufende Session
- Timeout-Regel: wenn `heartbeat` ausbleibt (z. B. 10 min) → Session `aborted`
### 6.2 Hysterese (Simulation als Stellvertreter für Sensorik)
In Simulation definierst du:
- Start, wenn „running“ mindestens **2s stabil**
- Stop, wenn „idle“ mindestens **15s stabil**
Diese Parameter als Odoo Systemparameter, z. B.:
- `ows_iot.start_debounce_s`
- `ows_iot.stop_debounce_s`
---
## 7. Simulation (Software statt Hardware)
### 7.1 Device Simulator: Maschine
- Konfigurierbar:
- Muster: random, fixed schedule, manuell per CLI
- Zustände: idle/running/fault
- Optional: „power_w“ und „vibration“ als Felder im Payload
- Publiziert MQTT in realistischen Intervallen
### 7.2 Device Simulator: Waage
- Modi:
- stream weight (mehrfach pro Sekunde)
- stable_weight nur auf „stabil“
- tare/zero Events per CLI
### 7.3 Bridge Simulator (MQTT → Odoo)
- Abonniert alle relevanten Topics
- Validiert Schema v1
- POSTet Events an Odoo
- Retry-Queue (lokal) bei Odoo-Ausfall
- Metriken/Logs:
- gesendete Events, Fehlerquoten, Latenz
---
## 8. Milestones & Deliverables
### M0 Repo/Grundgerüst (0.51 Tag)
**Deliverables**
- Git-Repo Struktur
- Docker Compose: mosquitto + simulator + bridge
- Odoo Modul Skeleton `ows_iot_bridge`
### M1 Odoo Endpoint & Modelle (12 Tage)
**Deliverables**
- `/ows/iot/event` Controller inkl. Token-Auth
- Modelle `ows.iot.device`, `ows.iot.event`
- Admin UI: Geräte anlegen, Token setzen, letzte Events anzeigen
### M2 Session-Engine (12 Tage)
**Deliverables**
- Modell `ows.machine.session`
- Event → Session Zuordnung
- Timeout/Abbruch-Logik
- Parameter für Debounce/Timeout
### M3 Simulatoren & End-to-End Test (12 Tage)
**Deliverables**
- Machine Simulator + Scale Simulator
- Bridge mit Retry-Queue
- End-to-End: Simulator → MQTT → Bridge → Odoo → Session entsteht
### M4 Qualität & Betrieb (1 Tag)
**Deliverables**
- Logging-Konzept (Odoo + Bridge)
- Tests (Unit: Controller/Auth, Integration: Session Engine)
- Doku: Event-Schema v1, Topics, Beispielpayloads
### Optional M5 POS/Anzeige (später)
- Realtime Anzeige im POS oder auf Display
- Live-Weight in POS
---
## 9. Testplan (Simulation-first)
### 9.1 Unit Tests (Odoo)
- Auth: gültiger Token → 200
- ungültig/fehlend → 401/403
- Schema-Validation → 400
- Idempotenz: duplicate `event_uid` → 409 oder duplicate-flag
### 9.2 Integration Tests
- Sequenz: start → heartbeat → stop → Session duration plausibel
- stop ohne start → kein Crash, Event loggt Fehlerzustand
- Timeout: start → keine heartbeat → Session aborted
### 9.3 Last-/Stabilitätstest (Simulator)
- 20 Maschinen, je 1 Event/s, 1h Lauf
- Ziel: Odoo bleibt stabil, Event-Insert performant, Queue läuft nicht über
---
## 10. Betriebs- und Sicherheitskonzept
- Token-Rotation möglich (neues Token, altes deaktivieren)
- Reverse Proxy:
- HTTPS
- Rate limiting auf `/ows/iot/event`
- optional Basic WAF Regeln
- Payload-Größenlimit (z. B. 1664 KB)
- Bridge persistiert Retry-Queue auf Disk
---
## 11. Offene Entscheidungen (später, nicht blocker für MVP)
- Event-Consumer in Odoo vs. Bridge-only Webhook (empfohlen: Webhook)
- Genaues Mapping zu bestehenden open_workshop Modellen (`ows.machine`, Nutzer/RFID)
- Abrechnung: Preisregeln, Rundungen, POS-Integration
- Realtime: Odoo Bus vs. eigener WebSocket Service
---
## 12. Nächste Schritte (konkret)
1. `ows_iot_bridge` Modul: Endpoint + Device/Event Modelle implementieren
2. Docker Compose: mosquitto + bridge + simulator bereitstellen
3. Simulatoren produzieren v1-Events; Bridge sendet an Odoo
4. Session-Engine anschließen und End-to-End testen
---
## Anhang A: Beispiel-Event (Maschine run_start)
```json
{
"schema_version": "v1",
"event_uid": "c2a7d6f1-7d8d-4a63-9a7f-4e6d7b0d9e2a",
"ts": "2026-01-10T12:34:56Z",
"source": "simulator",
"device_id": "esp32-fraser-01",
"entity_type": "machine",
"entity_id": "formatkreissaege",
"event_type": "run_start",
"confidence": "high",
"payload": {
"power_w": 820,
"vibration": 0.73,
"reason": "power_threshold"
}
}
```
## Anhang B: Beispiel-Event (Waage stable_weight)
```json
{
"schema_version": "v1",
"event_uid": "b6d0a2c5-9b1f-4a0b-8b19-7f2e1b8f3d11",
"ts": "2026-01-10T12:40:12Z",
"source": "simulator",
"device_id": "scale-sim-01",
"entity_type": "scale",
"entity_id": "waage-01",
"event_type": "stable_weight",
"confidence": "high",
"payload": {
"weight_g": 1532.4,
"unit": "g",
"stable_ms": 1200
}
}
```

View File

@ -68,8 +68,7 @@ open_workshop/
├── open_workshop/ # ✅ Alt-Modul (installable=False, Kompatibilität) ├── open_workshop/ # ✅ Alt-Modul (installable=False, Kompatibilität)
├── open_workshop_base/ # ✅ FERTIG Backend, _inherits maintenance.equipment ├── open_workshop_base/ # ✅ FERTIG Backend, _inherits maintenance.equipment
├── open_workshop_pos/ # ✅ FERTIG POS-Integration (JS, XML, UI) ├── open_workshop_pos/ # ✅ FERTIG POS-Integration (JS, XML, UI)
├── open_workshop_dokuwiki/ # ⏳ IN ENTWICKLUNG DokuWiki-Integration └── open_workshop_api/ # ⏳ GEPLANT REST API für WordPress
└── open_workshop_api/ # 💤 OPTIONAL REST API (zurückgestellt)
``` ```
**Entfernt:** open_workshop_maintenance (verworfen - Funktionalität direkt in open_workshop_base integriert) **Entfernt:** open_workshop_maintenance (verworfen - Funktionalität direkt in open_workshop_base integriert)
@ -82,7 +81,7 @@ open_workshop/
Enthält: Enthält:
- ✅ `ows.machine` mit _inherits zu `maintenance.equipment` (delegiert alle Felder von maintenance.equipment transparent an ows.machine) - ✅ `ows.machine` mit _inherits zu `maintenance.equipment`
- ✅ `ows.machine.area` (Bereiche mit JSONB-Namen) - ✅ `ows.machine.area` (Bereiche mit JSONB-Namen)
- ✅ `ows.machine.product` (Nutzungsprodukte) - ✅ `ows.machine.product` (Nutzungsprodukte)
- ✅ `ows.machine.training` (Einweisungsprodukte) - ✅ `ows.machine.training` (Einweisungsprodukte)
@ -141,282 +140,87 @@ Vollständig separiertes POS-Modul mit:
--- ---
# 5. Modul: open_workshop_dokuwiki ⏳ IN ENTWICKLUNG # 5. Modul: open_workshop_api ⏳ GEPLANT
**Status: IN ENTWICKLUNG (Branch: feature/dokuwiki-integration)** **Status: NOCH NICHT IMPLEMENTIERT**
Dieses Modul integriert **DokuWiki** als Dokumentationssystem für Equipment. Dieses Modul stellt die **REST API** bereit, über die WordPress öffentlich verfügbare Daten abholt.
## 5.1 Ziele ## 5.1 Ziele
- Automatische Wiki-Seiten für jedes Equipment - Minimaler externer Zugriff (nur API-Endpunkte)
- **Invertierte Include-Architektur**: - JSON-Ausgabe für WordPress
- Odoo generiert geschützte Hauptseite mit Equipment-Daten - Keine Odoo-Website erforderlich
- Hauptseite inkludiert User-Dokumentation via Include Plugin - Odoo selbst bleibt **nicht öffentlich erreichbar**
- User editieren separate `:doku` Unterseite
- **Klare Datentrennung**:
- Odoo = Master für strukturierte Daten (Hauptseite, read-only für User)
- User = Master für Freitext-Dokumentation (`:doku` Unterseite, editierbar)
- **Generisches ACL-System**: Eine Regel für alle Equipment
- Versionierung durch DokuWiki eingebaut
## 5.2 Architektur ## 5.2 API-Endpunkte
``` Pflicht:
Odoo (maintenance.equipment)
XML-RPC Client
DokuWiki API (wiki.putPage)
werkstatt:ausruestung:bereich:maschinenname
```
## 5.3 DokuWiki-Seitenstruktur
**Zwei-Seiten-System pro Equipment:**
```
werkstatt:ausruestung:holzwerkstatt:formatkreissaege ← Hauptseite (Odoo generiert)
werkstatt:ausruestung:holzwerkstatt:formatkreissaege:doku ← Doku-Seite (User editieren)
werkstatt:ausruestung:lasercutter:epilog_fusion_m2 ← Hauptseite
werkstatt:ausruestung:lasercutter:epilog_fusion_m2:doku ← Doku-Seite
```
**Namespace-Regeln:**
- **Hauptnamespace**: `werkstatt:ausruestung:`
- **Hauptseite**: `werkstatt:ausruestung:bereich:maschinenname`
- Bereich: ows_area_id (normalisiert, lowercase)
- Maschinenname: Equipment name (normalisiert, ohne Sonderzeichen)
- **Komplett Odoo-generiert**, User können nicht editieren
- **Doku-Seite**: `werkstatt:ausruestung:bereich:maschinenname:doku`
- **User editieren hier frei** (Anleitungen, Tipps, Bilder)
- Wird via Include in Hauptseite eingebunden
- User können weitere Unterseiten anlegen (`:doku:bilder`, `:doku:videos`)
## 5.4 Hauptseiten-Template (Odoo-generiert)
Odoo erstellt die komplette Hauptseite inklusive Include für User-Dokumentation:
```wiki
====== Formatkreissäge ======
===== Grunddaten =====
* **Seriennummer:** FKS-2024-001
* **Bereich:** [[werkstatt:ausruestung:holzwerkstatt|Holzwerkstatt]]
* **Kategorie:** 🔴 Kategorie 3 (Einweisung erforderlich)
* **Standort:** Schrank A, Regal 2
* **Hersteller:** Bosch
===== Sicherheit =====
* **Einweisungspflichtig:** Ja
* **Nutzungsprodukte:**
* Holzwerkstatt Nutzung (5€/Tag)
* **Einweisungsprodukte:**
* Einweisung Formatkreissäge (einmalig 25€)
===== Wartung =====
* **Letzte Wartung:** 01.12.2025
* **Nächste Wartung:** 01.06.2026
* **Wartungshistorie:** [[odoo:maintenance:request:42|Alle Wartungsaufträge]]
===== Dokumentation & Anleitungen =====
{{page>.:formatkreissaege:doku&noheader}}
[[.:formatkreissaege:doku|✏️ Dokumentation bearbeiten]]
```
**User-Doku-Seite** (`:doku`, User editieren hier):
```wiki
==== Bedienung ====
1. Hauptschalter einschalten
2. Sägeblatt-Drehzahl einstellen
3. Anschlag positionieren
...
==== Tipps & Tricks ====
- Bei Hartholz: Vorschub langsamer
- Sägeblatt nach 50h wechseln
==== Bilder ====
{{.:doku:bild1.jpg?300}}
```
## 5.5 Technische Umsetzung
**Python-Bibliothek:**
- `dokuwiki` (Python XML-RPC Client für DokuWiki)
- Installation: `pip install dokuwiki`
**API-Methoden:**
```python
# DokuWiki XML-RPC API
wiki.putPage(page_id, content, summary, minor) # Seite erstellen/aktualisieren
wiki.getPage(page_id) # Seite lesen
wiki.getAllPages() # Alle Seiten auflisten
```
**Odoo-Integration:**
- Neues Model: `ows.dokuwiki.config` (DokuWiki-URL, Credentials)
- Methode auf `maintenance.equipment`: `sync_to_dokuwiki()`
- Automated Action: Bei Equipment-Änderung → Wiki aktualisieren
- Smart Button: "Wiki öffnen" → Link zur DokuWiki-Seite
**Sync-Algorithmus (vereinfacht!):**
```python
def sync_to_dokuwiki(self):
"""Generiert Hauptseite mit Include für User-Doku"""
# 1. Generiere Hauptseite komplett (Odoo hat volle Kontrolle)
content = self._generate_equipment_page()
# 2. Füge Include für User-Doku hinzu
wiki_id = self._get_normalized_name()
content += "\n\n===== Dokumentation & Anleitungen =====\n"
content += f"{{{{page>.:{{wiki_id}}:doku&noheader}}}}\n\n"
content += f"[[.:{{wiki_id}}:doku|✏️ Dokumentation bearbeiten]]\n"
# 3. Schreibe Hauptseite (überschreibt komplett)
main_page = f"werkstatt:ausruestung:{self.ows_area_id.wiki_name}:{wiki_id}"
dokuwiki_client.putPage(main_page, content, "Odoo Auto-Sync")
# 4. Erstelle leere Doku-Seite falls nicht vorhanden
doku_page = f"{main_page}:doku"
if not dokuwiki_client.getPage(doku_page):
initial_doku = "==== Bedienung ====\n\n==== Tipps & Tricks ====\n"
dokuwiki_client.putPage(doku_page, initial_doku, "Initial")
def _generate_equipment_page(self):
"""Generiert komplette Hauptseite mit Equipment-Daten"""
return f"""====== {self.name} ======
===== Grunddaten =====
* **Seriennummer:** {self.serial_no or 'N/A'}
* **Bereich:** [[werkstatt:ausruestung:{self.ows_area_id.wiki_name}|{self.ows_area_id.name}]]
* **Kategorie:** {self._get_category_icon()} {self._get_category_label()}
* **Standort:** {self.location or 'N/A'}
* **Hersteller:** {self.partner_id.name or 'N/A'}
===== Sicherheit =====
* **Einweisungspflichtig:** {self._is_training_required()}
* **Nutzungsprodukte:**
{self._format_product_list(self.ows_product_ids)}
* **Einweisungsprodukte:**
{self._format_product_list(self.ows_training_ids)}
===== Wartung =====
* **Letzte Wartung:** {self.last_maintenance_date or 'N/A'}
* **Nächste Wartung:** {self.next_action_date or 'N/A'}
"""
```
**Vorteile dieser Architektur:**
- ✅ Keine Sync-Marker nötig (Hauptseite wird komplett überschrieben)
- ✅ User können Odoo-Daten nicht versehentlich löschen
- ✅ User-Content ist komplett geschützt (separate Seite)
- ✅ Include funktioniert transparent (User sehen alles auf einer Seite)
- ✅ Wartungsfreies ACL-System
## 5.6 ACL-System (Generisch & Wartungsfrei)
**Phase 1: Nur für Odoo sichtbar (Initial-Setup)**
```
# Komplett gesperrt für alle außer Odoo
werkstatt:ausruestung:* @odoo 8 # Voller Zugriff
werkstatt:ausruestung:* @ALL 0 # Kein Zugriff
```
**Phase 2: Werkstatt-Betreiber erhalten Zugriff (später)**
```
# User-Doku-Seiten editierbar (spezifischere Regel zuerst!)
werkstatt:ausruestung:*:doku @werkstatt 2 # Editieren erlaubt
werkstatt:ausruestung:*:doku:* @werkstatt 2 # Auch Unterseiten (Bilder, Videos, etc.)
# Hauptseiten nur lesen
werkstatt:ausruestung:* @werkstatt 1 # Nur Lesezugriff
werkstatt:ausruestung:* @ALL 1 # Alle können lesen
# Odoo hat vollen Zugriff überall
werkstatt:ausruestung:* @odoo 8 # Voller Zugriff
werkstatt:ausruestung:*:doku @odoo 8 # Auch auf Doku-Seiten (für Initial-Erstellung)
**Wie es funktioniert:**
| Seite | User @werkstatt | User @odoo | Ergebnis |
|-------|-----------------|------------|----------|
| `werkstatt:ausruestung:holzwerkstatt:formatkreissaege` | Matched: `werkstatt:ausruestung:*` → 1 | Matched: `werkstatt:ausruestung:*` → 8 | User: Lesen, Odoo: Schreiben |
| `werkstatt:ausruestung:holzwerkstatt:formatkreissaege:doku` | Matched: `werkstatt:ausruestung:*:doku` → 2 | Matched: `werkstatt:ausruestung:*:doku` → 8 | User: Editieren, Odoo: Voll |
- `16` = Admin (alle Rechte)
**Vorteile:**
- ✅ **Komplett generisch**: Gilt automatisch für alle Equipment
- ✅ **Wartungsfrei**: Kein manuelles ACL-Setup pro Equipment
- ✅ **Phasenweise Freischaltung**: Erst Odoo-Only, dann schrittweise für Werkstatt
- ✅ **Selbsterklärend**: User sehen sofort wo sie editieren können
- ✅ **Skalierbar**: Funktioniert auch für 1000+ Equipment-Seiten
| `ausruestung:holzwerkstatt:formatkreissaege` | Matched: `ausruestung:*` → 1 | Matched: `ausruestung:*` → 8 | User: Lesen, Odoo: Schreiben |
| `ausruestung:holzwerkstatt:formatkreissaege:doku` | Matched: `ausruestung:*:doku` → 2 | Matched: `ausruestung:*:doku` → 8 | User: Editieren, Odoo: Voll |
**Vorteile:**
- ✅ **Komplett generisch**: Gilt automatisch für alle Equipment
- ✅ **Wartungsfrei**: Kein manuelles ACL-Setup pro Equipment
- ✅ **Selbsterklärend**: User sehen sofort wo sie editieren können
- ✅ **Skalierbar**: Funktioniert auch für 1000+ Equipment-Seiten
**Odoo-Seite:**
- Credentials in `ir.config_parameter`:
- `dokuwiki.url` (z.B. `https://wiki.hobbyhimmel.de`)
- `dokuwiki.user` (z.B. `odoo`)
- `dokuwiki.password` (verschlüsselt gespeichert)
- XML-RPC über HTTPS
- Logging aller Wiki-Operationen in `mail.message`
## 5.7 Vorteile DokuWiki
**Leichtgewichtig** File-based, keine separate Datenbank
**Versionierung** Eingebautes History-System
**Wiki-Syntax** Perfekt für strukturierte Dokumentation
**ACL-System** Granulare Berechtigungen
**Suchfunktion** Volltext-Suche eingebaut
**Plugins** Erweiterbar (Markdown, Galleries, etc.)
**Open Source** Keine Lizenzkosten
---
# 6. Modul: open_workshop_api ⏳ GEPLANT
**Status: ZURÜCKGESTELLT (DokuWiki hat Priorität)**
REST API für externe Systeme (falls später WordPress/App-Integration gewünscht).
## 6.1 Potenzielle Endpunkte
``` ```
GET /api/v1/machines GET /api/v1/machines
GET /api/v1/machine/<id> GET /api/v1/machine/<id>
GET /api/v1/areas
``` ```
**Hinweis:** Mit DokuWiki kann die öffentliche Dokumentation direkt über das Wiki erfolgen. Empfohlen:
Eine separate REST API ist optional und wird nur bei Bedarf implementiert.
## 6.1 Warum DokuWiki statt WordPress? ```
GET /api/v1/areas
GET /api/v1/files/<attachment_id>
GET /api/v1/events (später für Kurse)
```
- **Einfacher**: Keine separate Datenbank, file-based ## 5.3 JSON Beispiel
- **Wiki-Syntax**: Perfekt für technische Dokumentation
- **Versionierung**: Eingebautes History-System
- **ACL**: Feinere Berechtigungskontrolle
- **Leichtgewichtig**: Weniger Overhead als WordPress
- **Integration**: XML-RPC API für bidirektionale Sync
## 6.2 Zukunft: WordPress optional ```json
{
"id": 12,
"name": "Formatkreissäge",
"status": "available",
"area": "Holzwerkstatt",
"image_url": "https://odoo.example.org/api/v1/file/29",
"manual_url": "https://odoo.example.org/api/v1/file/55",
"training_required": true
}
```
Falls später gewünscht, kann WordPress **zusätzlich** die öffentliche Maschinenliste anzeigen: ## 5.4 Sicherheitsmechanismen
- DokuWiki für **Dokumentation** (intern/extern lesbar)
- WordPress für **Präsentation** (extern mit Bildern, SEO)
- Beide Systeme greifen auf Odoo-Daten zu
Aktuell hat DokuWiki-Integration **Priorität**. - CORS nur für WordPress-Domain erlauben
- Optionaler Token im Header (`Authorization: Bearer <token>`)
- Kein Zugriff auf `/web`, `/api/web`, `/pos`
- Rate Limiting via Reverse Proxy
---
# 6. WordPress Integration ⏳ GEPLANT
**Status: VORBEREITET (API noch nicht implementiert)**
WordPress bleibt die öffentliche Website.
## 6.1 Warum?
- Sehr hohe Performance (CDN, Caching)
- SEO-optimiert
- Keine Belastung deiner Internetleitung
- Keine Sicherheitsrisiken im internen Odoo
- Keine Lizenzkosten
## 6.2 WordPress Plugin (bereitgestellt)
Das Plugin:
- Ruft Odoo-API ab
- Rendert Maschinenliste via Shortcode
- Konsumiert JSON-Daten
- Unterstützt Token-Auth
Beispiel:
```
[openworkshop_machines]
```
--- ---
@ -425,58 +229,46 @@ Aktuell hat DokuWiki-Integration **Priorität**.
``` ```
Internet Internet
| |
DokuWiki (Dokumentation) WordPress (Frontend, CDN)
WordPress (optional: Präsentation)
| |
XML-RPC / REST API fetch JSON via REST API
| |
---------------------------- ----------------------------
| Odoo Backend (LAN/VPN) | API Gateway (open_workshop_api)
| open_workshop_dokuwiki | Exposed only: /api/v1/*
| maintenance.equipment
---------------------------- ----------------------------
POS, Benutzer, Einweisungen |
Odoo Backend (LAN/VPN)
Maschinen, POS, Benutzer, Einweisungen
``` ```
**Fokus:** Equipment-Dokumentation über DokuWiki, WordPress später optional. **Kein direkter Zugriff auf Odoo-Weboberfläche!**
--- ---
# 8. Sicherheit # 8. Sicherheit
**DokuWiki ACL:** - Reverse Proxy (Traefik/Nginx) muss alle Odoo-Backoffice-URLS sperren
- Namespace `ausruestung:*` lesbar für alle (@ALL = 1) - Nur `/api/v1/*` darf öffentlich sein
- Namespace `ausruestung:*` schreibbar nur für Odoo (@odoo = 8)
- Manuelle Edits durch Admins möglich
**Odoo:**
- DokuWiki-Credentials in ir.config_parameter
- XML-RPC über HTTPS
- Logging aller Wiki-Operationen
- Backup-System für Wiki-Dateien
**Optional (REST API):**
- Reverse Proxy sperrt alle Odoo-URLs außer `/api/v1/*`
- HTTPS erzwingen - HTTPS erzwingen
- Rate-Limit + Firewall - Rate-Limit + Firewall-Regeln
- Token-Auth - Token-Authentifizierung optional zusätzlich einsetzbar
--- ---
# 9. Vorteile dieser Architektur # 9. Vorteile dieser Architektur
**DokuWiki für Dokumentation** Wiki-Syntax perfekt für Anleitungen **WordPress bleibt ultraschnell** (1000 Besucher/Tag problemlos)
**Versionierung eingebaut** History für alle Änderungen
**Odoo bleibt sicher** hinter Firewall **Odoo bleibt sicher** hinter Firewall
**Keine Benutzerkosten** alle Ehrenamtlichen können intern mitarbeiten **Keine Benutzerkosten** → alle Ehrenamtlichen können intern mitarbeiten
**Modular, wartbar, zukunftssicher** **Modular, wartbar, zukunftssicher**
**Intelligente Sync** Nur strukturierte Daten werden überschrieben, Freitext bleibt erhalten **API erlaubt feine Steuerung**, welche Daten öffentlich sind
**Öffentlich lesbar** Anleitungen für alle verfügbar **Keine Last auf DSL-Leitung** (WordPress hostet extern)
**WordPress optional** Kann später für Marketing/SEO ergänzt werden **Maintenance-Integration** ohne Extra-Modul direkt in Base
--- ---
# 10. Implementierungsstand (13.12.2025) # 10. Implementierungsstand (08.12.2025)
| Schritt | Status | Details | | Schritt | Status | Details |
|---------|--------|---------| |---------|--------|---------|
@ -484,55 +276,45 @@ Aktuell hat DokuWiki-Integration **Priorität**.
| 2. open_workshop_pos | ✅ **FERTIG** | POS-Integration komplett | | 2. open_workshop_pos | ✅ **FERTIG** | POS-Integration komplett |
| 3. ~~open_workshop_maintenance~~ | ❌ **VERWORFEN** | Direkt in Base integriert | | 3. ~~open_workshop_maintenance~~ | ❌ **VERWORFEN** | Direkt in Base integriert |
| 4. Maintenance Integration | ✅ **FERTIG** | _inherits Pattern implementiert | | 4. Maintenance Integration | ✅ **FERTIG** | _inherits Pattern implementiert |
| 5. Equipment-View Integration | ✅ **FERTIG** | Related fields, Menu-Migration | | 5. Migration Workflow | ✅ **FERTIG** | SQL + Python, CI/CD integriert |
| 6. Migration Workflow | ✅ **FERTIG** | SQL + Python, CI/CD integriert | | 6. open_workshop_api | ⏳ **GEPLANT** | REST API für WordPress |
| 7. open_workshop_dokuwiki | ⏳ **IN ENTWICKLUNG** | Branch: feature/dokuwiki-integration | | 7. WordPress Plugin | ⏳ **GEPLANT** | Frontend-Integration |
| 8. open_workshop_api | 💤 **OPTIONAL** | Zurückgestellt, bei Bedarf |
--- ---
# 11. Nächste Schritte # 11. Nächste Schritte
1. **open_workshop_dokuwiki** entwickeln 1. **open_workshop_api** entwickeln
- DokuWiki XML-RPC Client implementieren - REST Controller implementieren
- Template-System für Equipment-Seiten - JSON-Serializer für machines/areas
- Sync-Mechanismus (Odoo → Wiki) - CORS und Security konfigurieren
- Smart Button "Wiki öffnen" - Token-Auth optional hinzufügen
- ACL-Konfiguration dokumentieren
2. **DokuWiki aufsetzen** 2. **WordPress Plugin** anpassen
- Installation auf Server - API-Endpunkte konfigurieren
- Namespace `ausruestung:` anlegen - Shortcode-Rendering
- ACL konfigurieren (@odoo Gruppe) - Caching implementieren
- XML-RPC API aktivieren
3. **Testing** 3. **Testing & Deployment**
- Equipment-Seiten automatisch generieren - API-Tests schreiben
- Sync bei Änderungen testen - Reverse Proxy konfigurieren
- ACL-Rechte validieren - Performance-Tests
- Performance testen - Go-Live vorbereiten
4. **Optional: WordPress Plugin**
- Falls öffentliche Präsentation gewünscht
- REST API dann implementieren
- Caching-Layer hinzufügen
--- ---
# 12. Endfazit # 12. Endfazit
Diese Architektur vereint das Beste aus beiden Welten: Diese Architektur hat sich bewährt:
- ✅ **Technisch korrekt** _inherits Pattern statt separatem Modul - ✅ **Technisch korrekt** _inherits Pattern statt separatem Modul
- ✅ **Performant** Maintenance.equipment als Single Source of Truth - ✅ **Performant** Maintenance.equipment als Single Source of Truth
- ✅ **Dokumentiert** DokuWiki für strukturierte Anleitungen - ✅ **Sicher** API-Layer trennt intern/extern
- ✅ **Versioniert** History-System eingebaut - ✅ **Kostenoptimiert** Keine Odoo.sh Lizenzen nötig
- ✅ **Öffentlich zugänglich** Wiki lesbar für alle
- ✅ **Kostenoptimiert** Keine zusätzlichen Lizenzen
- ✅ **Langfristig erweiterbar** Modularer Aufbau - ✅ **Langfristig erweiterbar** Modularer Aufbau
- ✅ **Produktiv im Einsatz** Migration erfolgreich abgeschlossen - ✅ **Produktiv im Einsatz** Migration erfolgreich abgeschlossen
**DokuWiki-Integration ist der nächste logische Schritt** für bessere Equipment-Dokumentation. Die API ist **zentrales Zukunftsmodul** für die WordPress-Integration.
**Letztes Update: 13.12.2025** **Letztes Update: 08.12.2025**

415
README.md
View File

@ -1,415 +0,0 @@
# Open Workshop
**POS-basiertes Maschinenfreigabe- und Werkstattverwaltungssystem für Odoo**
Open Workshop ist eine modulare Erweiterung des Odoo POS-Systems zur Verwaltung von einweisungspflichtigen Maschinen und Geräten in Werkstätten, Makerspaces und FabLabs. Das System ermöglicht die Verwaltung von Maschinenfreigaben, Einweisungen und Equipment-Dokumentation.
---
## Hauptfunktionen
- **Maschinenfreigaben**: Verwaltung von kundenspezifischen Freigaben für einweisungspflichtige Geräte
- **Sicherheitskategorien**: Dreistufiges Kategoriesystem (grün/gelb/rot) für unterschiedliche Einweisungspflichten
- **POS-Integration**: Verkauf von Einweisungen und Anzeige von Maschinenfreigaben direkt am Point of Sale
- **Equipment-Verwaltung**: Vollständige Integration mit dem OCA Maintenance-Modul
- **Dokumentation**: Automatische Synchronisation von Equipment-Daten mit DokuWiki
- **Mitarbeiter-Display**: Dynamische Anzeige von Mitarbeiterdaten im POS Customer Display
---
## Module
### open_workshop_base
**Kernmodul** - Stellt die Basis-Funktionalität für das gesamte System bereit.
**Hauptfunktionen:**
- Maschinenmodelle mit Sicherheitskategorien (grün/gelb/rot)
- Bereiche (z.B. Holz, Metall, FabLab)
- Freigabeverwaltung (wer darf welche Maschine nutzen)
- Produktverknüpfungen für Einweisungen und Nutzung
- Integration mit OCA maintenance.equipment als Single Source of Truth
**Datenmodelle:**
- `ows.user` - Benutzerdaten (RFID, Geburtstag, Sicherheitsunterweisung)
- `ows.machine.area` - Maschinenbereiche mit Farbkodierung
- `ows.machine` - Maschinen (nutzt _inherits Pattern mit maintenance.equipment)
- `ows.machine.access` - Freigaben (Partner ↔ Maschine)
- `ows.machine.product` - Nutzungsprodukte für Maschinen
- `ows.machine.training` - Einweisungsprodukte für Maschinen
**Abhängigkeiten:**
- OCA `maintenance` (Basis-Equipment-Verwaltung)
- OCA `maintenance_equipment_status` (Equipment-Statusverwaltung)
```bash
git clone https://github.com/OCA/maintenance.git
cd maintenance
git reset --hard 5510275e
```
---
### open_workshop_pos
**POS-Integration** - Erweitert den Odoo Point of Sale um Maschinenfreigabe-Funktionalität.
**Hauptfunktionen:**
- **Machine Access List**: Übersichtsanzeige aller verfügbaren Maschinen welche Einweisungspflichtig sind, mit Freigabestatus für den aktuellen Kunden (roter Punkt)
- **Customer Sidebar**: Kundenspezifische Maschinenfreigaben am POS
- **POS Sidebar**: Maschinenauswahl und Statusanzeige
- **Automatische Freigabenvergabe**: Beim Verkauf eines Einweisungsprodukts wird dem Kunden automatisch die Freigabe für das zugehörige Gerät in der Datenbank erteilt
**Verwendungszweck:**
Verkauf von Maschineneinweisungen und Anzeige bestehender Freigaben direkt am Kassensystem. Ermöglicht schnelle Prüfung, welcher Kunde auf welche einweisungspflichtigen Geräte zugreifen darf.
**Abhängigkeiten:**
- `open_workshop_base`
- `point_of_sale`
---
### open_workshop_pos_customer_display
**Mitarbeiter-Display** - Zeigt dynamisches Mitarbeiter-Namensschild im POS Customer Display.
**Hauptfunktionen:**
- Anzeige von Mitarbeiterfoto (generiert durch open_workshop_employee_imagegenerator)
- Darstellung von Name und Schwerpunkten (job_focus)
- Automatische Aktualisierung bei Kassiererwechsel
- Responsive Design mit Fallback auf Company-Logo
**Verwendungszweck:**
Darstellung der Mitarbeitenden am Kundendisplay, verbessert die Kundeninteraktion und Transparenz.
**Abhängigkeiten:**
- `point_of_sale`
- `hr`
---
### open_workshop_dokuwiki
**DokuWiki-Integration** - Synchronisiert Equipment-Daten aus Odoo mit einem DokuWiki-System.
**Hauptfunktionen:**
- Automatische Erstellung von Wiki-Seiten für Equipment
- Übersichtstabelle aller Equipment (DataTables mit Sortierung/Filterung)
- Status-Seiten für Include-Plugin (nur von Odoo generiert, read-only)
- Smart Button "Wiki öffnen" im Equipment-Formular
- Automatische Synchronisation bei Equipment-Änderungen
**Architektur-Prinzip:**
- Odoo generiert **NUR** `odoo-status/` Seiten (maschinengeneriert, read-only)
- Benutzer erstellen eigene Dokumentation in `{bereich}/` Namespaces
- Einbindung der Odoo-Daten via DokuWiki Include-Plugin
**Verwendungszweck:**
Zentrale Dokumentation aller Geräte mit automatisch aktualisiertem Status aus Odoo, kombiniert mit benutzergenerierten Anleitungen und Wartungshinweisen.
**Abhängigkeiten:**
- `open_workshop_base`
- `maintenance`
- `maintenance_equipment_status`
---
### 🎨 open_workshop_employee_imagegenerator
**Mitarbeiterfoto-Generator** - Upload und Zuschnitt von Mitarbeiterfotos direkt in Odoo.
**Hauptfunktionen:**
- Upload und Zuschnitt von Fotos auf festes Format (369x492 Pixel)
- Hinzufügen von Schwerpunktbereichen (job_focus)
- Integrierter Cropper.js für professionelle Bildbearbeitung
- Fester Crop-Frame (nur Bild bewegt sich, nicht der Rahmen)
- Integration im Employee-Formular über Button "Namensschild erstellen"
**Verwendungszweck:**
Erstellung von professionellen Mitarbeiterfotos für das POS Customer Display. Vereinfacht den Workflow durch direkten Upload in Odoo ohne externe Bildbearbeitung.
**Abhängigkeiten:**
- `hr`
- `web`
---
## Architektur & Datenfluss
### Datenmodell-Übersicht
```
┌─────────────────────────────────────────────────────────────┐
│ OCA maintenance.equipment │
│ (Single Source of Truth für name, serial_no, cost, etc.) │
└──────────────────────┬──────────────────────────────────────┘
│ _inherits (Delegation Pattern)
┌────────────────┐
│ ows.machine │ ◄────────┐
│ (OWS-Features) │ │
└────┬───────────┘ │
│ │
┌─────────┼──────────────────────┤
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ ows.machine.area │ │
│ │ (Bereiche) │ │
│ └──────────────────┘ │
│ │
▼ │
┌──────────────────┐ ┌────────┴─────────┐
│ ows.machine. │ │ ows.machine. │
│ product │ │ training │
│ (Nutzung) │ │ (Einweisung) │
└──────────────────┘ └──────────────────┘
┌──────────────────┐ ┌──────────────────┐
│ res.partner │ │ ows.user │
│ (Kunde) │◄───────────│ (Benutzerdaten) │
└────────┬─────────┘ 1:1 └──────────────────┘
│ M:N
┌──────────────────┐ ┌──────────────────┐
│ ows.machine. │ │ ows.machine │
│ access │───────────►│ │
│ (Freigaben) │ │ │
└──────────────────┘ └──────────────────┘
```
### _inherits Pattern Erklärung
Das Modul nutzt das Odoo **_inherits Pattern** (Delegation/Prototypische Vererbung) für `ows.machine`:
**Wie es funktioniert:**
- `ows.machine` delegiert Basis-Felder an `maintenance.equipment`
- Beim Erstellen einer `ows.machine` wird automatisch ein `maintenance.equipment` erstellt
- Felder wie `name`, `serial_no`, `cost`, `location` werden direkt von equipment übernommen
- **Keine Datenduplizierung!** Ein Equipment-Datensatz, zwei Modell-Sichten
**Vorteile:**
- `maintenance.equipment` bleibt die **Single Source of Truth** für Stammdaten
- Kompatibilität mit allen OCA Maintenance-Modulen
- `ows.machine` fügt nur OWS-spezifische Felder hinzu (category, area_id, etc.)
- Nahtlose Integration in bestehendes Maintenance-System
**Beispiel:**
```python
class OwsMachine(models.Model):
_name = 'ows.machine'
_inherits = {'maintenance.equipment': 'equipment_id'}
equipment_id = fields.Many2one('maintenance.equipment', required=True, ondelete='cascade')
category = fields.Selection(...) # OWS-spezifisch
area_id = fields.Many2one(...) # OWS-spezifisch
```
Beim Zugriff auf `machine.name` wird automatisch `machine.equipment_id.name` zurückgegeben.
---
## 🔄 Modul-Zusammenhänge
### Installation & Abhängigkeiten
```
OCA maintenance ─────┐
OCA maintenance_ │
equipment_status ──┤
open_workshop_base ◄──────────┐
│ │
┌───────────┼─────────────┐ │
│ │ │ │
▼ ▼ ▼ │
open_workshop open_workshop open_workshop
_pos _dokuwiki _employee_
imagegenerator
│ │
▼ │
open_workshop_pos_ │
customer_display ◄───────────────┘
```
**Installation:**
Die OCA Module `maintenance` und `maintenance_equipment_status` müssen im `addons_path` verfügbar sein. Sie werden automatisch mit installiert, wenn `open_workshop_base` installiert wird (siehe `depends` in der `__manifest__.py`).
```bash
git clone https://github.com/OCA/maintenance.git
cd maintenance
git reset --hard 5510275e
```
**Installationsreihenfolge:**
1. `open_workshop_base` (Kernmodul - installiert OCA-Abhängigkeiten automatisch)
2. Optionale Module je nach Bedarf:
- `open_workshop_pos` für POS-Integration
- `open_workshop_dokuwiki` für Wiki-Dokumentation
- `open_workshop_employee_imagegenerator` für Mitarbeiterfotos
- `open_workshop_pos_customer_display` für Customer Display (benötigt imagegenerator)
---
## 🛠️ Entwicklungshinweise
### Wo finde ich was?
#### open_workshop_base
- **Models**: `/models/ows_models.py` - Alle Kern-Datenmodelle
- **Views**: `/views/` - Backend-Ansichten (Form, Tree, Kanban)
- Erweiterte Views:
- `res.partner` (Kontakte): Formular, Listenansicht - Maschinenfreigaben-Tab
- `maintenance.equipment`: Formular, Listenansicht, Suchansicht - OWS-Felder
- **Security**: `/security/ir.model.access.csv` - Zugriffsrechte
- **Data**: `/data/` - Stammdaten (Equipment-Status, Bereiche)
#### open_workshop_pos
- **JavaScript**: `/static/src/js/` - POS Frontend-Logik
- `ows_pos_sidebar.js` - POS Maschinensidebar
- `ows_pos_customer_sidebar.js` - Kundensidebar
- `ows_machine_access_list.js` - Zugriffsliste
- **Templates**: `/static/src/xml/` - OWL Templates für POS UI
- **Models**: `/models/pos_order.py` - POS Order Extensions
#### open_workshop_dokuwiki
- **Models**: `/models/`
- `dokuwiki_client.py` - DokuWiki XML-RPC Client
- `maintenance_equipment.py` - Equipment Extensions
- **Views**: `/views/`
- Erweiterte Views:
- `maintenance.equipment`: Smart Button "Wiki öffnen"
- `maintenance.equipment.status`: DokuWiki-Synchronisation
- **Wizard**: `/wizard/` - Synchronisations-Assistenten
#### open_workshop_employee_imagegenerator
- **JavaScript**: `/static/src/js/employee_image_widget.js` - Cropper Widget
- **Templates**: `/static/src/xml/employee_image_widget.xml` - Widget UI
- **Views**: `/views/`
- Erweiterte Views:
- `hr.employee`: Button "Namensschild erstellen" im Formular
- **CSS**: `/static/src/css/` - Badge-Styling
- **Library**: `/static/lib/cropperjs/` - Cropper.js Integration
#### open_workshop_pos_customer_display
- **JavaScript**: `/static/src/js/customer_display.js` - Display-Logik
- **CSS**: `/static/src/css/employee_badge.css` - Badge-Styling
### Wichtige Methoden & RPC Calls
#### open_workshop_base
- `ows.machine.get_access_list_grouped(partner_id)` - Gruppierte Zugriffsliste für POS
- Liefert alle Bereiche mit Maschinen und Zugriffssstatus für einen Partner
- Wird vom POS Frontend verwendet
#### res.partner Extensions
- `machine_access_ids` - One2many zu allen Freigaben
- `machine_access_html` - Computed HTML-Tabelle für Backend-Ansicht im Res.partner / Kontakt Formular
- `ows_user_id` - One2many zu ows.user (Benutzerdaten)
### Erweiterung des Systems
**Neue Sicherheitskategorie hinzufügen:**
1. In `ows_models.py``OwsMachine.category` Selection erweitern
2. Icon-Mapping in `_compute_category_icon()` aktualisieren
3. CSS in `/static/src/css/category_color.css` ergänzen
**Neuen Maschinenbereich erstellen:**
- Im Backend: **Wartung → Konfiguration → Bereiche**
- Oder via Data-File in `/data/` für Standardbereiche
**POS-UI anpassen:**
- OWL Templates in `/static/src/xml/` bearbeiten
- JavaScript-Komponenten in `/static/src/js/` erweitern
- CSS in `/static/src/css/` anpassen
---
## 📝 Technische Details
### Backend-Konfiguration
**Sicherheitskategorien zuweisen:**
- Menü: **Wartung → Ausrüstung** → [Equipment auswählen]
- Im Formular: Feld "Sicherheitskategorie" (🟢/🟡/🔴)
- Die Kategorie ist direkt am `ows.machine` / `maintenance.equipment` gespeichert
**Einweisungsprodukte zuweisen:**
- Menü: **Wartung → Ausrüstung** → [Equipment auswählen]
- Im Formular: Tab "Einweisungsprodukte"
- Button "Hinzufügen" → Produkt aus `product.product` auswählen
- Speichert Verknüpfung in `ows.machine.training` (Many2many über One2many)
- Beim Verkauf dieses Produkts am POS wird automatisch die Freigabe erteilt
**Nutzungsprodukte zuweisen:**
- Gleicher Weg wie Einweisungsprodukte
- Tab "Nutzungsprodukte" im Equipment-Formular
- Speichert Verknüpfung in `ows.machine.product`
- Diese Produkte können für Nutzungsgebühren verwendet werden
**Maschinenfreigaben manuell vergeben:**
- Menü: **Kontakte** → [Partner auswählen]
- Tab "Maschinenfreigaben" (von open_workshop_base hinzugefügt)
- Übersicht aller Bereiche und Maschinen mit aktuellem Freigabestatus
- Freigaben werden jedoch hauptsächlich automatisch über POS-Verkäufe vergeben
**Konfiguration:**
- **Bereiche**: Wartung → Konfiguration → Bereiche
- **Produkt-Zuordnungen**: Wartung → Konfiguration → Zuordnungen
- Nutzungsprodukte (Übersicht aller Zuordnungen)
- Einweisungsprodukte (Übersicht aller Zuordnungen)
---
### Sicherheitskategorien
- **🟢 Grün (Kategorie 1)**: Keine Einweisungspflicht, freier Zugang
- **🟡 Gelb (Kategorie 2)**: Empfohlene Einweisung, optionale Freigabe
- **🔴 Rot (Kategorie 3)**: Einweisung zwingend erforderlich, POS zeigt nur rote Maschinen
### Freigabenverwaltung
Freigaben werden in `ows.machine.access` gespeichert mit:
- Partner (Kunde)
- Maschine
- Datum der Freigabe
- Optional: Ablaufdatum
- Herkunft (granted_by_pos)
### DokuWiki Namespace-Struktur
```
{equipment_namespace}:
├── overview # Übersichtstabelle (von Odoo)
└── odoo-status/ # Maschinengeneriert (read-only)
├── maschine_1 # Status-Seite für Include
├── maschine_2
└── c_template # Template für Status-Seiten
{bereich}: # Benutzer-Namespaces
├── holz/
│ ├── maschine_1 # Benutzer-Dokumentation
│ └── maschine_2 # (inkludiert odoo-status via {{page>}})
└── metall/
└── ...
```
---
## 📄 Lizenz
AGPL-3 (open_workshop_base)
LGPL-3 (weitere Module)
---
## 👤 Autor
Matthias Lotz / Hobbyhimmel
---
## 🔗 Verwandte Projekte
- [OCA Maintenance](https://github.com/OCA/maintenance) - Basis Equipment-Verwaltung

View File

@ -6,7 +6,7 @@ from odoo import models, fields
class MaintenanceEquipment(models.Model): class MaintenanceEquipment(models.Model):
_inherit = 'maintenance.equipment' _inherit = 'maintenance.equipment'
qr_code = fields.Binary("QR Code", attachment=True) qr_code = fields.Binary("QR Code")
comp_serial_no = fields.Char("Inventory Serial No", tracking=True) comp_serial_no = fields.Char("Inventory Serial No", tracking=True)
serial_no = fields.Char('Mfg. Serial Number', copy=False) serial_no = fields.Char('Mfg. Serial Number', copy=False)

View File

@ -12,7 +12,7 @@
<div class="o_label_data"> <div class="o_label_data">
<div class="text-center o_label_right_column"> <div class="text-center o_label_right_column">
<t t-if="equipment.qr_code"> <t t-if="equipment.qr_code">
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')" style="width: 130px;margin-top: -40px;"/> <div t-field="equipment.qr_code" t-options="{'widget': 'image'}" style="width: 130px;margin-top: -40px;"/>
</t> </t>
</div> </div>
<div class="text-left" style="line-height:normal;word-wrap: break-word;"> <div class="text-left" style="line-height:normal;word-wrap: break-word;">
@ -40,7 +40,7 @@
</div> </div>
<div class= "text-center o_label_right_column"> <div class= "text-center o_label_right_column">
<t t-if="equipment.qr_code"> <t t-if="equipment.qr_code">
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')" style="width:95px;padding:0px;margin-top:-10px"/> <div t-field="equipment.qr_code" t-options="{'widget': 'image'}" style="width:95px;padding:0px;margin-top:-10px"/>
</t> </t>
</div> </div>
<div class="text-left o_label_left_column" style="line-height:normal;word-wrap: break-word;"> <div class="text-left o_label_left_column" style="line-height:normal;word-wrap: break-word;">
@ -66,19 +66,19 @@
<td style="width:25%;">Name</td> <td style="width:25%;">Name</td>
<td style="width:5%">:</td> <td style="width:5%">:</td>
<td colspan="2" style="width:70%;word-wrap: break-word;"> <td colspan="2" style="width:70%;word-wrap: break-word;">
<span t-field="equipment.name"/> <t t-esc="equipment.name"/>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="width:25%">Model</td> <td style="width:25%">Model</td>
<td style="width:5%">:</td> <td style="width:5%">:</td>
<td style="width:35%;word-wrap: break-word;"> <td style="width:35%;word-wrap: break-word;">
<span t-field="equipment.model"/> <t t-esc="equipment.model"/>
</td> </td>
<td rowspan="5" style="width:35%"> <td rowspan="5" style="width:35%">
<t t-if="equipment.qr_code"> <t t-if="equipment.name">
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')" <div t-field="equipment.qr_code" t-options="{'widget': 'image'}"
style="width:30mm;height:30mm;"/> style="width:30mm;height:10mm;"/>
</t> </t>
</td> </td>
</tr> </tr>
@ -87,22 +87,23 @@
<td style="width:25%">Mfg Serial</td> <td style="width:25%">Mfg Serial</td>
<td style="width:5%">:</td> <td style="width:5%">:</td>
<td style="width:35%;font-size:10px;word-wrap: break-word;"> <td style="width:35%;font-size:10px;word-wrap: break-word;">
<span t-field="equipment.serial_no"/> <t t-esc="equipment.serial_no"/>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="width:25%">Serial</td> <td style="width:25%">Serial</td>
<td style="width:5%">:</td> <td style="width:5%">:</td>
<td style="width:35%;word-wrap: break-word;"> <td style="width:35%;word-wrap: break-word;">
<span t-field="equipment.comp_serial_no"/> <t t-esc="equipment.comp_serial_no"/>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="width:25%">Warranty Date</td> <td style="width:25%">Warranty Date</td>
<td style="width:5%">:</td> <td style="width:5%">:</td>
<td style="width:35%;word-wrap: break-word;"> <td style="width:35%;word-wrap: break-word;">
<span t-field="equipment.warranty_date"/> <t t-esc="equipment.warranty_date"/>
</td> </td>
<!-- <td style="width:35%;"></td> -->
</tr> </tr>
<tr> <tr>
<td style="width:25%"></td> <td style="width:25%"></td>

View File

@ -1,34 +1,84 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import math
from io import BytesIO
import qrcode
from odoo import models from odoo import models
def generate_qr_code(value):
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=20,
border=4,
)
qr.add_data(value)
qr.make(fit=True)
img = qr.make_image()
temp = BytesIO()
img.save(temp, format="PNG")
qr_img = base64.b64encode(temp.getvalue())
return qr_img
def _prepare_data(env, data):
equipment_label_layout_id = env['equipment.label.layout'].browse(data['equipment_label_layout_id'])
equipment_dict = {}
equipment_ids = equipment_label_layout_id.equipment_ids
for equipment in equipment_ids:
if not equipment.name:
continue
equipment_dict[equipment] = 1
combine_equipment_detail = ""
# Generate Equipment Redirect LInk
url = env['ir.config_parameter'].sudo().get_param('web.base.url')
menuId = env.ref('maintenance.menu_equipment_form').sudo().id
actionId = env.ref('maintenance.hr_equipment_action').sudo().id
equipment_link = url + '/web#id=' + str(equipment.id) + '&menu_id=' + str(menuId) + '&action=' + str(
actionId) + '&model=maintenance.equipment&view_type=form'
# Prepare main Equipment Detail
main_equipment_detail = ""
main_equipment_detail = main_equipment_detail.join(
"Name: " + str(equipment.name) + "\n" +
"Model: " + str(equipment.model) + "\n" +
"Mfg serial no: " + str(equipment.serial_no) + "\n"
"Warranty Exp. Date: " +str(equipment.warranty_date) + "\n"
"Category: " +str(equipment.category_id.name)
)
# main_equipment_detail = equipment_link + '\n' + '\n' + main_equipment_detail
# Prepare Child Equipment Detail
combine_equipment_detail = main_equipment_detail
combine_equipment_detail += '\n' + '\n' + equipment_link
# Generate Qr Code depends on Details
qr_image = generate_qr_code(combine_equipment_detail)
equipment.write({
'qr_code': qr_image
})
env.cr.commit()
page_numbers = (len(equipment_ids) - 1) // (equipment_label_layout_id.rows * equipment_label_layout_id.columns) + 1
dict_equipment = {
'rows': equipment_label_layout_id.rows,
'columns': equipment_label_layout_id.columns,
'page_numbers': page_numbers,
'equipment_data': equipment_dict
}
return dict_equipment
class ReportProductTemplateLabel(models.AbstractModel): class ReportProductTemplateLabel(models.AbstractModel):
_name = 'report.aspl_equipment_qrcode_generator.maintenance_quip' _name = 'report.aspl_equipment_qrcode_generator.maintenance_quip'
_description = 'Equipment QR-code Report' _description = 'Equipment QR-code Report'
def _get_report_values(self, docids, data=None): def _get_report_values(self, docids, data):
""" return _prepare_data(self.env, data)
QR-Code-Generierung erfolgt im Wizard (equipment_label_layout.py).
Dieser Report rendert nur die bereits generierten QR-Codes.
"""
if not data:
return {}
equipment_label_layout_id = self.env['equipment.label.layout'].browse(data.get('equipment_label_layout_id'))
equipment_dict = {}
for equipment in equipment_label_layout_id.equipment_ids:
equipment_dict[equipment] = 1
page_numbers = (len(equipment_label_layout_id.equipment_ids) - 1) // (
equipment_label_layout_id.rows * equipment_label_layout_id.columns
) + 1
return {
'rows': equipment_label_layout_id.rows,
'columns': equipment_label_layout_id.columns,
'page_numbers': page_numbers,
'equipment_data': equipment_dict
}

View File

@ -16,16 +16,6 @@ class EquipmentLabelLayout(models.TransientModel):
rows = fields.Integer(compute='_compute_dimensions') rows = fields.Integer(compute='_compute_dimensions')
columns = fields.Integer(compute='_compute_dimensions') columns = fields.Integer(compute='_compute_dimensions')
@api.model
def default_get(self, fields_list):
"""Override to properly set equipment_ids from context"""
res = super().default_get(fields_list)
if self.env.context.get('active_ids'):
res['equipment_ids'] = [(6, 0, self.env.context.get('active_ids', []))]
elif self.env.context.get('default_equipment_ids'):
res['equipment_ids'] = [(6, 0, self.env.context.get('default_equipment_ids', []))]
return res
@api.depends('print_format') @api.depends('print_format')
def _compute_dimensions(self): def _compute_dimensions(self):
for wizard in self: for wizard in self:
@ -38,62 +28,9 @@ class EquipmentLabelLayout(models.TransientModel):
def process_label(self): def process_label(self):
# Generiere QR-Codes für alle Equipment VOR dem Report
self._generate_qr_codes()
xml_id = 'aspl_equipment_qrcode_generator.report_equipment_label' xml_id = 'aspl_equipment_qrcode_generator.report_equipment_label'
data = { data = {
'equipment_label_layout_id':self.id 'equipment_label_layout_id':self.id
} }
# report_action benötigt die Equipment IDs als docids return self.env.ref(xml_id).report_action(None, data=data)
return self.env.ref(xml_id).report_action(self.equipment_ids.ids, data=data)
def _generate_qr_codes(self):
"""Generiert QR-Codes für alle ausgewählten Equipment"""
import base64
from io import BytesIO
import qrcode
# Hole die base_url für die Equipment-Links
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
menu_id = self.env.ref('maintenance.menu_equipment_form').sudo().id
action_id = self.env.ref('maintenance.hr_equipment_action').sudo().id
for equipment in self.equipment_ids:
# Extrahiere Namen aus JSONB falls nötig
equipment_name = equipment.name
if isinstance(equipment_name, dict):
equipment_name = equipment_name.get('de_DE') or equipment_name.get('en_US') or str(equipment_name)
category_name = equipment.category_id.name if equipment.category_id else ""
if isinstance(category_name, dict):
category_name = category_name.get('de_DE') or category_name.get('en_US') or str(category_name)
# Erstelle Equipment-Link
equipment_link = f"{base_url}/web#id={equipment.id}&menu_id={menu_id}&action={action_id}&model=maintenance.equipment&view_type=form"
# Erstelle Equipment-Details für QR-Code
qr_data = f"Name: {equipment_name}\n"
qr_data += f"Model: {equipment.model or ''}\n"
qr_data += f"Mfg serial no: {equipment.serial_no or ''}\n"
qr_data += f"Warranty Exp. Date: {equipment.warranty_date or ''}\n"
qr_data += f"Category: {category_name}\n\n"
qr_data += equipment_link
# Generiere QR-Code
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=20,
border=4,
)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image()
temp = BytesIO()
img.save(temp, format="PNG")
qr_image = base64.b64encode(temp.getvalue())
# Speichere QR-Code im Equipment
equipment.write({'qr_code': qr_image})

View File

@ -7,7 +7,6 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<form> <form>
<group> <group>
<field name="equipment_ids" invisible="1"/>
<group> <group>
<field name="print_format" widget="radio"/> <field name="print_format" widget="radio"/>
</group> </group>

View File

@ -0,0 +1,300 @@
<?php
/**
* Plugin Name: OpenWorkshop Odoo API
* Plugin URI: https://hobbyhimmel.de/
* Description: Bindet Daten aus Odoo (OpenWorkshop) per REST-API in WordPress ein. Stellt u.a. den Shortcode [openworkshop_machines] bereit.
* Version: 0.1.0
* Author: HobbyHimmel / Matthias Lotz
* License: GPLv3
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
* Text Domain: openworkshop-odoo-api
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class OpenWorkshop_Odoo_API_Plugin {
const OPTION_GROUP = 'openworkshop_odoo_api_options';
const OPTION_NAME = 'openworkshop_odoo_api_settings';
public function __construct() {
add_action( 'admin_menu', array( $this, 'register_settings_page' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_shortcode( 'openworkshop_machines', array( $this, 'shortcode_machines' ) );
}
/**
* Register settings page under Settings OpenWorkshop Odoo API
*/
public function register_settings_page() {
add_options_page(
__( 'OpenWorkshop Odoo API', 'openworkshop-odoo-api' ),
__( 'OpenWorkshop Odoo API', 'openworkshop-odoo-api' ),
'manage_options',
'openworkshop-odoo-api',
array( $this, 'render_settings_page' )
);
}
/**
* Register settings (base URL + optional token)
*/
public function register_settings() {
register_setting(
self::OPTION_GROUP,
self::OPTION_NAME,
array(
'type' => 'array',
'sanitize_callback' => array( $this, 'sanitize_settings' ),
'default' => array(
'base_url' => '',
'api_token' => '',
'machines_endpoint' => '/api/v1/machines',
),
)
);
add_settings_section(
'openworkshop_odoo_api_main',
__( 'Odoo API Einstellungen', 'openworkshop-odoo-api' ),
'__return_false',
'openworkshop-odoo-api'
);
add_settings_field(
'base_url',
__( 'Odoo Basis-URL', 'openworkshop-odoo-api' ),
array( $this, 'render_field_base_url' ),
'openworkshop-odoo-api',
'openworkshop_odoo_api_main'
);
add_settings_field(
'machines_endpoint',
__( 'Endpoint für Maschinenliste', 'openworkshop-odoo-api' ),
array( $this, 'render_field_machines_endpoint' ),
'openworkshop-odoo-api',
'openworkshop_odoo_api_main'
);
add_settings_field(
'api_token',
__( 'API Token (optional)', 'openworkshop-odoo-api' ),
array( $this, 'render_field_api_token' ),
'openworkshop-odoo-api',
'openworkshop_odoo_api_main'
);
}
public function sanitize_settings( $input ) {
$output = array();
$output['base_url'] = isset( $input['base_url'] )
? esc_url_raw( rtrim( $input['base_url'], '/' ) )
: '';
$output['machines_endpoint'] = isset( $input['machines_endpoint'] )
? sanitize_text_field( $input['machines_endpoint'] )
: '/api/v1/machines';
$output['api_token'] = isset( $input['api_token'] )
? sanitize_text_field( $input['api_token'] )
: '';
return $output;
}
public function get_settings() {
$settings = get_option( self::OPTION_NAME, array() );
$defaults = array(
'base_url' => '',
'api_token' => '',
'machines_endpoint' => '/api/v1/machines',
);
return wp_parse_args( $settings, $defaults );
}
public function render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$settings = $this->get_settings();
?>
<div class="wrap">
<h1><?php esc_html_e( 'OpenWorkshop Odoo API Einstellungen', 'openworkshop-odoo-api' ); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields( self::OPTION_GROUP );
do_settings_sections( 'openworkshop-odoo-api' );
submit_button();
?>
</form>
<h2><?php esc_html_e( 'Shortcodes', 'openworkshop-odoo-api' ); ?></h2>
<p><?php esc_html_e( 'Maschinenliste anzeigen:', 'openworkshop-odoo-api' ); ?></p>
<code>[openworkshop_machines]</code>
<p><?php esc_html_e( 'Optional können Attribute verwendet werden, um z. B. eine andere Anzahl von Maschinen anzuzeigen.', 'openworkshop-odoo-api' ); ?></p>
</div>
<?php
}
public function render_field_base_url() {
$settings = $this->get_settings();
?>
<input type="text"
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[base_url]"
value="<?php echo esc_attr( $settings['base_url'] ); ?>"
class="regular-text"
placeholder="https://odoo.example.org" />
<p class="description">
<?php esc_html_e( 'Basis-URL deiner Odoo-Instanz (ohne Slash am Ende). Die API-Route wird daran angehängt.', 'openworkshop-odoo-api' ); ?>
</p>
<?php
}
public function render_field_machines_endpoint() {
$settings = $this->get_settings();
?>
<input type="text"
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[machines_endpoint]"
value="<?php echo esc_attr( $settings['machines_endpoint'] ); ?>"
class="regular-text"
placeholder="/api/v1/machines" />
<p class="description">
<?php esc_html_e( 'Relativer Pfad zum Maschinen-Endpoint. Standard: /api/v1/machines', 'openworkshop-odoo-api' ); ?>
</p>
<?php
}
public function render_field_api_token() {
$settings = $this->get_settings();
?>
<input type="text"
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[api_token]"
value="<?php echo esc_attr( $settings['api_token'] ); ?>"
class="regular-text" />
<p class="description">
<?php esc_html_e( 'Optionaler API-Token, der im Authorization-Header gesendet wird (Bearer &lt;token&gt;).', 'openworkshop-odoo-api' ); ?>
</p>
<?php
}
/**
* Shortcode: [openworkshop_machines]
* Attributes:
* limit Anzahl der Einträge (optional)
*/
public function shortcode_machines( $atts ) {
$atts = shortcode_atts(
array(
'limit' => 0,
),
$atts,
'openworkshop_machines'
);
$data = $this->fetch_machines();
if ( is_wp_error( $data ) ) {
return '<p>' . esc_html__( 'Fehler beim Laden der Maschinendaten aus Odoo.', 'openworkshop-odoo-api' ) . '</p>';
}
if ( ! is_array( $data ) || empty( $data ) ) {
return '<p>' . esc_html__( 'Keine Maschinen gefunden.', 'openworkshop-odoo-api' ) . '</p>';
}
$limit = intval( $atts['limit'] );
if ( $limit > 0 ) {
$data = array_slice( $data, 0, $limit );
}
ob_start();
?>
<div class="openworkshop-machines">
<table class="openworkshop-machines-table">
<thead>
<tr>
<th><?php esc_html_e( 'Name', 'openworkshop-odoo-api' ); ?></th>
<th><?php esc_html_e( 'Bereich', 'openworkshop-odoo-api' ); ?></th>
<th><?php esc_html_e( 'Status', 'openworkshop-odoo-api' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $data as $machine ) : ?>
<tr>
<td>
<?php echo isset( $machine['name'] ) ? esc_html( $machine['name'] ) : ''; ?>
</td>
<td>
<?php echo isset( $machine['area'] ) ? esc_html( $machine['area'] ) : ''; ?>
</td>
<td>
<?php echo isset( $machine['status'] ) ? esc_html( $machine['status'] ) : ''; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
return ob_get_clean();
}
/**
* Calls the Odoo API and returns decoded JSON or WP_Error.
*/
protected function fetch_machines() {
$settings = $this->get_settings();
if ( empty( $settings['base_url'] ) ) {
return new WP_Error( 'openworkshop_no_base_url', __( 'Keine Odoo Basis-URL konfiguriert.', 'openworkshop-odoo-api' ) );
}
$url = $settings['base_url'] . $settings['machines_endpoint'];
$args = array(
'timeout' => 10,
'headers' => array(
'Accept' => 'application/json',
),
);
if ( ! empty( $settings['api_token'] ) ) {
$args['headers']['Authorization'] = 'Bearer ' . $settings['api_token'];
}
$response = wp_remote_get( $url, $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
if ( $code < 200 || $code >= 300 ) {
return new WP_Error( 'openworkshop_bad_status', sprintf( 'HTTP %d', $code ) );
}
$data = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return new WP_Error( 'openworkshop_json_error', json_last_error_msg() );
}
return $data;
}
}
function openworkshop_odoo_api_bootstrap() {
static $instance = null;
if ( $instance === null ) {
$instance = new OpenWorkshop_Odoo_API_Plugin();
}
return $instance;
}
openworkshop_odoo_api_bootstrap();

View File

@ -0,0 +1,30 @@
=== OpenWorkshop Odoo API ===
Contributors: hobbyhimmel
Tags: odoo, api, openworkshop, integration
Requires at least: 5.8
Tested up to: 6.6
Stable tag: 0.1.0
License: GPLv3
License URI: https://www.gnu.org/licenses/gpl-3.0.html
Dieses Plugin bindet Maschinendaten aus einer Odoo/OpenWorkshop-Installation via REST-API in WordPress ein.
== Beschreibung ==
Das Plugin stellt u.a. den Shortcode [openworkshop_machines] bereit, der eine einfache Maschinenliste
auf Basis eines JSON-Endpunkts in Odoo rendert.
In den Einstellungen kann die Basis-URL der Odoo-Instanz, der Endpoint (z.B. /api/v1/machines) sowie ein
optionaler API-Token hinterlegt werden.
== Installation ==
1. ZIP in WordPress unter Plugins → Installieren → Plugin hochladen hochladen.
2. Aktivieren.
3. Unter Einstellungen → OpenWorkshop Odoo API die Basis-URL und den Endpoint konfigurieren.
4. Den Shortcode [openworkshop_machines] in einer Seite oder einem Beitrag einfügen.
== Changelog ==
= 0.1.0 =
* Erste Version.

View File

@ -0,0 +1,11 @@
{
"folders": [
{
"path": "."
},
{
"path": "../../../usr/lib/python3/dist-packages/odoo"
}
],
"settings": {}
}

View File

@ -0,0 +1,3 @@
from . import models
from . import controllers

View File

@ -0,0 +1,39 @@
{
'name': 'POS Open Workshop',
'license': 'AGPL-3',
'version': '18.0.1.0.1',
'summary': 'Erstellt Maschinenfreigaben basierend auf POS-Einweisungsprodukten',
'depends': ['base', 'account', 'hr','product','sale','contacts','point_of_sale'],
'author': 'matthias.lotz',
'category': 'Point of Sale',
'data': [
'security/ir.model.access.csv',
'views/machine_product_training_views.xml',
'views/menu_views.xml',
'views/machine_area_views.xml',
'views/machine_views.xml',
'views/res_partner_view.xml',
'data/data.xml',
],
'installable': False, # Wird durch open_workshop_base ersetzt
'assets': {
'web.assets_backend': [
'open_workshop/static/src/css/category_color.css',
],
'point_of_sale._assets_pos': [
'open_workshop/static/src/css/pos.css',
'open_workshop/static/src/js/ows_machine_access_list.js',
'open_workshop/static/src/js/ows_pos_customer_sidebar.js',
'open_workshop/static/src/js/ows_pos_sidebar.js',
'open_workshop/static/src/js/ows_product_screen_template_patch.js',
'open_workshop/static/src/xml/ows_machine_access_list.xml',
'open_workshop/static/src/xml/ows_pos_customer_sidebar.xml',
'open_workshop/static/src/xml/ows_pos_sidebar.xml',
'open_workshop/static/src/xml/ows_product_screen_template_patch.xml',
],
},
'description': """
Diese App erstellt Maschinenfreigaben basierend auf POS-Einweisungsprodukten.
Die App ist für den Einsatz in der Odoo-Version 18.0 konzipiert.
""",
}

View File

@ -0,0 +1,3 @@
# Datei: open_workshop/controllers/__init__.py
from . import pos_access

View File

@ -0,0 +1,14 @@
from odoo import http
from odoo.http import request
class OpenWorkshopPOSController(http.Controller):
@http.route('/open_workshop/partner_access', type='json', auth='user')
def get_partner_machine_access(self, **kwargs):
partner_id = kwargs.get('params', {}).get('partner_id')
if not partner_id:
return {"error": "Missing partner_id"}
Machine = request.env['ows.machine'].sudo()
return Machine.get_access_list_grouped(partner_id)

153
open_workshop/data/data.xml Normal file
View File

@ -0,0 +1,153 @@
<odoo>
<!-- Bereiche -->
<record id="area_fablab" model="ows.machine.area">
<field name="name">Fablab</field>
<field name="color_hex">#008000</field>
</record>
<record id="area_holz" model="ows.machine.area">
<field name="name">Holzbereich</field>
<field name="color_hex">#ff0000</field>
</record>
<record id="area_metall" model="ows.machine.area">
<field name="name">Metallbereich</field>
<field name="color_hex">#0000ff</field>
</record>
<record id="area_elektronik" model="ows.machine.area">
<field name="name">Elektronikbereich</field>
<field name="color_hex">#ffff00</field>
</record>
<!-- Maschinen im Fablab -->
<record id="machine_sabako_laser" model="ows.machine">
<field name="name">Sabako Laser</field>
<field name="code">sabako_laser</field>
<field name="area_id" ref="area_fablab"/>
</record>
<record id="machine_prusa" model="ows.machine">
<field name="name">Prusa</field>
<field name="code">prusa</field>
<field name="area_id" ref="area_fablab"/>
</record>
<record id="machine_prusa_mmu" model="ows.machine">
<field name="name">Prusa MMU</field>
<field name="code">prusa_mmu</field>
<field name="area_id" ref="area_fablab"/>
</record>
<record id="machine_3d_delta" model="ows.machine">
<field name="name">3D Delta</field>
<field name="code">3d_delta</field>
<field name="area_id" ref="area_fablab"/>
</record>
<record id="machine_cnc_beamicon" model="ows.machine">
<field name="name">CNC Beamicon</field>
<field name="code">cnc_beamicon</field>
<field name="area_id" ref="area_fablab"/>
</record>
<!-- Maschinen im Holzbereich -->
<record id="machine_formatkreissaege" model="ows.machine">
<field name="name">Formatkreissäge</field>
<field name="code">formatkreissaege</field>
<field name="area_id" ref="area_holz"/>
</record>
<record id="machine_bandsaege_holz" model="ows.machine">
<field name="name">Bandsäge</field>
<field name="code">bandsaege_holz</field>
<field name="area_id" ref="area_holz"/>
</record>
<record id="machine_abrichte" model="ows.machine">
<field name="name">Abricht Dickenhobel</field>
<field name="code">dickenhobel</field>
<field name="area_id" ref="area_holz"/>
</record>
<record id="machine_drechselbank" model="ows.machine">
<field name="name">Drechselbank</field>
<field name="code">drechselbank</field>
<field name="area_id" ref="area_holz"/>
</record>
<record id="machine_festool_domino" model="ows.machine">
<field name="name">Festool Domino Fräse</field>
<field name="code">festool_domino</field>
<field name="area_id" ref="area_holz"/>
</record>
<record id="machine_maffel_duo" model="ows.machine">
<field name="name">Maffel Duo Dübler</field>
<field name="code">maffel_duo</field>
<field name="area_id" ref="area_holz"/>
</record>
<record id="machine_lamello" model="ows.machine">
<field name="name">Lamello Zeta P2</field>
<field name="code">lamello_zeta_p2</field>
<field name="area_id" ref="area_holz"/>
</record>
<!-- Maschinen im Metallbereich -->
<record id="machine_kreissaege_metall" model="ows.machine">
<field name="name">Kreissäge</field>
<field name="code">kreissaege_metall</field>
<field name="area_id" ref="area_metall"/>
</record>
<record id="machine_bandsaege_metall" model="ows.machine">
<field name="name">Bandsäge</field>
<field name="code">bandsaege_metall</field>
<field name="area_id" ref="area_metall"/>
</record>
<record id="machine_mig_mag" model="ows.machine">
<field name="name">MIG/MAG Schweißgeräte</field>
<field name="code">mig_mag</field>
<field name="area_id" ref="area_metall"/>
</record>
<record id="machine_wig" model="ows.machine">
<field name="name">WIG Schweißgerät</field>
<field name="code">wig</field>
<field name="area_id" ref="area_metall"/>
</record>
<record id="machine_schweissen" model="ows.machine">
<field name="name">Schweißen allgemein</field>
<field name="code">schweissen_allgemein</field>
<field name="area_id" ref="area_metall"/>
</record>
<record id="machine_drehbank" model="ows.machine">
<field name="name">Drehbank</field>
<field name="code">drehbank</field>
<field name="area_id" ref="area_metall"/>
</record>
<record id="machine_fraese" model="ows.machine">
<field name="name">Fräse</field>
<field name="code">fraese</field>
<field name="area_id" ref="area_metall"/>
</record>
<record id="machine_abkantbank" model="ows.machine">
<field name="name">Abkantbank</field>
<field name="code">abkantbank</field>
<field name="area_id" ref="area_metall"/>
</record>
<!-- Maschine im Elektronikbereich -->
<record id="machine_loetkolben" model="ows.machine">
<field name="name">Lötkolben</field>
<field name="code">loetkolben</field>
<field name="area_id" ref="area_elektronik"/>
</record>
</odoo>

View File

@ -0,0 +1,5 @@
from . import ows_models
from . import pos_order

View File

@ -0,0 +1,589 @@
# -*- coding: utf-8 -*-
# ows_models.py
# Part of Odoo Open Workshop
from odoo import models, fields, api
from markupsafe import escape as html_escape
import logging
_logger = logging.getLogger(__name__)
_logger.info("✅ ows_models.py geladen")
class HREmployee(models.Model):
_inherit = 'hr.employee'
@api.model
def anonymize_for_testsystem(self):
"""Benennt Admin-Angestellten um und archiviert alle anderen für das Testsystem."""
admin_user = self.env['res.users'].search([('name', '=', 'Administrator')], limit=1)
if not admin_user:
_logger.error("[OWS] Administrator-Benutzer nicht gefunden!")
return
_logger.info(f"[OWS] Administrator-Benutzer gefunden: {admin_user.name} (ID: {admin_user.id})")
# Suche auch archivierte Employees
admin_employee = self.with_context(active_test=False).search([('user_id', '=', admin_user.id)], limit=1)
if admin_employee:
# Administrator-Employee reaktivieren und umbenennen
admin_employee.write({
'name': 'TESTSYSTEM',
'job_title': 'Testumgebung',
'work_email': 'office@hobbyhimmel.de',
'work_phone': False,
'active': True, # Reaktivieren falls archiviert
})
_logger.info(f"[OWS] Admin-Angestellter reaktiviert und umbenannt: {admin_employee.name} (ID: {admin_employee.id})")
else:
_logger.warning("[OWS] Kein Angestellter für Administrator gefunden.")
return
# Alle anderen Angestellten archivieren (auch bereits archivierte berücksichtigen)
other_employees = self.with_context(active_test=False).search([('id', '!=', admin_employee.id)])
other_employees.write({'active': False})
_logger.info("[OWS] %d Angestellte archiviert.", len(other_employees))
class ResPartner(models.Model):
_inherit = 'res.partner'
_logger.info("✅ ows ResPartner geladen")
ows_user_id = fields.One2many('ows.user', 'partner_id', string="OWS Benutzerdaten")
# Alte Felder (weiterhin sichtbar für externe Programme)
birthday = fields.Date(
string="Geburtstag",
compute='_compute_ows_user_fields',
inverse='_inverse_birthday',
store=False
)
rfid_card = fields.Text(
string="RFID Card ID",
compute='_compute_ows_user_fields',
inverse='_inverse_rfid_card',
store=False
)
security_briefing = fields.Boolean(
string="Haftungsausschluss",
compute='_compute_ows_user_fields',
inverse='_inverse_security_briefing',
store=False
)
security_id = fields.Text(
string="Haftungsausschluss ID",
compute='_compute_ows_user_fields',
inverse='_inverse_security_id',
store=False
)
# Neue direkte vvow_* Felder
vvow_birthday = fields.Date(string="Geburtstag (vvow)")
vvow_rfid_card = fields.Text(string="RFID Card ID (vvow)")
vvow_security_briefing = fields.Boolean(string="Haftungsausschluss (vvow)")
vvow_security_id = fields.Text(string="Haftungsausschluss ID (vvow)")
@api.depends('ows_user_id')
def _compute_ows_user_fields(self):
for partner in self:
user = partner.ows_user_id[0] if partner.ows_user_id else False
partner.birthday = user.birthday if user else False
partner.rfid_card = user.rfid_card if user else False
partner.security_briefing = user.security_briefing if user else False
partner.security_id = user.security_id if user else False
def _inverse_birthday(self):
for partner in self:
user = partner.ows_user_id[0] if partner.ows_user_id else False
if user:
user.birthday = partner.birthday
def _inverse_rfid_card(self):
for partner in self:
user = partner.ows_user_id[0] if partner.ows_user_id else False
if user:
user.rfid_card = partner.rfid_card
def _inverse_security_briefing(self):
for partner in self:
user = partner.ows_user_id[0] if partner.ows_user_id else False
if user:
user.security_briefing = partner.security_briefing
def _inverse_security_id(self):
for partner in self:
user = partner.ows_user_id[0] if partner.ows_user_id else False
if user:
user.security_id = partner.security_id
@api.model_create_multi
def create(self, vals_list):
partners = super().create(vals_list)
for vals, partner in zip(vals_list, partners):
self.env['ows.user'].create({
'partner_id': partner.id,
'birthday': vals.get('birthday') or vals.get('vvow_birthday'),
'rfid_card': vals.get('rfid_card') or vals.get('vvow_rfid_card'),
'security_briefing': vals.get('security_briefing') or vals.get('vvow_security_briefing'),
'security_id': vals.get('security_id') or vals.get('vvow_security_id'),
})
return partners
def write(self, vals):
res = super().write(vals)
for partner in self:
user = partner.ows_user_id[0] if partner.ows_user_id else False
if not user:
continue
# Synchronisation alt -> user
if 'birthday' in vals:
user.birthday = vals['birthday']
if 'rfid_card' in vals:
user.rfid_card = vals['rfid_card']
if 'security_briefing' in vals:
user.security_briefing = vals['security_briefing']
if 'security_id' in vals:
user.security_id = vals['security_id']
# Synchronisation vvow_* -> user + alt
if 'vvow_birthday' in vals:
user.birthday = vals['vvow_birthday']
partner.birthday = vals['vvow_birthday']
if 'vvow_rfid_card' in vals:
user.rfid_card = vals['vvow_rfid_card']
partner.rfid_card = vals['vvow_rfid_card']
if 'vvow_security_briefing' in vals:
user.security_briefing = vals['vvow_security_briefing']
partner.security_briefing = vals['vvow_security_briefing']
if 'vvow_security_id' in vals:
user.security_id = vals['vvow_security_id']
partner.security_id = vals['vvow_security_id']
return res
machine_access_ids = fields.One2many(
'ows.machine.access',
'partner_id',
string='Maschinenfreigaben'
)
machine_access_html = fields.Html(
string="Maschinenfreigabe",
compute="_compute_machine_access_html",
sanitize=False
)
@api.depends('machine_access_ids')
def _compute_machine_access_html(self):
areas = self.env['ows.machine.area'].search([], order="name")
for partner in self:
html = ""
for area in areas:
html += f"""
<div class="o_form_sheet">
<h3 class="o_form_label">{area.name}</h3>
<table class="table table-sm table-bordered o_form_table">
<thead>
<tr>
<th>Maschine</th>
<th>Status</th>
<th>Datum</th>
<th>Gültig bis</th>
</tr>
</thead>
<tbody>
"""
machines = self.env['ows.machine'].search([('area_id', '=', area.id)], order="name")
for machine in machines:
access = self.env['ows.machine.access'].search([
('partner_id', '=', partner.id),
('machine_id', '=', machine.id),
], limit=1)
icon = '<span class="text-success fa fa-check"/>' if access else '<span class="text-danger fa fa-times"/>'
date_granted = access.date_granted.strftime('%Y-%m-%d') if access and access.date_granted else "-"
date_expiry = access.date_expiry.strftime('%Y-%m-%d') if access and access.date_expiry else "-"
html += f"""
<tr>
<td>{machine.name}</td>
<td>{icon}</td>
<td>{date_granted}</td>
<td>{date_expiry}</td>
</tr>
"""
html += "</tbody></table></div>"
partner.machine_access_html = html
@api.model
def migrate_existing_partners(self):
"""
Erstellt für alle vorhandenen res.partner einen ows.user,
wenn noch keiner existiert, und übernimmt alte vvow_* Felder.
Führt am Ende automatisch einen commit durch.
Verwendung in der odoo shell:
env['res.partner'].migrate_existing_partners()
env['ows.user'].search_count([])
env['ows.user'].search([]).mapped('partner_id.name')
"""
migrated = 0
skipped = 0
partners = self.env['res.partner'].search([])
for partner in partners:
if partner.ows_user_id:
skipped += 1
continue
# Werte lesen (werden evtl. durch _inherit hinzugefügt)
vals = {
'partner_id': partner.id,
'birthday': getattr(partner, 'vvow_birthday', False),
'rfid_card': getattr(partner, 'vvow_rfid_card', False),
'security_briefing': getattr(partner, 'vvow_security_briefing', False),
'security_id': getattr(partner, 'vvow_security_id', False),
}
self.env['ows.user'].create(vals)
migrated += 1
_logger.info(f"Erzeuge ows.user für Partner {partner.id} ({partner.name}) mit Werten: {vals}")
_logger.info(f"[OWS Migration] ✅ Migriert: {migrated}, ❌ Übersprungen: {skipped}")
# 🔐 Commit am Ende
self.env.cr.commit()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': "Migration abgeschlossen",
'message': f"{migrated} Partner migriert, {skipped} übersprungen.",
'sticky': False,
}
}
@api.model
def migrate_machine_access_from_old_fields(self):
"""
Migriert alte vvow_* Boolean-Felder in strukturierte ows.machine.access Einträge.
Das Freigabe-Datum wird aus dem Änderungsverlauf (mail.message.tracking_value_ids) extrahiert.
"""
mapping = [
('vvow_holz_sander', 'bandschleifer'),
('vvow_holz_felder_bandsaw', 'bandsaege_holz'),
('vvow_holz_felder_jointer', 'dickenhobel'),
('vvow_holz_felder_mill', 'felder_fraese'),
('vvow_holz_felder_tablesaw', 'formatkreissaege'),
('vvow_holz_mill', 'tischfraese'),
('vvow_holz_lathe', 'drechselbank'),
('vvow_holz_domino', 'festool_domino'),
('vvow_holz_duoduebler', 'maffel_duo'),
('vvow_holz_lamello', 'lamello_zeta_p2'),
('vvow_fablab_laser_sabko', 'sabako_laser'),
('vvow_fablab_3dprint_delta', '3d_delta'),
('vvow_fablab_3dprint_prusa', 'prusa'),
('vvow_fablab_3dprint_prusa_mmu', 'prusa_mmu'),
('vvow_fablab_cnc_beamicon', 'cnc_beamicon'),
('vvow_metall_welding_mig', 'mig_mag'),
('vvow_metall_welding_wig', 'wig'),
('vvow_metall_welding_misc', 'schweissen_allgemein'),
('vvow_metall_lathe', 'drehbank'),
('vvow_metall_bandsaw', 'bandsaege_metall'),
('vvow_metall_mill_fp2', 'fraese'),
('vvow_metall_bende', 'abkantbank'),
('vvow_metall_chop_saw', 'kreissaege_metall'),
]
MachineAccess = self.env['ows.machine.access']
MailMessage = self.env['mail.message']
TrackingValue = self.env['mail.tracking.value']
count_created = 0
for partner in self.search([]):
for field_name, machine_code in mapping:
if not getattr(partner, field_name, False):
continue
machine = self.env['ows.machine'].search([('code', '=', machine_code)], limit=1)
if not machine:
continue
# Änderungsverlauf durchsuchen: Wann wurde das Feld auf True gesetzt?
tracking = TrackingValue.search([
('field', '=', field_name),
('field_type', '=', 'boolean'),
('new_value_integer', '=', 1),
('mail_message_id.model', '=', 'res.partner'),
('mail_message_id.res_id', '=', partner.id),
], order='id ASC', limit=1)
date_granted = tracking.mail_message_id.date if tracking else fields.Datetime.now()
if not MachineAccess.search([('partner_id', '=', partner.id), ('machine_id', '=', machine.id)]):
MachineAccess.create({
'partner_id': partner.id,
'machine_id': machine.id,
'date_granted': date_granted,
})
count_created += 1
_logger.info(f"[OWS Migration] ✅ Maschinenfreigaben erstellt: {count_created}")
self.env.cr.commit()
@api.model
def archive_partners_without_users(self):
"""
Archiviert alle Partner (res.partner), die keine Benutzer (res.users) sind.
"""
Partner = self.env['res.partner']
User = self.env['res.users']
# IDs aller Partner, die ein Benutzerkonto haben
user_partner_ids = User.search([]).mapped('partner_id').ids
# Alle Partner ohne Benutzerkonto
partners_to_archive = Partner.search([
('id', 'not in', user_partner_ids),
('active', '=', True),
])
count = len(partners_to_archive)
partners_to_archive.write({'active': False})
for p in partners_to_archive:
_logger.debug(f"[OWS] Archiviert Partner: {p.name} (ID {p.id})")
_logger.info(f"[OWS] Archiviert {count} Partner ohne Benutzerkonto.")
self.env.cr.commit()
class OwsUser(models.Model):
_name = 'ows.user'
_description = 'OWS: Benutzerdaten'
_table = 'ows_user'
_logger.info("✅ ows_user geladen")
partner_id = fields.Many2one(
'res.partner',
required=True,
ondelete='cascade',
string='Kontakt'
)
birthday = fields.Date('Geburtstag')
rfid_card = fields.Text('RFID Card ID')
security_briefing = fields.Boolean('Haftungsausschluss', default=False)
security_id = fields.Text('Haftungsausschluss ID')
_sql_constraints = [
('partner_unique', 'unique(partner_id)', 'Jeder Partner darf nur einen OWS-Datensatz haben.')
]
AVAILABLE_COLORS = [
('#000000', 'schwarz'),
('#ff0000', 'Rot'),
('#E91E63', 'Pink'),
('#9C27B0', 'Lila'),
('#3F51B5', 'Indigo'),
('#0000ff', 'Blau'),
('#008000', 'Grün'),
('#ffff00', 'Gelb'),
('#FF9800', 'Orange'),
('#795548', 'Braun'),
('#ffffff', 'Weiss'),
]
class OwsMachineArea(models.Model):
_name = 'ows.machine.area'
_table = 'ows_machine_area'
_description = 'OWS: Maschinenbereich'
_order = 'name'
name = fields.Char(string="Name", required=True, translate=True)
color_hex = fields.Selection(
selection=AVAILABLE_COLORS,
string="Farbe (Hex)",
required=True,
)
color_hex_value = fields.Char(
string="Farbcode",
compute='_compute_color_hex_value',
store=False
)
color_name = fields.Char(
string="Farbname",
compute='_compute_color_name',
store=False
)
@api.depends('color_hex')
def _compute_color_hex_value(self):
for rec in self:
rec.color_hex_value = rec.color_hex or ''
@api.depends('color_hex')
def _compute_color_name(self):
label_dict = dict(AVAILABLE_COLORS)
for rec in self:
rec.color_name = label_dict.get(rec.color_hex, 'Unbekannt')
class OwsMachine(models.Model):
_name = 'ows.machine'
_table = 'ows_machine'
_description = 'OWS: Maschine'
name = fields.Char(required=True, translate=True)
code = fields.Char(required=True, help="Eindeutiger Kurzcode, z.B. 'lasercutter'")
category = fields.Selection([
('green', 'Kategorie 1: grün'),
('yellow', 'Kategorie 2: gelb'),
('red', 'Kategorie 3: rot'),
], string="Sicherheitskategorie", required=True, default='red', help="Sicherheitsrelevante Maschinenkategorie:\n"
"- grün: keine Einweisungspflicht\n"
"- gelb: empfohlene Einweisung\n"
"- rot: Einweisung zwingend erforderlich")
category_icon = fields.Char(string="Kategorie-Symbol", compute="_compute_category_icon", store=False)
@api.depends('category')
def _compute_category_icon(self):
for rec in self:
icon_map = {
'green': '🟢',
'yellow': '🟡',
'red': '🔴',
}
rec.category_icon = icon_map.get(rec.category, '')
description = fields.Text()
active = fields.Boolean(default=True)
area_id = fields.Many2one('ows.machine.area', string='Bereich', help="Bereich, in dem die Maschine oder das Gerät steht.")
product_ids = fields.One2many('ows.machine.product', 'machine_id', string="Nutzungsprodukte")
product_names = fields.Char(string="Liste der Nutzungsprodukte", compute="_compute_product_using_names", store=False,)
training_ids = fields.One2many('ows.machine.training', 'machine_id', string="Einweisungsprodukte")
training_names = fields.Char(string="Liste der Einweisungsprodukte", compute="_compute_product_training_names", store=False,)
storage_location = fields.Char(string="Lagerort", help="Lagerort der Maschine oder des Geräts.")
purchase_price = fields.Float(string="Kaufpreis", help="Kaufpreis der Maschine oder des Geräts.")
purchase_date = fields.Date(string="Kaufdatum", help="Kaufdatum der Maschine oder des Geräts.")
@api.depends('product_ids.product_id.name')
def _compute_product_using_names(self):
for machine in self:
names = machine.product_ids.mapped('product_id.name')
machine.product_names = ", ".join(names)
@api.depends('training_ids.training_id.name')
def _compute_product_training_names(self):
for machine in self:
names = machine.training_ids.mapped('training_id.name')
machine.training_names = ", ".join(names)
_sql_constraints = [
('code_unique', 'unique(code)', 'Maschinencode muss eindeutig sein.')
]
def name_get(self):
return [(rec.id, f"{rec.name} ({rec.code})") for rec in self]
@api.model
def get_access_list_grouped(self, partner_id):
"""
Gibt eine gruppierte Liste von Maschinenzugängen für einen bestimmten Partner zurück. Diese Funktion wird in
Odoo POS Frontend verwendet um die Ansicht zu erzeugen auf Welche Maschinen der Partner Zugriff hat.
Für einen gegebenen Partner (über die partner_id) werden alle Maschinenbereiche (areas) abgefragt.
Für jeden Bereich wird geprüft, auf welche Maschinen der Partner Zugriff hat. Das Ergebnis wird
als Liste von Bereichen mit jeweils zugehörigen Maschinen und Zugriffsstatus zurückgegeben.
Zusätzlich werden sicherheitsrelevante Informationen des Partners (wie Sicherheitsunterweisung,
Sicherheits-ID, RFID-Karte und Geburtstag) aus dem zugehörigen ows_user ermittelt und mitgeliefert.
Args:
partner_id (int): Die ID des Partners, für den die Zugriffsübersicht erstellt werden soll.
Returns:
dict: Ein Dictionary mit folgenden Schlüsseln:
- 'access_by_area': Liste von Bereichen mit Maschinen und Zugriffsstatus.
- 'security_briefing': Sicherheitsunterweisung des Nutzers (bool oder False).
- 'security_id': Sicherheits-ID des Nutzers (str oder '').
- 'rfid_card': RFID-Kartennummer des Nutzers (str oder '').
- 'birthday': Geburtstag des Nutzers (str oder '').
"""
partner = self.env['res.partner'].browse(partner_id)
areas = self.env['ows.machine.area'].search([], order="name")
_logger.info("Access RPC called with partner_id=%s", partner_id)
access_by_area = []
for area in areas:
machines = self.search([('area_id', '=', area.id), ('category', '=', 'red')], order="name")
machine_list = []
for machine in machines:
has_access = bool(self.env['ows.machine.access'].search([
('partner_id', '=', partner_id),
('machine_id', '=', machine.id),
], limit=1))
machine_list.append({
'name': machine.name,
'has_access': has_access,
})
if machine_list:
access_by_area.append({
'area': area.name,
'color_hex': area.color_hex or '#000000',
'machines': machine_list
})
user = partner.ows_user_id[:1]
return {
'access_by_area': access_by_area,
'security_briefing': user.security_briefing if user else False,
'security_id': user.security_id if user else '',
'rfid_card': user.rfid_card if user else '',
'birthday': user.birthday if user else '',
}
class OwsMachineAccess(models.Model):
_name = 'ows.machine.access'
_table = 'ows_machine_access'
_description = 'OWS: Maschinenfreigabe'
_order = 'partner_id, machine_id'
partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade')
machine_id = fields.Many2one('ows.machine', required=True)
date_granted = fields.Date(default=fields.Date.today)
date_expiry = fields.Date(string="Ablaufdatum")
granted_by_pos = fields.Boolean(default=True)
_sql_constraints = [
('partner_machine_unique', 'unique(partner_id, machine_id)', 'Der Kunde hat diese Freigabe bereits.')
]
class OwsMachineProduct(models.Model):
_name = 'ows.machine.product'
_table = 'ows_machine_product'
_description = 'OWS: Zuordnung Produkt der Nutzung zu der Maschine'
product_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
class OwsMachineTraining(models.Model):
_name = 'ows.machine.training'
_table = 'ows_machine_training'
_description = 'OWS: Zuordnung Produkt der Einweisung zu der Maschine'
training_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')

View File

@ -0,0 +1,8 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ows_machine_access_user,ows.machine.access,model_ows_machine_access,base.group_user,1,1,1,1
access_ows_machine_user,ows.machine,model_ows_machine,base.group_user,1,1,1,1
access_ows_machine_product_user,ows.machine.product,model_ows_machine_product,base.group_user,1,1,1,1
access_ows_machine_training_user,access_ows_machine_training_user,model_ows_machine_training,base.group_user,1,1,1,1
access_ows_machine_area,ows.machine.area,model_ows_machine_area,base.group_user,1,1,1,1
access_ows_user,ows.user,model_ows_user,base.group_user,1,1,1,1
access_ows_machine_training,ows.machine.training,model_ows_machine_training,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ows_machine_access_user ows.machine.access model_ows_machine_access base.group_user 1 1 1 1
3 access_ows_machine_user ows.machine model_ows_machine base.group_user 1 1 1 1
4 access_ows_machine_product_user ows.machine.product model_ows_machine_product base.group_user 1 1 1 1
5 access_ows_machine_training_user access_ows_machine_training_user model_ows_machine_training base.group_user 1 1 1 1
6 access_ows_machine_area ows.machine.area model_ows_machine_area base.group_user 1 1 1 1
7 access_ows_user ows.user model_ows_user base.group_user 1 1 1 1
8 access_ows_machine_training ows.machine.training model_ows_machine_training base.group_user 1 1 1 1

View File

@ -0,0 +1,9 @@
.category-color-circle {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 50%;
margin-left: 8px;
vertical-align: middle;
border: 1px solid #444;
}

View File

@ -0,0 +1,57 @@
.custompane {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.ows-sidebar {
flex: 1 1 auto;
width: 220px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.order-entry:hover { cursor: pointer; }
.order-entry.selected { background-color: #007bff; color: white; }
.ows-customer-list {
flex: 1 1 auto;
overflow-y: auto;
min-height: 0; /* notwendig für Scrollbar */
}
.client-details-grid {
flex-shrink: 0;
max-height: 60%;
overflow-y: auto;
background: #fff;
}
.pos *::-webkit-scrollbar {
width: 8px;
height:8px;
}
.sidebar-line {
display: flex;
justify-content: space-between;
gap: 0.5em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.2em 0;
}
.sidebar-date {
flex-shrink: 0;
}
.sidebar-name {
flex-shrink: 1;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -0,0 +1,72 @@
// @odoo-module ows_machine_access_list.js
import { Component, useState } from "@odoo/owl";
import { useBus } from "@web/core/utils/hooks";
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
export class OwsMachineAccessList extends Component {
static template = 'open_workshop.OwsMachineAccessList';
setup() {
this.pos = usePos();
this.state = useState({
client: null,
grouped_accesses: [],
security_briefing: false,
security_id: '',
rfid_card: '',
birthday: '',
});
// 🔁 Reagiere auf Partnerwechsel über den Odoo-Bus
useBus(this.env.bus, 'partner-changed', () => {
this.updateAccessList();
});
// 🔃 Beim Mounten initiale Daten laden
this.updateAccessList();
}
async updateAccessList() {
const order = this.pos.get_order();
const partner = order?.get_partner?.();
this.state.client = partner || null;
if (!partner) {
this.state.grouped_accesses = [];
this.state.security_briefing = false;
this.state.security_id = '';
this.state.rfid_card = '';
this.state.birthday = '';
return;
}
try {
const data = await rpc("/open_workshop/partner_access", {
params: { partner_id: partner.id },
});
this.state.grouped_accesses = data.access_by_area || [];
this.state.security_briefing = data.security_briefing;
this.state.security_id = data.security_id;
this.state.rfid_card = data.rfid_card;
this.state.birthday = data.birthday;
} catch (error) {
console.error("Fehler beim Laden der Einweisungen:", error);
this.state.grouped_accesses = [];
this.state.security_briefing = false;
this.state.security_id = '';
this.state.rfid_card = '';
this.state.birthday = '';
}
}
}
registry.category("templates").add("open_workshop.OwsMachineAccessList", OwsMachineAccessList);

View File

@ -0,0 +1,54 @@
// @odoo-module ows_pos_customer_sidebar.js
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { _t } from "@web/core/l10n/translation";
import { ask } from "@web/core/confirmation_dialog/confirmation_dialog";
export class OwsPosCustomerSidebar extends Component {
static template = "open_workshop.OwsPosCustomerSidebar";
setup() {
this.pos = usePos(); // ✅ Holt dir Zugriff auf den zentralen POS-Store
this.dialog = useService("dialog");
}
addOrder() {
this.pos.add_new_order(); // ✅ Neue Order wird aktive Order
this.pos.showScreen("ProductScreen");
this.pos.selectPartner();
this.env.bus.trigger('partner-changed'); // ✅ Event manuell auslösen
}
async removeCurrentOrder() {
this.pos.onDeleteOrder(this.pos.get_order())
}
openTicketScreen() {
this.pos.showScreen("TicketScreen");
}
// 🔧 FIXED: Zugriff auf Order-Liste korrigiert
getFilteredOrderList() {
return this.pos.get_open_orders();
}
getDate(order) {
const date = new Date(order.date_order);
const dd = String(date.getDate()).padStart(2, '0');
const mm = String(date.getMonth() + 1).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mi = String(date.getMinutes()).padStart(2, '0');
return `${dd}.${mm}. ${hh}:${mi}`;
}
getPartner(order) {
return order.get_partner()?.name || "Kein Kunde";
}
selectOrder(order) {
this.pos.set_order(order);
this.env.bus.trigger('partner-changed');
}
}

View File

@ -0,0 +1,11 @@
// ows_pos_sidebar.js
// @odoo-module
import { Component } from "@odoo/owl";
import { OwsPosCustomerSidebar } from "./ows_pos_customer_sidebar";
import { OwsMachineAccessList } from "./ows_machine_access_list";
export class OwsPosSidebar extends Component {
static template = "open_workshop.OwsPosSidebar";
static components = { OwsPosCustomerSidebar, OwsMachineAccessList };
}

View File

@ -0,0 +1,15 @@
// product_screen_template_patch.js
// @odoo-module
import { registry } from "@web/core/registry";
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
import { OwsPosSidebar } from "./ows_pos_sidebar";
class OwsProductScreen extends ProductScreen {
static components = Object.assign({}, ProductScreen.components, {
OwsPosSidebar,
});
}
registry.category("pos_screens").remove("ProductScreen");
registry.category("pos_screens").add("ProductScreen", OwsProductScreen);

View File

@ -0,0 +1,76 @@
<t t-name="open_workshop.OwsMachineAccessList">
<div class="client-details-grid p-2 small">
<!-- ✅ Sicherheitsbereich -->
<t t-if="state.client">
<div class="client-details-header">
<ul>
<li><span class="client-details-label">Einweisungen</span></li>
</ul>
<div class="client-details-area border" t-att-style="'border: solid 3px #ffffff; margin: 5px;'">
<ul>
<li class="client-detail">
<span class="detail client-details-vvow_briefing"></span>
<span class="briefinglabel">Werkstatt</span>
</li>
<li class="client-detail">
<t t-if="!state.security_briefing">
<span class="detail client-details-vvow_briefing_error"></span>
</t>
<t t-if="state.security_briefing">
<span class="detail client-details-vvow_briefing"></span>
</t>
<span class="briefinglabel">Haftungsausschluss</span>
</li>
<t t-if="!state.security_briefing">
<li class="client-detail">
<ul class="subpoints">
<span class="detail client-details-vvow_sec_briefing_error">Bitte Prüfen‼</span>
</ul>
</li>
</t>
<t t-if="state.security_briefing">
<ul class="subpoints">
<li class="client-detail">
<span class="label">Id:</span>
<span class="detail client-details-vvow_security_id">
<t t-esc="state.security_id || 'N/A'" />
</span>
</li>
<li class="client-detail">
<span class="label">Geburtstag:</span>
<span class="detail client-details-vvow_security_id">
<t t-esc="state.birthday || 'N/A'" />
</span>
</li>
</ul>
</t>
</ul>
</div>
</div>
</t>
<!-- ✅ Maschinenliste: immer sichtbar, gefiltert -->
<t t-foreach="state.grouped_accesses" t-as="area" t-key="area.area">
<t t-if="area.machines.length > 0">
<div class="client-details-area" t-att-style="'border: solid 3px ' + area.color_hex + '; margin: 5px;'">
<ul>
<t t-foreach="area.machines" t-as="machine" t-key="machine.name">
<li class="client-detail">
<span t-attf-class="detail {{ machine.has_access ? 'client-details-vvow_briefing' : 'client-details-vvow_briefing_error' }}">
<t t-esc="machine.has_access ? '✅' : '❌'" />
</span>
<span class="briefinglabel"><t t-esc="machine.name"/></span>
</li>
</t>
</ul>
</div>
</t>
</t>
</div>
</t>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="open_workshop.OwsPosCustomerSidebar" owl="1">
<div class="ows-sidebar p-2 bg-light border-end h-100">
<div class="ows-sidebar-header mb-2 d-flex justify-content-between align-items-center">
<button class="btn btn-secondary" t-on-click="openTicketScreen">Orders</button>
<div>
<button class="btn btn-sm btn-success me-1" t-on-click="addOrder">+</button>
<button class="btn btn-sm btn-danger" t-on-click="removeCurrentOrder"></button>
</div>
</div>
<div class="ows-customer-list overflow-auto">
<t t-foreach="getFilteredOrderList()" t-as="order" t-key="order.uid">
<!--div class="order-entry p-1 rounded mb-1 border"
t-att-class="order === pos.get_order() ? 'bg-primary text-white' : 'bg-white'"
t-on-click="() => selectOrder(order)"-->
<div t-att-class="'order-entry' + (order === pos.get_order() ? ' selected' : '')" t-on-click="() => this.selectOrder(order)">
<div class="sidebar-line">
<span class="sidebar-date"><t t-esc="getDate(order)"/></span>
<span class="sidebar-name" t-att-title="getPartner(order)"><t t-esc="getPartner(order)"/></span>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="open_workshop.OwsPosSidebar" owl="1">
<div class="custompane d-flex flex-column border-end bg-200">
<OwsPosCustomerSidebar />
<OwsMachineAccessList />
</div>
</t>
</templates>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.ProductScreen" t-inherit="point_of_sale.ProductScreen" t-inherit-mode="extension">
<xpath expr="//div[contains(@class, 'leftpane')]" position="before">
<OwsPosSidebar />
</xpath>
</t>
</templates>

View File

@ -0,0 +1,8 @@
<odoo>
<template id="assets_open_workshop" inherit_id="point_of_sale._assets_pos">
<xpath expr="." position="inside">
<script type="text/javascript" src="/open_workshop/static/src/js/machine_access_sidebar.js"/>
<link rel="stylesheet" type="text/css" href="/open_workshop/static/src/css/pos.css"/>
</xpath>
</template>
</odoo>

View File

@ -0,0 +1,40 @@
<!-- machine_area_views.xml -->
<odoo>
<!-- Action zum Anzeigen der Bereiche -->
<record id="action_machine_area_list" model="ir.actions.act_window">
<field name="name">Maschinenbereiche</field>
<field name="res_model">ows.machine.area</field>
<field name="view_mode">list,form</field>
</record>
<!-- Menüpunkt unter Maschinen > Konfiguration -->
<menuitem id="menu_machine_area" name="Bereiche" parent="menu_machine_config" action="open_workshop.action_machine_area_list" sequence="30"/>
<!-- Listenansicht -->
<record id="view_machine_area_tree" model="ir.ui.view">
<field name="name">ows.machine.area.tree</field>
<field name="model">ows.machine.area</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="color_hex_value" string="Farbe (Hex)"/>
<field name="color_name" string="Farbname"/>
</list>
</field>
</record>
<!-- Formularansicht -->
<record id="view_machine_area_form" model="ir.ui.view">
<field name="name">ows.machine.area.form</field>
<field name="model">ows.machine.area</field>
<field name="arch" type="xml">
<form string="Maschinenbereich">
<group>
<field name="name"/>
<field name="color_hex"/>
<field name="color_name" readonly="1"/>
</group>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,49 @@
<odoo>
<!-- Tree View: Nutzungsprodukte -->
<record id="view_machine_product_tree" model="ir.ui.view">
<field name="name">ows.machine.product.tree</field>
<field name="model">ows.machine.product</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="machine_id"/>
<field name="product_id" domain="[('categ_id.name', '=', 'Maschinennutzung')]"/>
</list>
</field>
</record>
<!-- Tree View: Einweisungsprodukte -->
<record id="view_machine_training_tree" model="ir.ui.view">
<field name="name">ows.machine.training.tree</field>
<field name="model">ows.machine.training</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="machine_id"/>
<field name="training_id" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]"/>
</list>
</field>
</record>
<!-- Action: Nutzungsprodukte -->
<record id="action_machine_product" model="ir.actions.act_window">
<field name="name">Maschinen-Nutzungsprodukte</field>
<field name="res_model">ows.machine.product</field>
<field name="view_mode">list</field>
<field name="view_id" ref="view_machine_product_tree"/>
<field name="help" type="html">
<p>Verwalte die Zuordnung von Maschinen zu Nutzungsprodukten.</p>
</field>
</record>
<!-- Action: Einweisungsprodukte -->
<record id="action_machine_training" model="ir.actions.act_window">
<field name="name">Maschinen-Einweisungsprodukte</field>
<field name="res_model">ows.machine.training</field>
<field name="view_mode">list</field>
<field name="view_id" ref="view_machine_training_tree"/>
<field name="help" type="html">
<p>Verwalte die Zuordnung von Maschinen zu Einweisungsprodukten.</p>
</field>
</record>
</odoo>

View File

@ -0,0 +1,68 @@
<!-- machine_views.xml -->
<odoo>
<!-- Maschinen Listenansicht -->
<record id="view_machine_tree" model="ir.ui.view">
<field name="name">ows.machine.tree</field>
<field name="model">ows.machine</field>
<field name="arch" type="xml">
<list>
<field name="category_icon" string="⚙" readonly="1"/>
<field name="name"/>
<field name="category"/>
<field name="code"/>
<field name="area_id" widget="many2one_color"/>
<field name="product_names"/>
<field name="training_names"/>
<field name="storage_location"/>
<field name="purchase_price"/>
<field name="purchase_date"/>
<field name="active"/>
</list>
</field>
</record>
<!-- Maschinen Formularansicht -->
<record id="view_machine_form" model="ir.ui.view">
<field name="name">ows.machine.form</field>
<field name="model">ows.machine</field>
<field name="arch" type="xml">
<form string="Maschine">
<sheet>
<group>
<field name="category_icon" string="⚙" readonly="1"/>
<field name="name"/>
<field name="category"/>
<field name="code"/>
<field name="area_id"/>
</group>
<group>
<field name="description"/>
<field name="storage_location"/>
<field name="purchase_price"/>
<field name="purchase_date"/>
<field name="active"/>
</group>
<!-- Notebook für Produkte und Einweisungen -->
<notebook>
<page string="Nutzungsprodukte">
<field name="product_ids" context="{'default_machine_id': id}">
<list editable="bottom">
<field name="product_id" domain="[('categ_id.name', '=', 'Maschinennutzung')]" />
</list>
</field>
</page>
<page string="Einweisungsprodukte">
<field name="training_ids" context="{'default_machine_id': id}">
<list editable="bottom">
<field name="training_id" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]" />
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,81 @@
<!-- menu_views.xml -->
<odoo>
<!-- Maschinenliste -->
<record id="action_machine_list" model="ir.actions.act_window">
<field name="name">Maschinen</field>
<field name="res_model">ows.machine</field>
<field name="view_mode">list,form</field>
</record>
<!-- Trainingsprodukt-Liste -->
<record id="action_training_product_list" model="ir.actions.act_window">
<field name="name">Einweisungs-Produkte</field>
<field name="res_model">ows.machine.product</field>
<field name="view_mode">list,form</field>
</record>
<!-- Menüstruktur -->
<!-- Oberstes Menü -->
<menuitem id="menu_machine_root"
name="Maschinen"
sequence="10"/>
<!-- Konfigurationsebene -->
<menuitem id="menu_machine_config"
name="Konfiguration"
parent="menu_machine_root"
sequence="10"/>
<!-- Menüpunkt: Maschinenliste (klickbar) -->
<menuitem id="menu_machine_list_action"
name="Alle Maschinen"
parent="menu_machine_config"
action="open_workshop.action_machine_list"
sequence="10"/>
<!-- Menücontainer: Zuordnungen -->
<menuitem id="menu_machine_list"
name="Zuordnungen"
parent="menu_machine_config"
sequence="20"/>
<!-- Untermenü: Nutzungsprodukte -->
<menuitem id="menu_machine_product"
name="Nutzungsprodukte"
parent="menu_machine_list"
action="action_machine_product"
sequence="10"/>
<!-- Untermenü: Einweisungsprodukte -->
<menuitem id="menu_machine_training"
name="Einweisungsprodukte"
parent="menu_machine_list"
action="action_machine_training"
sequence="20"/>
<!-- List & Form Views für training.product -->
<record id="view_training_product_tree" model="ir.ui.view">
<field name="name">ows.machine.product.tree</field>
<field name="model">ows.machine.product</field>
<field name="arch" type="xml">
<list>
<field name="product_id"/>
<field name="machine_id"/>
</list>
</field>
</record>
<record id="view_training_product_form" model="ir.ui.view">
<field name="name">ows.machine.product.form</field>
<field name="model">ows.machine.product</field>
<field name="arch" type="xml">
<form string="Einweisungs-Produkt">
<group>
<field name="product_id"/>
<field name="machine_id"/>
</group>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,127 @@
<odoo>
<!-- Entfernt die Partner-Warnung (Duplicate Bank Accounts) in res.partner
<record id="patch_res_partner_duplicate_warning" model="ir.ui.view">
<field name="name">res.partner.remove.duplicate.bank.warning</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="account.view_partner_property_form"/>
<field name="priority" eval="99"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='warning_tax' and @class='alert alert-warning oe_edit_only']" position="replace"/>
</field>
</record>-->
<!-- Entfernt die Bankkonto-Warnung in res.partner.bank
<record id="patch_res_partner_bank_duplicate_warning" model="ir.ui.view">
<field name="name">res.partner.bank.remove.duplicate.warning</field>
<field name="model">res.partner.bank</field>
<field name="inherit_id" ref="account.view_partner_bank_form_inherit_account"/>
<field name="priority" eval="99"/>
<field name="arch" type="xml">
<xpath expr="//div[@class='alert alert-warning']" position="replace"/>
</field>
</record> -->
<!-- Zentrale View für alle drei Tabs in garantierter Reihenfolge -->
<record id="view_partner_form_inherit_open_workshop_tabs" model="ir.ui.view">
<field name="name">res.partner.form.ows.tabs</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="priority" eval="20"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page name="ows_machine_access" string="Offene Werkstatt (Hobbyhimmel)">
<!-- EINWEISUNG: Zwei Felder nebeneinander -->
<group name="container_row_2" string="Sicherheitseinweisung" col="2">
<field name="security_briefing"/>
<field name="security_id"/>
</group>
<!-- MASCHINENFREIGABEN: Volle Breite -->
<group string="Maschinenfreigaben" col="2">
<field name="machine_access_ids" colspan="2" context="{'default_partner_id': id}" nolabel="1">
<list>
<field name="machine_id"/>
<field name="date_granted"/>
<field name="date_expiry"/>
<field name="granted_by_pos"/>
</list>
</field>
</group>
<!-- ÜBERSICHT: Volle Breite -->
<group string="Maschinenfreigaben Übersicht" >
<field name="machine_access_html" colspan="2" readonly="1" widget="html" nolabel="1"/>
</group>
</page>
</xpath>
</field>
</record>
<!-- Geburtstag direkt nach der USt-ID -->
<record id="view_partner_form_inherit_ows_birthday" model="ir.ui.view">
<field name="name">res.partner.form.ows.birthday</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="priority" eval="15"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='vat']" position="after">
<field name="birthday"/>
</xpath>
</field>
</record>
<!-- List View Anpassung -->
<record id="ows_userList_inherit" model="ir.ui.view">
<field name="name">res.partner.ows.tree</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='vat']" position="after">
<field name="create_date" optional="show"/>
<field name="security_briefing" optional="show"/>
<field name="security_id" optional="show"/>
<field name="rfid_card" optional="show"/>
<field name="category_id" widget="many2many_tags"/>
</xpath>
<xpath expr="//field[@name='vat']" position="replace">
<field name="vat" invisible="1"/>
</xpath>
<xpath expr="//field[@name='email']" position="replace">
<field name="email" invisible="1"/>
</xpath>
<xpath expr="//field[@name='phone']" position="replace">
<field name="phone" invisible="1"/>
</xpath>
<xpath expr="//field[@name='state_id']" position="replace">
<field name="state_id" invisible="1"/>
</xpath>
<xpath expr="//field[@name='country_id']" position="replace">
<field name="country_id" invisible="1"/>
</xpath>
</field>
</record>
<!-- Standardwerte setzen (company_type = person) -->
<record id="view_partner_form_inherit" model="ir.ui.view">
<field name="name">res.partner.form.inherit.default_person</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<field name="company_type" position="attributes">
<attribute name="default">person</attribute>
</field>
</field>
</record>
<!-- Optional: Kontakte-Action, falls gebraucht -->
<record id="contacts.action_contacts" model="ir.actions.act_window">
<field name="view_mode">list,kanban,form,activity</field>
</record>
<record id="contacts.action_contacts_view_kanban" model="ir.actions.act_window.view">
<field name="sequence" eval="1"/>
</record>
<record id="contacts.action_contacts_view_tree" model="ir.actions.act_window.view">
<field name="sequence" eval="0"/>
</record>
</odoo>

View File

@ -0,0 +1,44 @@
# ✅ OpenWorkshop Test Checkliste
## 🔹 1. Migration
- [ ] `migrate_existing_partners()` erzeugt zu jedem Partner genau einen `ows.user`.
- [ ] `migrate_existing_partners()` übernimmt korrekt alte `vvow_*`-Felder.
- [ ] `migrate_machine_access_from_old_fields()` erstellt korrekte Einträge in `ows.machine.access`.
- [ ] `migrate_machine_access_from_old_fields()` übernimmt das Änderungsdatum aus `mail.tracking.value`.
## 🔹 2. Kontakte Backend
- [ ] Beim Anlegen eines neuen Partners wird automatisch ein `ows.user` angelegt.
- [ ] Änderungen an Geburtstag, RFID, Haftung in Partner-Formular schreiben korrekt in `ows.user`.
- [ ] Die Werte aus `ows.user` werden korrekt im Partnerformular angezeigt (via `compute`).
- [ ] Das HTML-Widget mit Maschinenfreigaben (`machine_access_html`) wird korrekt dargestellt.
## 🔹 3. POS-Integration
- [ ] Felder aus `ows.user` (Geburtstag, RFID etc.) erscheinen im POS-Kunden-Popup.
- [ ] Maschinenfreigaben erscheinen im POS-Layout korrekt gruppiert nach Bereichen.
- [ ] Farben der Maschinenbereiche werden korrekt aus `color_hex` übernommen.
## 🔹 4. Maschinenverwaltung
- [ ] Maschinen-Formular zeigt Nutzungs- und Einweisungsprodukte korrekt an.
- [ ] Drop-downs in den Produktlisten zeigen nur Produkte der richtigen Kategorie.
- [ ] Neue Zuordnungen können direkt in den Tree-Ansichten editiert werden.
- [ ] Filter greifen korrekt (Maschinennutzung / Einweisungen).
## 🔹 5. Menüstruktur
- [ ] Menüeinträge "Nutzungsprodukte" und "Einweisungsprodukte" erscheinen unter Konfiguration > Maschinen.
- [ ] Klick auf "Alle Maschinen" öffnet die erwartete Listenansicht.
## 🔹 6. CSV/XML Demo-/Initialdaten
- [ ] Maschinenbereiche (`ows.machine.area`) sind korrekt aus `data.xml` geladen.
- [ ] Maschinen und ihre Produkt-Zuordnungen sind vollständig.
- [ ] Kategorien und Produkte sind korrekt verknüpft (`product.category`, `product.product`).
## 🔹 7. Systemweite Konsistenz
- [ ] Es gibt keine doppelten `ows.user`-Einträge.
- [ ] Kein Partner existiert ohne zugehörigen `ows.user`.
- [ ] `res.partner.ows_user_id` ist immer gefüllt.
## 🔹 8. Technische Qualität
- [ ] Kein `@api.depends('id')` mehr vorhanden.
- [ ] Commit wird in Migrationsfunktionen korrekt gesetzt (`self.env.cr.commit()`).
- [ ] Keine toten `vvow_*` Felder mehr im Modell (wenn auf ows.user umgestellt).
- [ ] post-init und pre-load Skripte laufen fehlerfrei bei Neuinstallation.

View File

@ -1,21 +1,20 @@
{ {
'name': 'Open Workshop Base', 'name': 'Open Workshop Base',
'license': 'AGPL-3', 'license': 'AGPL-3',
'version': '18.0.1.0.5', # Migration läuft bei 18.0.1.0.4 'version': '18.0.1.0.4', # Migration läuft bei 18.0.1.0.4
'summary': 'Kern-Modul für Maschinenfreigaben - vereinfachte Equipment-Integration', 'summary': 'Kern-Modul für Maschinenfreigaben - vereinfachte Equipment-Integration',
'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance', 'maintenance_equipment_status'], 'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance'],
'author': 'matthias.lotz', 'author': 'matthias.lotz',
'category': 'Manufacturing', 'category': 'Manufacturing',
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/maintenance_equipment_status_data.xml',
'views/machine_product_training_views.xml', 'views/machine_product_training_views.xml',
'views/menu_views.xml',
'views/machine_area_views.xml', 'views/machine_area_views.xml',
'views/machine_views.xml', 'views/machine_views.xml',
'views/maintenance_equipment_views.xml', 'views/maintenance_equipment_views.xml',
'views/res_partner_view.xml', 'views/res_partner_view.xml',
'views/menu_views.xml',
'data/data.xml', 'data/data.xml',
], ],
'installable': True, 'installable': True,

View File

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Equipment Status für Open Workshop -->
<record id="equipment_status_inbetrieb" model="maintenance.equipment.status">
<field name="name">In Betrieb</field>
<field name="sequence" eval="10"/>
</record>
<record id="equipment_status_defekt" model="maintenance.equipment.status">
<field name="name">Defekt</field>
<field name="sequence" eval="20"/>
</record>
<record id="equipment_status_wartung" model="maintenance.equipment.status">
<field name="name">Wartung</field>
<field name="sequence" eval="30"/>
</record>
<record id="equipment_status_ausgemustert" model="maintenance.equipment.status">
<field name="name">Ausgemustert</field>
<field name="sequence" eval="40"/>
</record>
</odoo>

View File

@ -99,55 +99,7 @@ def migrate(cr, version):
except Exception as e: except Exception as e:
_logger.error(f"Failed to migrate machine {m_id}: {e}") _logger.error(f"Failed to migrate machine {m_id}: {e}")
# 3. Synchronisiere related fields von ows.machine → maintenance.equipment # 3. Validierung
# Nötig weil related fields mit store=True nicht automatisch bei SQL-INSERT synchronisiert werden
_logger.info("Synchronizing related fields (area_id, category) to maintenance.equipment...")
cr.execute("""
UPDATE maintenance_equipment me
SET ows_area_id = om.area_id
FROM ows_machine om
WHERE om.equipment_id = me.id
AND om.area_id IS NOT NULL
""")
synced_areas = cr.rowcount
_logger.info(f"✅ Synchronized {synced_areas} area_id values")
cr.execute("""
UPDATE maintenance_equipment me
SET ows_category = om.category
FROM ows_machine om
WHERE om.equipment_id = me.id
AND om.category IS NOT NULL
""")
synced_categories = cr.rowcount
_logger.info(f"✅ Synchronized {synced_categories} category values")
# 4. Setze Default-Status "In Betrieb" für migrierte Equipment ohne Status
# (Status-Records werden automatisch über XML-Datei erstellt)
_logger.info("Setting default status for equipment without status...")
# Prüfe ob Status "In Betrieb" existiert
cr.execute("SELECT id FROM maintenance_equipment_status WHERE name = 'In Betrieb' LIMIT 1")
status_result = cr.fetchone()
if status_result:
status_id = status_result[0]
cr.execute("""
UPDATE maintenance_equipment me
SET status_id = %s
WHERE me.status_id IS NULL
AND EXISTS (
SELECT 1 FROM ows_machine om
WHERE om.equipment_id = me.id
)
""", (status_id,))
status_set = cr.rowcount
_logger.info(f"✅ Set default status for {status_set} equipment records")
else:
_logger.warning("⚠️ Status 'In Betrieb' not found - skipping status assignment")
# 5. Validierung
cr.execute("SELECT COUNT(*) FROM ows_machine WHERE equipment_id IS NULL") cr.execute("SELECT COUNT(*) FROM ows_machine WHERE equipment_id IS NULL")
remaining = cr.fetchone()[0] remaining = cr.fetchone()[0]
@ -157,4 +109,3 @@ def migrate(cr, version):
_logger.info(f"✅ Successfully migrated {migrated_count} machines to equipment") _logger.info(f"✅ Successfully migrated {migrated_count} machines to equipment")
_logger.info("Post-migration completed") _logger.info("Post-migration completed")

View File

@ -1,4 +1,5 @@
from . import ows_models from . import ows_models
from . import pos_order

View File

@ -400,128 +400,6 @@ AVAILABLE_COLORS = [
('#ffffff', 'Weiss'), ('#ffffff', 'Weiss'),
] ]
class MaintenanceEquipment(models.Model):
"""Erweitere maintenance.equipment mit OWS-Feldern für direkte Bearbeitung"""
_inherit = 'maintenance.equipment'
# Inverse Relation zu ows.machine
ows_machine_id = fields.One2many('ows.machine', 'equipment_id', string='OWS Maschine', readonly=True)
# OWS-Felder direkt auf Equipment (via related für einfache Bearbeitung)
# store=True für Performance beim Filtern/Suchen
ows_category = fields.Selection(
related='ows_machine_id.category',
string='Sicherheitskategorie',
readonly=False,
store=True
)
ows_category_icon = fields.Char(
related='ows_machine_id.category_icon',
string=''
)
ows_area_id = fields.Many2one(
'ows.machine.area',
related='ows_machine_id.area_id',
string='Bereich',
readonly=False,
store=True
)
ows_product_ids = fields.One2many(
'ows.machine.product',
related='ows_machine_id.product_ids',
string='Nutzungsprodukte',
readonly=False
)
ows_training_ids = fields.One2many(
'ows.machine.training',
related='ows_machine_id.training_ids',
string='Einweisungsprodukte',
readonly=False
)
ows_usage_product_ids = fields.Many2many(
'product.product',
compute='_compute_usage_products',
inverse='_inverse_usage_products',
string='Nutzungsprodukte',
store=False
)
ows_training_product_ids = fields.Many2many(
'product.product',
compute='_compute_training_products',
inverse='_inverse_training_products',
string='Einweisungsprodukte',
store=False
)
@api.depends('ows_product_ids.product_id')
def _compute_usage_products(self):
for record in self:
record.ows_usage_product_ids = record.ows_product_ids.mapped('product_id')
def _inverse_usage_products(self):
for record in self:
if record.ows_machine_id:
# Entferne alle bisherigen
record.ows_machine_id.product_ids.unlink()
# Füge neue hinzu
for product in record.ows_usage_product_ids:
self.env['ows.machine.product'].create({
'machine_id': record.ows_machine_id.id,
'product_id': product.id
})
@api.depends('ows_training_ids.training_id')
def _compute_training_products(self):
for record in self:
record.ows_training_product_ids = record.ows_training_ids.mapped('training_id')
def _inverse_training_products(self):
for record in self:
if record.ows_machine_id:
# Entferne alle bisherigen
record.ows_machine_id.training_ids.unlink()
# Füge neue hinzu
for product in record.ows_training_product_ids:
self.env['ows.machine.training'].create({
'machine_id': record.ows_machine_id.id,
'training_id': product.id
})
@api.model_create_multi
def create(self, vals_list):
"""Erstelle automatisch ows.machine für jedes neue Equipment (außer wenn vom ows.machine.create aufgerufen)"""
records = super(MaintenanceEquipment, self).create(vals_list)
records._create_missing_ows_machines()
return records
def _create_missing_ows_machines(self):
"""Erstelle fehlende ows.machine Datensätze für Equipment ohne ows.machine"""
if self.env.context.get('skip_ows_machine_creation'):
return
for record in self:
if not record.ows_machine_id:
self.env['ows.machine'].with_context(skip_ows_machine_creation=True).create({
'equipment_id': record.id,
'category': record.ows_category or 'red',
'area_id': record.ows_area_id.id if record.ows_area_id else False,
})
@api.model
def load(self, fields, data):
"""Override load() um sicherzustellen, dass ows.machine nach dem Import erstellt wird"""
result = super(MaintenanceEquipment, self).load(fields, data)
# Nach erfolgreichem Import: Erstelle fehlende ows.machine Einträge
if result.get('ids'):
equipment_records = self.browse(result['ids'])
equipment_records._create_missing_ows_machines()
return result
class OwsMachineArea(models.Model): class OwsMachineArea(models.Model):
_name = 'ows.machine.area' _name = 'ows.machine.area'
_table = 'ows_machine_area' _table = 'ows_machine_area'
@ -635,33 +513,43 @@ class OwsMachine(models.Model):
# Keine eigenen SQL Constraints - Equipment hat bereits unique constraint für serial_no # Keine eigenen SQL Constraints - Equipment hat bereits unique constraint für serial_no
@api.model_create_multi @api.model
def create(self, vals_list): def create(self, vals):
""" """
Beim Erstellen einer ows.machine: Beim Erstellen einer ows.machine:
1. Equipment nur erstellen wenn equipment_id NICHT angegeben wurde 1. Equipment IMMER automatisch erstellen
2. Area Location synchronisieren 2. Area Location synchronisieren
3. serial_no und name vom User übernehmen 3. serial_no und name vom User übernehmen
""" """
# Equipment nur erstellen wenn noch nicht vorhanden # Equipment IMMER automatisch erstellen
for vals in vals_list:
# Wenn equipment_id bereits gesetzt ist (z.B. bei Import), KEIN neues Equipment erstellen!
if 'equipment_id' not in vals:
equipment_vals = { equipment_vals = {
'name': vals.get('name', 'Neue Maschine'), 'name': vals.get('name', 'Neue Maschine'),
'serial_no': vals.get('serial_no', False), 'serial_no': vals.get('serial_no', False),
} }
equipment = self.env['maintenance.equipment'].with_context(skip_ows_machine_creation=True).create(equipment_vals) # Area → Location Mapping für Equipment
if 'area_id' in vals and vals['area_id']:
area = self.env['ows.machine.area'].browse(vals['area_id'])
if area and area.name:
equipment_vals['location'] = area.name
equipment = self.env['maintenance.equipment'].create(equipment_vals)
vals['equipment_id'] = equipment.id vals['equipment_id'] = equipment.id
return super(OwsMachine, self).create(vals_list) return super(OwsMachine, self).create(vals)
def write(self, vals): def write(self, vals):
""" """
Bei Updates: Bei Updates:
1. Name/Serial_no Equipment synchronisieren 1. Area Location synchronisieren
2. Name/Serial_no Equipment synchronisieren
""" """
# Area → Location Mapping
if 'area_id' in vals and vals['area_id']:
area = self.env['ows.machine.area'].browse(vals['area_id'])
if area and area.name:
vals['location'] = area.name
# Name/Serial_no an Equipment weiterleiten # Name/Serial_no an Equipment weiterleiten
equipment_vals = {} equipment_vals = {}
if 'name' in vals: if 'name' in vals:
@ -778,14 +666,6 @@ class OwsMachineProduct(models.Model):
product_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade') product_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade') machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
def name_get(self):
"""Zeigt den Produktnamen statt ows.machine.product,ID"""
result = []
for record in self:
name = record.product_id.name if record.product_id else 'Unbenannt'
result.append((record.id, name))
return result
class OwsMachineTraining(models.Model): class OwsMachineTraining(models.Model):
_name = 'ows.machine.training' _name = 'ows.machine.training'
_table = 'ows_machine_training' _table = 'ows_machine_training'
@ -793,11 +673,3 @@ class OwsMachineTraining(models.Model):
training_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade') training_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade') machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
def name_get(self):
"""Zeigt den Produktnamen statt ows.machine.training,ID"""
result = []
for record in self:
name = record.training_id.name if record.training_id else 'Unbenannt'
result.append((record.id, name))
return result

View File

@ -0,0 +1,50 @@
from odoo import models, fields, api
from collections import defaultdict
#import debugpy
import logging
_logger = logging.getLogger(__name__)
_logger.info("✅ pos_order.py geladen Test 2")
#debugpy.listen(("0.0.0.0", 5678))
print("✅ debugpy wartet auf Verbindung (Port 5678) ...")
# Optional: Starte erst, wenn VS Code verbunden ist
#debugpy.wait_for_client()
class PosOrder(models.Model):
_inherit = 'pos.order'
def _process_order(self, order, existing_order):
_logger.info("🚨 DEBUG: _process_order wurde aufgerufen mit order: %s", order.get('name', 'unbekannt'))
pos_order_id = super(PosOrder, self)._process_order(order, existing_order)
pos_order = self.browse(pos_order_id)
training_products = self.env['ows.machine.training'].search([])
product_map = defaultdict(list)
for tp in training_products:
product_map[tp.training_id.product_tmpl_id.id].append(tp.machine_id.id)
partner = pos_order.partner_id
if not partner:
_logger.info("🟡 POS-Bestellung ohne Partner keine Freigabe möglich")
return pos_order_id
for line in pos_order.lines:
product_tmpl_id = line.product_id.product_tmpl_id.id
machine_ids = product_map.get(product_tmpl_id, [])
_logger.info("🔍 Prüfe Produkt %s → Maschinen IDs: %s", line.product_id.display_name, machine_ids)
for machine_id in machine_ids:
already_exists = self.env['ows.machine.access'].search([
('partner_id', '=', partner.id),
('machine_id', '=', machine_id)
], limit=1)
if not already_exists:
self.env['ows.machine.access'].create({
'partner_id': partner.id,
'machine_id': machine_id,
'granted_by_pos': True
})
_logger.info("✅ Maschinenfreigabe erstellt: %s für %s", machine_id, partner.name)
return pos_order_id

View File

@ -7,6 +7,9 @@
<field name="view_mode">list,form</field> <field name="view_mode">list,form</field>
</record> </record>
<!-- Menüpunkt unter Maschinen > Konfiguration -->
<menuitem id="menu_machine_area" name="Bereiche" parent="menu_machine_config" action="open_workshop_base.action_machine_area_list" sequence="30"/>
<!-- Listenansicht --> <!-- Listenansicht -->
<record id="view_machine_area_tree" model="ir.ui.view"> <record id="view_machine_area_tree" model="ir.ui.view">
<field name="name">ows.machine.area.tree</field> <field name="name">ows.machine.area.tree</field>

View File

@ -1,5 +1,5 @@
<odoo> <odoo>
<!-- Tree View: Nutzungsprodukte (normale Ansicht) --> <!-- Tree View: Nutzungsprodukte -->
<record id="view_machine_product_tree" model="ir.ui.view"> <record id="view_machine_product_tree" model="ir.ui.view">
<field name="name">ows.machine.product.tree</field> <field name="name">ows.machine.product.tree</field>
<field name="model">ows.machine.product</field> <field name="model">ows.machine.product</field>
@ -11,19 +11,7 @@
</field> </field>
</record> </record>
<!-- Tree View: Nutzungsprodukte (vereinfacht für Equipment) --> <!-- Tree View: Einweisungsprodukte -->
<record id="view_ows_machine_product_tree_simple" model="ir.ui.view">
<field name="name">ows.machine.product.tree.simple</field>
<field name="model">ows.machine.product</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="machine_id" column_invisible="1"/>
<field name="product_id" domain="[('categ_id.name', '=', 'Maschinennutzung')]"/>
</list>
</field>
</record>
<!-- Tree View: Einweisungsprodukte (normale Ansicht) -->
<record id="view_machine_training_tree" model="ir.ui.view"> <record id="view_machine_training_tree" model="ir.ui.view">
<field name="name">ows.machine.training.tree</field> <field name="name">ows.machine.training.tree</field>
<field name="model">ows.machine.training</field> <field name="model">ows.machine.training</field>
@ -35,18 +23,6 @@
</field> </field>
</record> </record>
<!-- Tree View: Einweisungsprodukte (vereinfacht für Equipment) -->
<record id="view_ows_machine_training_tree_simple" model="ir.ui.view">
<field name="name">ows.machine.training.tree.simple</field>
<field name="model">ows.machine.training</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="machine_id" column_invisible="1"/>
<field name="training_id" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]"/>
</list>
</field>
</record>
<!-- Action: Nutzungsprodukte --> <!-- Action: Nutzungsprodukte -->
<record id="action_machine_product" model="ir.actions.act_window"> <record id="action_machine_product" model="ir.actions.act_window">
<field name="name">Maschinen-Nutzungsprodukte</field> <field name="name">Maschinen-Nutzungsprodukte</field>

View File

@ -6,27 +6,20 @@
<field name="model">ows.machine</field> <field name="model">ows.machine</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<search string="Ausrüstung suchen"> <search string="Ausrüstung suchen">
<!-- OWS Felder -->
<field name="name" string="Name"/> <field name="name" string="Name"/>
<field name="serial_no" string="Code"/> <field name="serial_no" string="Code"/>
<field name="area_id" string="Bereich"/> <field name="area_id" string="Bereich"/>
<!-- maintenance.equipment Felder (via _inherits) -->
<field name="model" string="Modell"/>
<field name="partner_id" string="Hersteller"/>
<field name="location" string="Standort"/>
<field name="company_id" string="Unternehmen"/>
<separator/> <separator/>
<filter string="Kategorie grün" name="filter_green" domain="[('category', '=', 'green')]"/> <filter string="Kategorie 1 (grün)" name="filter_green" domain="[('category', '=', 'green')]"/>
<filter string="Kategorie gelb" name="filter_yellow" domain="[('category', '=', 'yellow')]"/> <filter string="Kategorie 2 (gelb)" name="filter_yellow" domain="[('category', '=', 'yellow')]"/>
<filter string="Kategorie rot" name="filter_red" domain="[('category', '=', 'red')]"/> <filter string="Kategorie 3 (rot)" name="filter_red" domain="[('category', '=', 'red')]"/>
<separator/> <separator/>
<filter string="Aktiv" name="filter_active" domain="[('active', '=', True)]"/> <filter string="Aktiv" name="filter_active" domain="[('active', '=', True)]"/>
<filter string="Archiviert" name="filter_inactive" domain="[('active', '=', False)]"/> <filter string="Archiviert" name="filter_inactive" domain="[('active', '=', False)]"/>
<separator/> <separator/>
<group expand="1" string="Gruppieren nach"> <group expand="0" string="Gruppieren nach">
<filter string="Bereich" name="group_area" context="{'group_by': 'area_id'}"/> <filter string="Bereich" name="group_area" context="{'group_by': 'area_id'}"/>
<filter string="Kategorie" name="group_category" context="{'group_by': 'category'}"/> <filter string="Kategorie" name="group_category" context="{'group_by': 'category'}"/>
</group> </group>
</search> </search>
</field> </field>
@ -40,7 +33,7 @@
<list sample="1" multi_edit="1"> <list sample="1" multi_edit="1">
<field name="category_icon" string="⚙" readonly="1"/> <field name="category_icon" string="⚙" readonly="1"/>
<field name="name" optional="show"/> <field name="name" optional="show"/>
<!--field name="serial_no" string="Code" optional="show"/--> <field name="serial_no" string="Code" optional="show"/>
<field name="category" optional="show"/> <field name="category" optional="show"/>
<field name="area_id" widget="many2one" optional="show"/> <field name="area_id" widget="many2one" optional="show"/>
<field name="location" optional="hide"/> <field name="location" optional="hide"/>

View File

@ -1,80 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<!-- Erweitere Equipment Form mit OWS-Feldern --> <!-- Inherit maintenance.equipment form view to make location readonly -->
<record id="view_equipment_form_ows_extension" model="ir.ui.view"> <record id="view_equipment_form_location_readonly" model="ir.ui.view">
<field name="name">maintenance.equipment.form.ows</field> <field name="name">maintenance.equipment.form.location.readonly</field>
<field name="model">maintenance.equipment</field> <field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/> <field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<!-- Füge OWS-Felder in die Details-Gruppe ein --> <xpath expr="//field[@name='location']" position="attributes">
<xpath expr="//field[@name='location']" position="after"> <attribute name="readonly">1</attribute>
<field name="ows_area_id" string="Bereich"/> <attribute name="help">Wird automatisch aus dem Bereich (ows.machine) synchronisiert. Änderungen nur über Bereich möglich.</attribute>
<field name="ows_category" string="Sicherheitskategorie"/>
</xpath>
<!-- Füge Open Workshop Notebook-Page hinzu -->
<xpath expr="//notebook" position="inside">
<page string="Offene Werkstatt (Hobbyhimmel)" name="open_workshop" invisible="not ows_machine_id">
<group>
<group string="Sicherheit">
<field name="ows_machine_id" invisible="1"/>
<field name="ows_category_icon" readonly="1"/>
<field name="ows_category"/>
<field name="ows_area_id"/>
</group>
</group>
<group string="Nutzungsprodukte">
<field name="ows_usage_product_ids" nolabel="1" widget="many2many_tags" options="{'no_create': True}" domain="[('categ_id.name', '=', 'Maschinennutzung')]"/>
</group>
<group string="Einweisungsprodukte">
<field name="ows_training_product_ids" nolabel="1" widget="many2many_tags" options="{'no_create': True}" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]"/>
</group>
</page>
</xpath>
</field>
</record>
<!-- Erweitere Equipment List-View mit OWS-Spalten -->
<record id="view_equipment_tree_ows_extension" model="ir.ui.view">
<field name="name">maintenance.equipment.tree.ows</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="ows_category_icon" string="⚙" optional="show"/>
<field name="ows_area_id" optional="show"/>
</xpath>
</field>
</record>
<!-- Erweitere Equipment Search-View mit OWS-Filtern -->
<record id="view_equipment_search_ows_extension" model="ir.ui.view">
<field name="name">maintenance.equipment.search.ows</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_search"/>
<field name="arch" type="xml">
<!-- Füge OWS-Felder zur Suche hinzu -->
<xpath expr="//field[@name='name']" position="after">
<field name="ows_area_id" string="Bereich"/>
<field name="ows_category" string="Kategorie"/>
</xpath>
<!-- Füge OWS-Filter hinzu -->
<xpath expr="//filter[@name='inactive']" position="after">
<separator/>
<filter string="Kategorie 1 (grün)" name="filter_green" domain="[('ows_category', '=', 'green')]"/>
<filter string="Kategorie 2 (gelb)" name="filter_yellow" domain="[('ows_category', '=', 'yellow')]"/>
<filter string="Kategorie 3 (rot)" name="filter_red" domain="[('ows_category', '=', 'red')]"/>
</xpath>
<!-- Füge OWS-Gruppierungen hinzu (erstelle group wenn nicht vorhanden) -->
<xpath expr="//search" position="inside">
<group expand="0" string="Gruppieren nach">
<filter string="Bereich" name="group_by_area" context="{'group_by': 'ows_area_id'}"/>
<filter string="Kategorie" name="group_by_category" context="{'group_by': 'ows_category'}"/>
</group>
</xpath> </xpath>
</field> </field>
</record> </record>
</odoo> </odoo>

View File

@ -1,15 +1,10 @@
<!-- menu_views.xml --> <!-- menu_views.xml -->
<odoo> <odoo>
<!-- Equipment-Liste mit OWS-Filter (ersetzt Standard Equipment Action) --> <!-- Maschinenliste -->
<record id="action_equipment_ows" model="ir.actions.act_window"> <record id="action_machine_list" model="ir.actions.act_window">
<field name="name">Ausrüstung</field> <field name="name">Ausrüstung</field>
<field name="res_model">maintenance.equipment</field> <field name="res_model">ows.machine</field>
<field name="view_mode">list,form</field> <field name="view_mode">list,form</field>
<field name="domain">[('ows_machine_id', '!=', False)]</field>
<field name="context">{
'default_ows_category': 'red',
'search_default_group_by_area': 1
}</field>
</record> </record>
<!-- Trainingsprodukt-Liste --> <!-- Trainingsprodukt-Liste -->
@ -19,32 +14,42 @@
<field name="view_mode">list,form</field> <field name="view_mode">list,form</field>
</record> </record>
<!-- Integration in Maintenance Menü --> <!-- Menüstruktur -->
<!-- Oberstes Menü -->
<menuitem id="menu_machine_root"
name="Ausrüstung"
sequence="10"/>
<!-- Bereiche unter Maintenance → Configuration --> <!-- Konfigurationsebene -->
<menuitem id="menu_maintenance_ows_areas" <menuitem id="menu_machine_config"
name="Bereiche" name="Konfiguration"
parent="maintenance.menu_maintenance_config" parent="menu_machine_root"
action="action_machine_area_list" sequence="10"/>
sequence="50"/>
<!-- Zuordnungen Container unter Maintenance → Configuration --> <!-- Menüpunkt: Maschinenliste (klickbar) -->
<menuitem id="menu_maintenance_ows_assignments" <menuitem id="menu_machine_list_action"
name="Alle Ausrüstung"
parent="menu_machine_config"
action="open_workshop_base.action_machine_list"
sequence="10"/>
<!-- Menücontainer: Zuordnungen -->
<menuitem id="menu_machine_list"
name="Zuordnungen" name="Zuordnungen"
parent="maintenance.menu_maintenance_config" parent="menu_machine_config"
sequence="51"/> sequence="20"/>
<!-- Nutzungsprodukte unter Zuordnungen --> <!-- Untermenü: Nutzungsprodukte -->
<menuitem id="menu_maintenance_machine_product" <menuitem id="menu_machine_product"
name="Nutzungsprodukte" name="Nutzungsprodukte"
parent="menu_maintenance_ows_assignments" parent="menu_machine_list"
action="action_machine_product" action="action_machine_product"
sequence="10"/> sequence="10"/>
<!-- Einweisungsprodukte unter Zuordnungen --> <!-- Untermenü: Einweisungsprodukte -->
<menuitem id="menu_maintenance_machine_training" <menuitem id="menu_machine_training"
name="Einweisungsprodukte" name="Einweisungsprodukte"
parent="menu_maintenance_ows_assignments" parent="menu_machine_list"
action="action_machine_training" action="action_machine_training"
sequence="20"/> sequence="20"/>

View File

@ -1,456 +0,0 @@
# Equipment Übersichtstabelle - Setup
## Voraussetzungen
Das **datatables Plugin** muss in DokuWiki installiert sein:
```bash
# Als DokuWiki Admin: Administration → Erweiterungsverwaltung
# Suche: "datatables"
# Installiere: "DataTables Plugin" von Matthias Schulte
```
Das **include Plugin** muss in DokuWiki installiert sein (für Einbindung von Odoo-Status-Seiten):
```bash
# Als DokuWiki Admin: Administration → Erweiterungsverwaltung
# Suche: "include"
# Installiere: "Include Plugin" - sollte standardmäßig vorhanden sein
```
## Setup-Schritte
### 1. Odoo Konfiguration
Die Übersichtstabelle wird vollständig aus Odoo heraus konfiguriert - **kein DokuWiki Template nötig!**
Gehe zu: **Einstellungen → Technisch → Parameter → Systemparameter**
Standardwerte (automatisch gesetzt beim Modul-Install):
```python
# Basis-Namespace für Equipment-Dokumentation (konfigurierbar)
dokuwiki.equipment_namespace = werkstatt:ausstattung
# Seiten-ID wo die Übersicht erstellt wird (konfigurierbar)
dokuwiki.overview_page_id = werkstatt:ausstattung:uebersicht
# Titel der Übersichtsseite
dokuwiki.overview_title = Geräte & Maschinen - Übersicht
# Spaltenüberschriften (Pipe-separiert)
dokuwiki.overview_columns = Status|Sicherheits-Kategorie|Hersteller|Bemerkung|Typ|Bild|Standort|Dokumentation
# Spaltendaten mit Platzhaltern (Pipe-separiert)
dokuwiki.overview_column_data = {status_smiley}|{ows_machine_id.category_icon}|{partner_id}|{note}|{model}|{image}|{ows_machine_id.location}|{wiki_doku_link}
```
### 2. Erste Synchronisation
1. In Odoo: **Wartung → Konfiguration → Wiki-Synchronisation**
2. Wähle: **"Übersichtstabelle aktualisieren"**
3. Klicke: **"Synchronisieren"**
Die Übersichtsseite wird erstellt unter: `werkstatt:ausstattung:uebersicht` (konfigurierbar über Systemparameter)
## Features
### DataTables Funktionen
- ✅ Sortieren nach allen Spalten (Klick auf Header)
- ✅ Filter/Suche über alle Felder
- ✅ Pagination bei vielen Einträgen
- ✅ Responsive Design
### Flexible Spalten
- ✅ Spalten in Odoo konfigurierbar (kein DokuWiki Template)
- ✅ Alle Platzhalter verfügbar (siehe unten)
- ✅ Spalten hinzufügen/entfernen ohne Code-Änderung
- ✅ Änderungen sofort beim nächsten Sync wirksam
### Standard-Spalten
| Spalte | Platzhalter | Ausgabe |
|--------|-------------|---------|
| Status | `{status_smiley}` | 😊 / 😐 / ☹️ |
| Sicherheits-Kategorie | `{ows_machine_id.category_icon}` | 🟢 / 🟡 / 🔴 |
| Hersteller | `{partner_id}` | BOSCH, Festool, ... |
| Bemerkung | `{note}` | Notizen |
| Typ | `{model}` | Modellbezeichnung |
| Bild | `{image}` | Thumbnail 100px |
| Standort | `{ows_machine_id.location}` | Regal A3, ... |
| Dokumentation | `{wiki_doku_link}` | Link zur Detail-Seite |
## Spalten anpassen
### Beispiel 1: Seriennummer hinzufügen
```python
# Spaltenüberschriften erweitern
dokuwiki.overview_columns = Status|Typ|Seriennummer|Hersteller|Standort|Dokumentation
# Spaltendaten erweitern
dokuwiki.overview_column_data = {status_smiley}|{model}|{serial_no}|{partner_id}|{ows_machine_id.location}|{wiki_doku_link}
```
### Beispiel 2: Minimale Ansicht
```python
dokuwiki.overview_columns = Name|Status|Kategorie|Dokumentation
dokuwiki.overview_column_data = {name}|{status_smiley}|{ows_machine_id.category_icon}|{wiki_doku_link}
```
### Beispiel 3: Detaillierte Ansicht mit Kosten
```python
dokuwiki.overview_columns = Name|Hersteller|Modell|Seriennummer|Kosten|Garantie|Standort|Kategorie
dokuwiki.overview_column_data = {name}|{partner_id}|{model}|{serial_no}|{cost}|{warranty_date}|{location}|{ows_machine_id.category_icon}
```
### Beispiel 4: Mit Tags
```python
dokuwiki.overview_columns = Status|Name|Typ|Tags|Standort|Dokumentation
dokuwiki.overview_column_data = {status_smiley}|{name}|{model}|{tags_list}|{location}|{wiki_doku_link}
```
**Wichtig:** Anzahl der Pipes muss übereinstimmen!
- 3 Pipes = 4 Spalten
- Spalten und Daten müssen gleiche Anzahl haben
## Performance
- ⚡ Schneller Sync: Keine DB-Iteration über alle Einträge
- ⚡ Generiert nur eine Seite (statt 156 einzelne)
- ⚡ DokuWiki cached die Seite automatisch
- ⚡ Spalten-Änderung ohne Code-Deploy
## Troubleshooting
### DataTables funktioniert nicht
→ Installiere DataTables Plugin in DokuWiki
### Bilder werden nicht angezeigt
→ Prüfe ob Equipment `image_1920` Feld gesetzt hat
→ Bilder müssen zuerst via "Alle Equipment" Sync hochgeladen werden
### Equipment fehlen in Tabelle
→ Nur Equipment mit gesetztem `ows_area_id` werden angezeigt
→ Prüfe Filter in `action_sync_overview_table()`
### Spalten werden nicht korrekt dargestellt
→ Prüfe ob Anzahl Pipes in `overview_columns` und `overview_column_data` übereinstimmt
→ Beispiel: 3 Pipes = 4 Spalten
### Platzhalter wird nicht ersetzt (z.B. `{status}` bleibt stehen)
→ Prüfe Schreibweise (case-sensitive!)
→ Siehe Liste der verfügbaren Platzhalter unten
→ Bei `ows.machine` Feldern: `{ows_machine_id.feldname}` verwenden
## Workflow
```
┌─────────────────┐
│ Odoo Systempar. │
│ - Spalten │
│ - Platzhalter │
└────────┬────────┘
┌─────────────────────────┐
│ Odoo Equipment │
│ (156 Einträge) │
└────────┬────────────────┘
┌─────────────────────────┐
│ _generate_overview_ │
│ table_row() │ ← Pro Equipment eine Zeile
│ (Platzhalter ersetzen) │ mit Platzhalter-Engine
└────────┬────────────────┘
│ 156 Zeilen
┌─────────────────────────┐
│ Seite zusammenbauen │
│ - Titel │
│ - DataTables Header │
│ - Zeilen │
│ - Footer │
└────────┬────────────────┘
┌─────────────────────────┐
│ Seite speichern │
│ werkstatt:ausstattung: │
│ uebersicht │
└─────────────────────────┘
```
## Automatische Updates
Die Übersichtstabelle kann optional automatisch bei Equipment-Änderungen aktualisiert werden.
### Manueller Sync (empfohlen)
**Manueller Sync empfohlen für:**
- Nach Massen-Importen
- Einmal täglich/wöchentlich
- Bei strukturellen Änderungen (neue Bereiche, etc.)
**Vorteil:** Performanter, keine zusätzliche Last bei jeder Equipment-Änderung
### Automatischer Sync bei Equipment-Änderungen
**Was passiert beim Speichern eines Equipment?**
1. **Equipment-Detailseite wird immer aktualisiert** wenn:
- Eines der überwachten Felder geändert wurde (siehe unten)
- `wiki_auto_sync` für das Equipment aktiviert ist (Standard: `True`)
- Equipment bereits synchronisiert wurde (`wiki_synced = True`)
2. **Übersichtstabelle wird zusätzlich aktualisiert** wenn:
- Alle Bedingungen von (1) erfüllt sind UND
- Systemparameter `dokuwiki.auto_update_overview_table = True` gesetzt ist
**Überwachte Felder (lösen Sync aus):**
- `name`, `serial_no`, `ows_area_id`, `category_id`, `status_id`
- `model`, `partner_id`, `location`, `note`, `image_1920`, `tag_ids`
**Aktivierung Übersichtstabellen-Sync:**
```python
# In Odoo: Einstellungen → Technisch → Parameter → Systemparameter
dokuwiki.auto_update_overview_table = True
```
**Achtung:**
- Bei vielen gleichzeitigen Equipment-Änderungen kann dies Performance-Probleme verursachen!
- Die komplette Übersichtstabelle wird bei jeder Änderung neu generiert (ca. 156 Zeilen)
### Cronjob (Alternative)
```python
# In Odoo: Einstellungen → Technisch → Automatisierung → Geplante Aktionen
Name: Wiki Übersichtstabelle Update
Modell: maintenance.equipment
Aktion: action_sync_overview_table()
Intervall: Täglich um 02:00 Uhr
```
---
# Verfügbare Platzhalter
Alle Platzhalter aus dem Detail-Template sind verfügbar!
## Basis-Felder (maintenance.equipment)
- `{name}` - Equipment-Name
- `{serial_no}` - Seriennummer
- `{model}` - Modell
- `{category}` - Kategoriename
- `{status}` - Status (aus status_id)
- `{status_smiley}` - Status als Smiley (aus status_id.smiley Feld, z.B. 😊 oder ☹️)
- `{location}` - Standort
- `{ows_area}` - Bereichsname
- `{assign_date}` - Zuweisungsdatum (formatiert)
- `{cost}` - Kosten
- `{warranty_date}` - Garantiedatum (formatiert)
- `{note}` - Notizen
- `{partner_id}` - Lieferant (Name)
- `{partner_ref}` - Lieferanten-Referenz
## Tags (falls maintenance_equipment_tags installiert)
- `{tags}` - Komma-separierte Liste aller Tags (z.B. "Holz, CNC, Einweisung erforderlich")
- `{tags_list}` - DokuWiki Bullet-Liste mit Zeilenumbrüchen (ideal für Tabellenzellen)
- `{tags_count}` - Anzahl der zugewiesenen Tags
## Spezial-Felder
- `{view_type}` - "Bereich" oder "Einsatzzweck"
- `{view_name}` - Name des Bereichs/Einsatzzwecks
- `{wiki_doku_page}` - ID der zentralen Doku-Seite
- `{wiki_doku_link}` - Fertiger Link zur zentralen Doku-Seite
- `{odoo_link}` - Fertiger Link zur Odoo Equipment-Seite
- `{odoo_url}` - URL zur Odoo Equipment-Seite (ohne Link-Markup)
- `{sync_datetime}` - Aktuelles Datum/Zeit
- `{image}` - Equipment-Bild (100px Breite) - Format: `{{:media_id?100}}`
- `{image_large}` - Equipment-Bild (Originalgröße) - Format: `{{:media_id}}`
- `{image_id}` - Media-ID des Bildes (z.B. werkstatt:ausruestung:media:sabako-laser.jpg)
## ows.machine Felder (falls verknüpft)
- `{ows_machine_id.name}` - Name
- `{ows_machine_id.model}` - Modell
- `{ows_machine_id.serial_no}` - Seriennummer
- `{ows_machine_id.location}` - Standort
- `{ows_machine_id.note}` - Notizen
- `{ows_machine_id.category}` - Sicherheitskategorie (red/yellow/green)
- `{ows_machine_id.category_icon}` - Kategorie-Icon als Emoji (🔴/🟡/🟢)
## Tipps für Platzhalter
- **Case-sensitive**: `{Name}` funktioniert nicht, nur `{name}`
- **Pipe als Trenner**: Zwischen Spalten `|` verwenden
- **Leere Werte**: Werden automatisch durch `-` ersetzt
- **Links**: `{wiki_doku_link}` und `{odoo_link}` enthalten bereits DokuWiki Link-Syntax
- **Bilder**: `{image}` ist bereits formatiert mit `{{:...?100}}`
- **ows.machine**: Immer mit Präfix `ows_machine_id.` (z.B. `{ows_machine_id.location}`)
## Beispiel-Konfigurationen
### Minimalistische Ansicht
```
Spalten: Name|Status|Kategorie
Daten: {name}|{status_smiley}|{ows_machine_id.category_icon}
```
### Standard-Ansicht (Default)
```
Spalten: Status|Sicherheits-Kategorie|Hersteller|Bemerkung|Typ|Bild|Standort|Dokumentation
Daten: {status_smiley}|{ows_machine_id.category_icon}|{partner_id}|{note}|{model}|{image}|{ows_machine_id.location}|{wiki_doku_link}
```
### Vollständige Ansicht
```
Spalten: Name|Status|Kategorie|Hersteller|Modell|S/N|Kosten|Garantie|Standort|Bereich|Bild|Doku
Daten: {name}|{status_smiley}|{ows_machine_id.category_icon}|{partner_id}|{model}|{serial_no}|{cost}|{warranty_date}|{location}|{ows_area}|{image}|{wiki_doku_link}
```
### Mit Tags
```
Spalten: Name|Status|Tags|Hersteller|Standort|Doku
Daten: {name}|{status_smiley}|{tags_list}|{partner_id}|{location}|{wiki_doku_link}
```
---
# Best Practices: DokuWiki Seitenstruktur
## Erweiterbare Dokumentation ohne Umbenennung
### Problem
Benutzer möchten zu Equipment-Seiten eigene Unterseiten hinzufügen, ohne die von Odoo generierten Seiten umbenennen zu müssen.
### Lösung: Parallele Seite + Namespace
DokuWiki erlaubt **gleichzeitig** eine Seite und einen gleichnamigen Namespace:
- Datei: `equipment.txt` (von Odoo generiert)
- Verzeichnis: `equipment/` (für Benutzer-Unterseiten)
**Die Seite `equipment.txt` hat Vorrang und bleibt die Haupt-Equipment-Seite!**
### Empfohlene Struktur
```
werkstatt/
└── ausstattung/
├── uebersicht # VON ODOO GENERIERT: Übersichtstabelle
├── odoo-status/ # VON ODOO GENERIERT: Nur-Lese Seiten (nur odoo.odoo schreibt)
│ ├── c_template.txt # Template für Equipment-Status-Seiten
│ ├── analog-oscilloscope-hm303-6.txt # Status-Seite für include-Plugin
│ ├── cnc-fraese-xyz.txt # Status-Seite für include-Plugin
│ └── ...
└── {Bereich Name}/ # VON BENUTZER ERSTELLT: Namespace wird von Odoo festgelegt
├── analog-oscilloscope-hm303-6.txt # Benutzer erstellt diese Seite manuell (Klick auf Link in Übersicht)
├── analog-oscilloscope-hm303-6/ # Benutzer-Unterseiten (optional)
│ ├── kalibrierung.txt
│ ├── messungen.txt
│ └── bilder/
│ └── oszillogramme.txt
├── cnc-fraese-xyz.txt # Benutzer erstellt diese Seite manuell (Klick auf Link in Übersicht)
└── cnc-fraese-xyz/ # Benutzer-Unterseiten (optional)
├── programme.txt
└── werkzeuge.txt
```
**Wichtig:**
- **Odoo erstellt NUR:** `{overview_page_id}` (Übersichtstabelle) und `odoo-status/*.txt` (Status-Seiten)
- **Odoo erstellt NIEMALS:** Seiten in `{Bereich Name}/` - diese werden ausschließlich von Benutzern erstellt
- **Workflow:** Übersichtstabelle verlinkt auf `{Bereich Name}/equipment-name.txt` → Link ist rot (Seite existiert nicht) → Benutzer klickt darauf → DokuWiki bietet "Seite erstellen" an → Benutzer fügt `{{page>odoo-status:equipment-name}}` ein
- **Berechtigungen:** `odoo-status/` Namespace hat spezielle Berechtigungen (nur odoo.odoo kann schreiben)
### Wichtige Regeln
#### ✅ DO: Unterverzeichnis OHNE start.txt
```
equipment/
├── subpage1.txt
├── subpage2.txt
└── weitere/
└── details.txt
```
**Vorteile:**
- Breadcrumb zeigt: `Home » Werkstatt » Ausrüstung » Equipment » Subpage1`
- Keine Duplikation oder Verwirrung
- Equipment-Hauptseite bleibt `equipment.txt`
#### ❌ DON'T: Unterverzeichnis MIT start.txt
```
equipment/
├── start.txt # ❌ NICHT erstellen!
├── subpage1.txt
└── subpage2.txt
```
**Probleme:**
- Breadcrumb wird verwirrend: `Equipment` könnte auf zwei Seiten verweisen
- `equipment` und `equipment:start` zeigen unterschiedliche Inhalte
- Benutzer wissen nicht, welche Seite die "richtige" ist
### Include-Plugin für Odoo-Status einbinden
**Wichtig:** Diese Seite wird NICHT von Odoo erstellt! Der Benutzer erstellt sie manuell nach Klick auf den Link in der Übersichtstabelle.
In der Benutzer-Dokumentationsseite wird der Odoo-generierte Status eingebunden:
```dokuwiki
===== Analog Oscilloscope HM303-6 =====
==== Odoo Status ====
{{page>werkstatt:ausstattung:odoo-status:analog-oscilloscope-hm303-6}}
==== Eigene Dokumentation ====
Hier kann der Benutzer seine eigenen Inhalte hinzufügen...
==== Weitere Dokumentation ====
<catlist .:analog-oscilloscope-hm303-6 -noNSInBold -sortByTitle>
```
**Syntax-Erklärung:**
- `{{page>...}}` → Bindet Odoo-Status-Seite ein (nur lesbar für Benutzer)
- `.:namespace` → Relativer Namespace für catlist (Punkt + Doppelpunkt!)
- `-noNSInBold` → Namespace-Präfix nicht fett darstellen
- `-sortByTitle` → Alphabetisch sortieren
- `-exclude:{ns1 ns2}` → Optional: Namespaces ausschließen
**Ergebnis:**
- Odoo-Status wird direkt in Benutzer-Seite eingebettet
- Benutzer sehen aktuelle Odoo-Daten ohne direkt auf odoo-status zuzugreifen
- Catlist zeigt alle Benutzer-Unterseiten im `equipment/` Verzeichnis
- Automatisch aktualisiert wenn Benutzer neue Seiten erstellen
- Kein manuelles Pflegen von Links nötig
### Workflow
1. **Odoo synchronisiert** → Erstellt/aktualisiert `{overview_page_id}` (Übersichtstabelle) und `odoo-status/equipment.txt` (Status-Seiten)
2. **Benutzer sieht Übersichtstabelle** → Equipment sind verlinkt, aber Links sind rot (Seiten existieren nicht)
3. **Benutzer klickt roten Link** → DokuWiki zeigt "Diese Seite existiert noch nicht - erstellen?"
4. **Benutzer erstellt Seite** → Fügt `{{page>werkstatt:ausstattung:odoo-status:equipment-name}}` ein und eigene Inhalte
5. **Benutzer erstellt optional Unterseiten** → Erstellt Verzeichnis `equipment/` für weitere Dokumentation
6. **Catlist zeigt automatisch Unterseiten** → Wenn in Hauptseite eingebunden
7. **Keine Konflikte** → Odoo schreibt NIEMALS in `{Bereich Name}/`, nur in `start` und `odoo-status/`
### Vorteile dieser Struktur
- ✅ **Klare Trennung:** `odoo-status/` (nur Odoo schreibt) vs. `{Bereich Name}/` (nur Benutzer schreiben)
- ✅ **Berechtigungsschutz:** Benutzer können Odoo-Daten nicht versehentlich überschreiben
- ✅ **Keine Überschreibgefahr:** Odoo erstellt niemals Seiten in Benutzer-Namespaces
- ✅ **Einfaches Einbinden:** Ein `{{page>...}}` in Benutzer-Seite, fertig
- ✅ **Keine Namenskollisionen:** Odoo und Benutzer haben komplett getrennte Namespaces
- ✅ **Benutzer entscheiden:** Nur wer dokumentieren will, erstellt eine Seite (über Link in Übersichtstabelle)
- ✅ **Flexibel erweiterbar:** Benutzer können beliebig viele Unterseiten erstellen
- ✅ **Automatische Updates:** Odoo-Status wird in alle einbindenden Seiten propagiert
- ✅ **Konfigurierbarer Namespace:** Basis-Pfad über Systemparameter `dokuwiki.equipment_namespace` anpassbar

View File

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

View File

@ -1,68 +0,0 @@
# -*- coding: utf-8 -*-
{
'name': 'Open Workshop - DokuWiki Integration',
'version': '18.0.2.1.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
* Übersichtstabelle aller Equipment (DataTables mit Sortierung/Filterung)
* Status-Seiten für Include-Plugin (nur von Odoo generiert, nur lesbar)
* Benutzer erstellen eigene Dokumentationsseiten und binden Odoo-Status ein
* Smart Button "Wiki öffnen" im Equipment-Formular
* Automatische Synchronisation bei Equipment-Änderungen (optional)
Architektur:
------------
* Odoo generiert NUR: {overview_page_id} (Übersichtstabelle) und odoo-status/ Seiten
* Odoo generiert NIEMALS: Benutzer-Dokumentationsseiten in {bereich}/ Namespaces
* Namespace-Struktur (konfigurierbar über dokuwiki.equipment_namespace):
- {equipment_namespace}:{overview_page_id} - Übersichtstabelle (von Odoo)
- {equipment_namespace}:odoo-status:{equipment_id} - Status-Seiten (von Odoo, nur lesbar)
- {equipment_namespace}:odoo-status:c_template - Template für Status-Seiten
- {equipment_namespace}:{bereich}:{equipment_id} - Benutzer-Dokumentation (von Benutzern erstellt!)
Workflow:
---------
1. Odoo synchronisiert Equipment erstellt odoo-status/ Seiten
2. Übersichtstabelle verlinkt auf {bereich}/{equipment_id} Link ist rot (existiert nicht)
3. Benutzer klickt roten Link DokuWiki: "Seite erstellen?"
4. Benutzer erstellt Seite und fügt {{page>odoo-status:equipment_id}} ein
ACL-Empfehlung:
---------------
* odoo-status/ Namespace: Nur odoo.odoo kann schreiben, alle anderen nur lesen
* {bereich}/ Namespaces: Benutzer haben volle Schreibrechte
""",
'author': 'Hobbyhimmel',
'website': 'https://hobbyhimmel.de',
'license': 'LGPL-3',
'depends': [
'open_workshop_base',
'maintenance',
'maintenance_equipment_status',
],
'external_dependencies': {
'python': ['dokuwiki'],
},
'data': [
'security/ir.model.access.csv',
'data/ir_config_parameter.xml',
'data/maintenance_equipment_status_data.xml',
'views/maintenance_equipment_views.xml',
'views/maintenance_equipment_status_views.xml',
'wizard/equipment_wiki_sync_wizard_views.xml',
],
'post_init_hook': 'post_init_hook',
'pre_init_hook': 'pre_init_hook',
'installable': True,
'application': False,
'auto_install': False,
}

View File

@ -1,8 +0,0 @@
<?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

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- DokuWiki Smiley-Werte für Status aus open_workshop_base -->
<record id="open_workshop_base.equipment_status_inbetrieb" model="maintenance.equipment.status">
<field name="smiley">😊</field>
</record>
<record id="open_workshop_base.equipment_status_defekt" model="maintenance.equipment.status">
<field name="smiley">☹️</field>
</record>
<record id="open_workshop_base.equipment_status_wartung" model="maintenance.equipment.status">
<field name="smiley">🛠️</field>
</record>
<record id="open_workshop_base.equipment_status_ausgemustert" model="maintenance.equipment.status">
<field name="smiley"></field>
</record>
</odoo>

View File

@ -1,15 +0,0 @@
# -*- coding: utf-8 -*-
def pre_init_hook(env):
"""
Hook der VOR der Modul-Installation/Update ausgeführt wird.
"""
pass
def post_init_hook(env):
"""
Hook der nach der Modul-Installation/Update ausgeführt wird.
Initialisiert die DokuWiki-Parameter falls sie nicht existieren.
"""
env['res.config.settings']._init_dokuwiki_parameters()

View File

@ -1,101 +0,0 @@
# -*- coding: utf-8 -*-
import logging
from psycopg2 import sql
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Fügt die neuen Wiki-ID-Felder zur maintenance_equipment Tabelle hinzu.
Args:
cr: Database cursor
version: Aktuelle Version vor dem Upgrade (sollte 18.0.2.0.0 sein)
"""
_logger.info("=" * 80)
_logger.info("PRE-MIGRATION SCRIPT: open_workshop_dokuwiki 18.0.2.1.0")
_logger.info("Aktuelle Version: %s", version)
_logger.info("=" * 80)
try:
# Prüfe ob die Tabelle maintenance_equipment existiert
cr.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'maintenance_equipment'
)
""")
table_exists = cr.fetchone()[0]
if not table_exists:
_logger.warning("Tabelle maintenance_equipment existiert nicht - überspringe Migration")
return
_logger.info("Tabelle maintenance_equipment gefunden")
# Prüfe ob die Felder bereits existieren
cr.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'maintenance_equipment'
AND column_name IN ('wiki_status_id', 'wiki_doku_id')
""")
existing_fields = [row[0] for row in cr.fetchall()]
_logger.info("Bereits existierende Felder: %s", existing_fields)
# Füge wiki_status_id hinzu falls nicht vorhanden
if 'wiki_status_id' not in existing_fields:
_logger.info("Füge Spalte wiki_status_id hinzu...")
cr.execute("""
ALTER TABLE maintenance_equipment
ADD COLUMN wiki_status_id VARCHAR
""")
cr.commit() # Wichtig: Commit nach jeder Änderung
_logger.info("✓ Spalte wiki_status_id erfolgreich hinzugefügt")
else:
_logger.info("→ Spalte wiki_status_id existiert bereits, überspringe")
# Füge wiki_doku_id hinzu falls nicht vorhanden
if 'wiki_doku_id' not in existing_fields:
_logger.info("Füge Spalte wiki_doku_id hinzu...")
cr.execute("""
ALTER TABLE maintenance_equipment
ADD COLUMN wiki_doku_id VARCHAR
""")
cr.commit() # Wichtig: Commit nach jeder Änderung
_logger.info("✓ Spalte wiki_doku_id erfolgreich hinzugefügt")
else:
_logger.info("→ Spalte wiki_doku_id existiert bereits, überspringe")
# Verifiziere dass beide Felder jetzt existieren
cr.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'maintenance_equipment'
AND column_name IN ('wiki_status_id', 'wiki_doku_id')
ORDER BY column_name
""")
final_fields = [row[0] for row in cr.fetchall()]
_logger.info("Felder nach Migration: %s", final_fields)
if len(final_fields) == 2:
_logger.info("=" * 80)
_logger.info("✓ PRE-MIGRATION ERFOLGREICH ABGESCHLOSSEN")
_logger.info("=" * 80)
else:
_logger.error("!" * 80)
_logger.error("FEHLER: Nicht alle Felder wurden hinzugefügt!")
_logger.error("Erwartet: ['wiki_doku_id', 'wiki_status_id']")
_logger.error("Gefunden: %s", final_fields)
_logger.error("!" * 80)
except Exception as e:
_logger.error("!" * 80)
_logger.error("FEHLER IM PRE-MIGRATION SCRIPT!")
_logger.error("Exception: %s", str(e))
_logger.error("!" * 80)
raise

View File

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

View File

@ -1,392 +0,0 @@
# -*- coding: utf-8 -*-
import logging
from dokuwiki import DokuWiki, DokuWikiError
from odoo import api, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Connection Cache (pro Request wiederverwendet)
_connection_cache = {}
class DokuWikiClient(models.AbstractModel):
"""
Wrapper für DokuWiki XML-RPC API Client.
Managed die Verbindung zum DokuWiki und stellt Methoden für Seitenoperationen bereit.
Performance-Optimierung: Verbindungen werden gecached und wiederverwendet
um den Overhead von XML-RPC Verbindungsaufbauten zu minimieren.
"""
_name = 'dokuwiki.client'
_description = 'DokuWiki API Client'
@api.model
def _get_cache_key(self):
"""
Generiert einen Cache-Key basierend auf den Verbindungsparametern.
Returns:
str: Cache-Key (URL + User)
"""
IrConfigParameter = self.env['ir.config_parameter'].sudo()
wiki_url = IrConfigParameter.get_param('dokuwiki.url', '')
wiki_user = IrConfigParameter.get_param('dokuwiki.user', '')
return f"{wiki_url}:{wiki_user}"
@api.model
def _get_wiki_connection(self, use_cache=True):
"""
Erstellt eine DokuWiki-Verbindung mit Credentials aus System-Parametern.
Verbindungen werden gecached und wiederverwendet für bessere Performance.
Args:
use_cache (bool): Wenn True, wird eine gecachte Verbindung wiederverwendet
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"
)
# URL validieren/normalisieren
if not wiki_url.startswith(('http://', 'https://')):
raise UserError(f"DokuWiki URL muss mit http:// oder https:// beginnen: {wiki_url}")
# Cache-Key generieren
cache_key = self._get_cache_key()
# Gecachte Verbindung wiederverwenden wenn möglich
if use_cache and cache_key in _connection_cache:
try:
# Teste ob Verbindung noch lebt
cached_wiki = _connection_cache[cache_key]
_ = cached_wiki.version # Test-Call
_logger.debug(f"Wiederverwendung gecachter DokuWiki-Verbindung: {wiki_url}")
return cached_wiki
except Exception as e:
_logger.warning(f"Gecachte Verbindung ungültig, erstelle neue: {e}")
del _connection_cache[cache_key]
# Neue Verbindung erstellen
try:
_logger.info(f"Neue DokuWiki-Verbindung: {wiki_url} (User: {wiki_user})")
wiki = DokuWiki(wiki_url, wiki_user, wiki_password)
# Test-Verbindung
version = wiki.version
_logger.info(f"DokuWiki-Verbindung erfolgreich: Version {version}")
# In Cache speichern
if use_cache:
_connection_cache[cache_key] = wiki
_logger.debug(f"Verbindung im Cache gespeichert: {cache_key}")
return wiki
except DokuWikiError as e:
error_details = f"URL: {wiki_url}, User: {wiki_user}, Fehler: {e}"
_logger.error(f"DokuWiki-Verbindung fehlgeschlagen: {error_details}")
raise UserError(
f"Verbindung zu DokuWiki fehlgeschlagen.\n\n"
f"Details:\n"
f"- URL: {wiki_url}\n"
f"- User: {wiki_user}\n"
f"- Fehler: {e}\n\n"
f"Hinweise:\n"
f"- Prüfen Sie ob die URL korrekt ist (sollte auf /lib/exe/xmlrpc.php zeigen)\n"
f"- Stellen Sie sicher, dass XML-RPC in DokuWiki aktiviert ist\n"
f"- Prüfen Sie die Benutzer-Zugangsdaten"
)
except Exception as e:
_logger.error(f"Unerwarteter Fehler bei DokuWiki-Verbindung: {e}", exc_info=True)
raise UserError(f"Unerwarteter Fehler: {e}")
@api.model
def clear_connection_cache(self):
"""
Löscht den Verbindungs-Cache (z.B. nach Konfigurationsänderungen).
"""
global _connection_cache
_connection_cache.clear()
_logger.info("DokuWiki Verbindungs-Cache geleert")
@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:
_logger.info(f"Erstelle/Aktualisiere Wiki-Seite: {page_id} ({len(content)} Zeichen)")
wiki.pages.set(page_id, content, summary=summary)
_logger.info(f"Wiki-Seite erfolgreich erstellt/aktualisiert: {page_id}")
return True
except DokuWikiError as e:
_logger.error(f"DokuWiki API Fehler bei Seite {page_id}: {e}", exc_info=True)
raise UserError(
f"Fehler beim Erstellen der Wiki-Seite '{page_id}':\n\n"
f"{e}\n\n"
f"Mögliche Ursachen:\n"
f"- XML-RPC nicht aktiviert in DokuWiki\n"
f"- Keine Schreibrechte für den Namespace\n"
f"- Ungültige Zeichen in der Page-ID\n"
f"- DokuWiki gibt Fehler/Warnings aus (prüfen Sie die DokuWiki-Logs)"
)
except Exception as e:
_logger.error(f"Unerwarteter Fehler beim Erstellen der Seite {page_id}: {e}", exc_info=True)
raise UserError(f"Unerwarteter Fehler: {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}"
@api.model
def upload_media(self, media_id, file_content, overwrite=True):
"""
Lädt eine Mediendatei (Bild) ins DokuWiki hoch.
Args:
media_id (str): DokuWiki Media-ID (z.B. "werkstatt:ausruestung:equipment_3.jpg")
file_content (bytes): Binärer Dateiinhalt
overwrite (bool): Existierende Datei überschreiben
Returns:
bool: True bei Erfolg
Raises:
UserError: Bei Fehler beim Upload
"""
wiki = self._get_wiki_connection()
import tempfile
import os
import base64
import imghdr
tmp_path = None
# Unterstütze mehrere Eingabetypen:
# - ir.attachment record (Recordset)
# - attachment id (int)
# - rohe Bytes
# - base64-codierter String
try:
content_bytes = None
ext = ''
# ir.attachment Record
if hasattr(file_content, '_name') and getattr(file_content, '_name') == 'ir.attachment':
att = file_content.sudo()
if not att.datas:
raise UserError(f"Attachment {att.id} enthält keine Daten")
content_bytes = base64.b64decode(att.datas)
fname = att.datas_fname or ''
ext = os.path.splitext(fname)[1]
# attachment id
elif isinstance(file_content, int):
att = self.env['ir.attachment'].sudo().browse(file_content)
if not att.exists() or not att.datas:
raise UserError(f"Attachment {file_content} nicht gefunden oder leer")
content_bytes = base64.b64decode(att.datas)
fname = att.datas_fname or ''
ext = os.path.splitext(fname)[1]
# base64 string
elif isinstance(file_content, str):
try:
content_bytes = base64.b64decode(file_content)
except Exception:
# Fallback: treat as empty
raise UserError("Übergebener String konnte nicht als base64 decodiert werden")
ext = os.path.splitext(media_id)[1]
# rohe bytes
else:
# assume bytes-like
content_bytes = file_content
# try extension from media_id
ext = os.path.splitext(media_id)[1]
# Wenn noch keine Extension, versuche Bildtyp zu erkennen
if not ext:
try:
img_type = imghdr.what(None, content_bytes)
if img_type:
ext = f'.{img_type}'
except Exception:
ext = ''
if not ext:
ext = '.bin'
# Sicherstellen, dass ext mit Punkt beginnt
if not ext.startswith('.'):
ext = f'.{ext}'
# Temporäre Datei erstellen (dokuwiki.py erwartet Datei-Pfad)
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_file:
tmp_file.write(content_bytes)
tmp_path = tmp_file.name
try:
wiki.medias.add(media_id, tmp_path, overwrite=overwrite)
_logger.info(f"Mediendatei hochgeladen: {media_id} (tmp={tmp_path})")
return True
except DokuWikiError as e:
error_msg = f"Fehler beim Hochladen der Mediendatei {media_id}: {e}"
_logger.error(error_msg)
raise UserError(error_msg)
finally:
# Temporäre Datei löschen
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except Exception:
_logger.warning(f"Konnte temporäre Datei nicht löschen: {tmp_path}")
@api.model
def get_media_url(self, media_id):
"""
Generiert die vollständige URL zu einer DokuWiki-Mediendatei.
Args:
media_id (str): DokuWiki Media-ID
Returns:
str: Vollständige URL zur Mediendatei
"""
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('/')
# Ersetze : durch / für Media-Pfad
media_path = media_id.replace(':', '/')
return f"{wiki_url}/lib/exe/fetch.php?media={media_id}"

View File

@ -1,974 +0,0 @@
# -*- 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__)
# Cache für Wiki-Templates (wird beim Server-Neustart geleert)
_template_cache = {}
class MaintenanceEquipment(models.Model):
"""
Erweitert das Maintenance Equipment Modell um DokuWiki-Integration.
Diese Klasse ermöglicht die Synchronisation von Werkstatt-Equipment-Daten mit einem
DokuWiki-System. Jedes Equipment erhält eine zentrale Dokumentationsseite sowie
verschiedene Ansichtsseiten (nach Bereich, später auch nach Einsatzzweck).
Hauptfunktionen:
- Automatische Generierung von Wiki-Seiten aus Equipment-Daten
- Template-basierte Wiki-Seiten-Erstellung (verwendet c_template aus DokuWiki)
- Bild-Upload und Einbindung in Wiki-Seiten
- Zentrale Dokumentationsseite pro Equipment (manuell erweiterbar)
- Bereichs-spezifische Übersichtsseiten (automatisch aktualisiert)
- Automatische Übersichtstabelle aller Equipment (DataTable mit Sortierung/Filterung)
- Automatische Synchronisation bei Feldänderungen (optional)
Wiki-Seitenstruktur:
- {equipment_namespace}:{overview_page_id} - Übersichtstabelle (von Odoo generiert)
- {equipment_namespace}:odoo-status:{wiki_doku_id} - Status-Seite (von Odoo generiert, nur lesbar)
- {equipment_namespace}:odoo-status:c_template - Template für Status-Seiten
- {equipment_namespace}:{bereich}:{wiki_doku_id} - Benutzer-Dokumentation (NICHT von Odoo erstellt!)
Neue Felder:
- image_1920: Bild des Equipment (wird automatisch ins Wiki hochgeladen)
- wiki_doku_id: Eindeutige ID für Wiki-Dokumentation (aus Equipment-Name generiert)
- wiki_page_url: Berechnete URL zur Wiki-Seite
- wiki_synced: Status der Synchronisation
- wiki_last_sync: Zeitstempel der letzten Synchronisation
- wiki_auto_sync: Schalter für automatische Synchronisation bei Änderungen
Template-System:
Die Wiki-Seiten werden aus einem Template ({equipment_namespace}:odoo-status:c_template)
generiert, das folgende Platzhalter unterstützt:
- {feldname} - Direkte Equipment-Felder (z.B. {name}, {serial_no})
- {ows_machine_id.feldname} - Maschinenfelder (z.B. {ows_machine_id.power})
- {wiki_status_page} - ID der Odoo-Status-Seite
- {wiki_status_link} - DokuWiki-Link zur Odoo-Status-Seite
- {image} - Bild-Einbindung (wird automatisch hochgeladen)
- {tags} - Komma-separierte Tag-Liste
- {sync_datetime} - Zeitstempel der Synchronisation
Übersichtstabelle:
Generiert eine zentrale Übersichtstabelle aller Equipment mit konfigurierbaren
Spalten. Die Tabelle verwendet das DokuWiki DataTable-Plugin für interaktive
Sortierung und Filterung. Konfiguration über Systemparameter:
- dokuwiki.overview_page_id - Seiten-ID der Übersicht
- dokuwiki.overview_title - Titel der Seite
- dokuwiki.overview_columns - Spaltenüberschriften (pipe-separiert)
- dokuwiki.overview_column_data - Spaltendaten mit Platzhaltern
Verwendung:
1. Equipment anlegen/bearbeiten in Odoo
2. Bereich (ows_area_id) setzen (erforderlich für Wiki-Sync)
3. Optional: Bild hochladen (wird automatisch ins Wiki übertragen)
4. "Zum Wiki synchronisieren" klicken (oder automatisch bei Änderungen)
5. Wiki-Seiten werden erstellt/aktualisiert
6. "Wiki-Seite öffnen" für direkten Browser-Zugriff
Notes:
- Zentrale Dokumentationsseite wird nur beim ersten Sync erstellt
- Bereichsansichten werden bei jedem Sync aktualisiert
- Bilder werden als {equipment_namespace}:{bereich}:{wiki_doku_id}.jpg gespeichert (parallele Struktur zu Pages)
- Wiki-Namen werden normalisiert (Umlaute, Sonderzeichen, Kleinschreibung)
- Bei fehlendem Template wird Fallback-Inhalt verwendet
- Übersichtstabelle kann manuell oder automatisch aktualisiert werden
"""
_inherit = 'maintenance.equipment'
# Bild-Feld
image_1920 = fields.Image(
string='Bild',
max_width=1920,
max_height=1920,
help='Equipment-Bild (wird beim Wiki-Sync hochgeladen)'
)
# Wiki-Felder
wiki_status_id = fields.Char(
string='Wiki Status-ID',
readonly=True,
help='Eindeutige ID für die Odoo-Status-Seite (automatisch generiert aus Name + Seriennummer, z.B. "formatkreissaege-eq001")'
)
wiki_doku_id = fields.Char(
string='Wiki Dokumentations-ID',
readonly=False,
help='ID für die Benutzer-Dokumentationsseite (editierbar, initial aus Equipment-Namen generiert, z.B. "formatkreissaege"). Mehrere Equipment können die gleiche ID nutzen.'
)
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'
)
wiki_doku_link = fields.Html(
string='Wiki Doku',
compute='_compute_wiki_doku_link',
sanitize=False,
help='Klickbarer Link zur Wiki-Dokumentationsseite'
)
wiki_status_link = fields.Html(
string='Wiki Status',
compute='_compute_wiki_status_link',
sanitize=False,
help='Klickbarer Link zur Wiki-Status-Seite (Odoo-Daten)'
)
@api.depends('wiki_doku_id', 'wiki_page_url')
def _compute_wiki_doku_link(self):
"""
Generiert einen HTML-Link mit wiki_doku_id als Text und wiki_page_url als Ziel.
"""
for record in self:
if record.wiki_doku_id and record.wiki_page_url:
record.wiki_doku_link = f'<a href="{record.wiki_page_url}" target="_blank" style="color: #007bff;">{record.wiki_doku_id}</a>'
else:
record.wiki_doku_link = ''
@api.depends('wiki_status_id', 'name', 'comp_serial_no', 'ows_area_id')
def _compute_wiki_status_link(self):
"""
Generiert einen HTML-Link mit wiki_status_id als Text zur Odoo-Status-Seite.
"""
dokuwiki_client = self.env['dokuwiki.client']
for record in self:
# wiki_status_id dynamisch berechnen falls noch nicht gesetzt
wiki_status_id = record.wiki_status_id or record._get_wiki_status_id()
if wiki_status_id and record.ows_area_id:
status_page_id = record._get_wiki_status_page_id()
if status_page_id:
status_url = dokuwiki_client.get_wiki_url(status_page_id)
record.wiki_status_link = f'<a href="{status_url}" target="_blank" style="color: #007bff;">{wiki_status_id}</a>'
else:
record.wiki_status_link = ''
else:
record.wiki_status_link = ''
@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_status_id(self):
"""
Generiert die Wiki-Status-ID aus Equipment-Namen und comp_serial_no.
Format: normalisierter_name-comp_serial_no (z.B. "formatkreissaege-eq001")
Diese ID ist eindeutig und wird für die Odoo-Status-Seite und Media-Dateien verwendet.
Die comp_serial_no wird automatisch generiert falls noch nicht vorhanden.
Returns:
str: Wiki-Status-ID oder False wenn kein Name vorhanden
"""
self.ensure_one()
# Verwende wiki_status_id falls bereits gesetzt
if self.wiki_status_id:
return self.wiki_status_id
# Name ist erforderlich
if not self.name:
return False
# comp_serial_no automatisch generieren falls nicht vorhanden
if not self.comp_serial_no:
self.generate_serial_no()
# Refresh nach generate_serial_no
self.refresh()
# wiki_status_id aus Name + comp_serial_no generieren
normalized_name = self._normalize_wiki_name(self.name)
normalized_serial = self._normalize_wiki_name(self.comp_serial_no)
return f"{normalized_name}-{normalized_serial}"
def _get_wiki_doku_id(self):
"""
Generiert die Wiki-Dokumentations-ID aus Equipment-Namen (ohne Seriennummer).
Format: normalisierter_name (z.B. "formatkreissaege")
Diese ID ist editierbar und wird für die Benutzer-Dokumentationsseite verwendet.
Mehrere Equipment können die gleiche wiki_doku_id haben (z.B. alle Akkuschrauber).
Returns:
str: Wiki-Doku-ID oder False wenn kein Name vorhanden
"""
self.ensure_one()
# Verwende wiki_doku_id falls bereits gesetzt
if self.wiki_doku_id:
return self.wiki_doku_id
# Name ist erforderlich
if not self.name:
return False
# wiki_doku_id nur aus Name generieren (Duplikate sind erwünscht)
normalized_name = self._normalize_wiki_name(self.name)
return normalized_name
def _get_wiki_page_id_by_area(self):
"""
Generiert die Wiki-Page-ID für die Benutzer-Dokumentationsseite (nach Bereich).
Format: {equipment_namespace}:{area_name}:{wiki_doku_id}
WICHTIG: Diese Seite wird NICHT von Odoo erstellt! Sie wird nur in der Übersichtstabelle
verlinkt. Benutzer erstellen sie manuell durch Klick auf den Link.
Returns:
str: Page-ID für Benutzer-Dokumentation
"""
self.ensure_one()
if not self.ows_area_id:
return False
# Equipment-Namespace aus Systemparameter laden
IrConfigParameter = self.env['ir.config_parameter'].sudo()
equipment_namespace = IrConfigParameter.get_param(
'dokuwiki.equipment_namespace',
default='werkstatt:ausstattung'
)
# 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"{equipment_namespace}:{area_name}:{wiki_doku_id}"
def _get_wiki_status_page_id(self):
"""
Generiert die Wiki-Page-ID für die Odoo-Status-Seite (nur lesbar für Benutzer).
Format: {equipment_namespace}:odoo-status:{wiki_status_id}
Diese Seite wird von Odoo generiert und kann mit dem include-Plugin eingebunden werden.
Returns:
str: Page-ID der Odoo-Status-Seite
"""
self.ensure_one()
# Namespace aus Systemparameter laden (verwendet jetzt central_documentation_namespace)
IrConfigParameter = self.env['ir.config_parameter'].sudo()
status_namespace = IrConfigParameter.get_param(
'dokuwiki.central_documentation_namespace',
default='werkstatt:ausstattung:odoo-status'
)
wiki_status_id = self._get_wiki_status_id()
return f"{status_namespace}:{wiki_status_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 _render_template_from_wiki(self, view_type='area'):
"""
Lädt c_template aus DokuWiki und ersetzt Platzhalter mit Odoo-Feldwerten.
Template-Pfad wird dynamisch aus dem Systemparameter 'dokuwiki.central_documentation_namespace' generiert.
Standard: werkstatt:ausstattung:odoo-status:c_template
Platzhalter-Format:
- {feldname} für maintenance.equipment Felder, z.B. {name}, {serial_no}
- {ows_machine_id.feldname} für ows.machine Felder, z.B. {ows_machine_id.power}
- {wiki_status_page} für die Odoo-Status-Seite ID
- {wiki_status_link} für Link zur Odoo-Status-Seite
- {ows_area} für Bereichsname
- {category} für Kategoriename
- {sync_datetime} für aktuelles Datum/Zeit
Args:
view_type (str): 'area' oder 'purpose'
Returns:
str: Gerenderter Wiki-Markup-Inhalt
"""
self.ensure_one()
global _template_cache
dokuwiki_client = self.env['dokuwiki.client']
# Template-Pfad aus Systemparameter generieren
IrConfigParameter = self.env['ir.config_parameter'].sudo()
status_namespace = IrConfigParameter.get_param(
'dokuwiki.central_documentation_namespace',
default='werkstatt:ausstattung:odoo-status'
)
template_page_id = f"{status_namespace}:c_template"
try:
# Template aus Cache oder Wiki laden
if template_page_id not in _template_cache:
template_content = dokuwiki_client.get_page(template_page_id)
if not template_content:
_logger.warning(f"Template {template_page_id} nicht gefunden, verwende Fallback")
return self._generate_wiki_main_page_content_fallback(view_type)
_template_cache[template_page_id] = template_content
_logger.info(f"Template geladen und gecacht: {template_page_id} ({len(template_content)} Zeichen)")
else:
_logger.debug(f"Template aus Cache verwendet: {template_page_id}")
template_content = _template_cache[template_page_id]
# Werte-Dictionary vorbereiten
values = self._prepare_template_values(view_type)
# Debug-Logging für partner_ref
if 'partner_ref' in values:
_logger.info(f"partner_ref Wert für {self.name}: '{values['partner_ref']}'")
# Platzhalter ersetzen
rendered_content = template_content
for key, value in values.items():
placeholder = '{' + key + '}'
rendered_content = rendered_content.replace(placeholder, str(value or ''))
return rendered_content
except Exception as e:
_logger.warning(f"Fehler beim Laden des Templates {template_page_id}: {e}")
return self._generate_wiki_main_page_content_fallback(view_type)
def _prepare_template_values(self, view_type='area'):
"""
Bereitet Dictionary mit allen verfügbaren Feldwerten für Template-Rendering vor.
Args:
view_type (str): 'area' oder 'purpose'
Returns:
dict: Dictionary mit Feldnamen als Keys und Werten
"""
self.ensure_one()
# Odoo-Status-Seite
status_page_id = self._get_wiki_status_page_id()
# Odoo Base URL
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069')
odoo_equipment_url = f"{base_url}/web#id={self.id}&model=maintenance.equipment&view_type=form"
# Wiki Page IDs
wiki_page_id = self._get_wiki_page_id_by_area() if self.ows_area_id else ''
# Basis-Werte für maintenance.equipment
values = {
# Spezielle Werte
'wiki_status_page': status_page_id,
'wiki_status_link': f"[[{status_page_id}|📊 Odoo Status]]",
'wiki_page_id': wiki_page_id, # Page-ID der Hauptseite (nach Bereich)
'wiki_page_url': self.wiki_page_url or '', # Externe URL zur Hauptseite
'odoo_link': f"[[{odoo_equipment_url}|🔗 In Odoo öffnen]]",
'odoo_url': odoo_equipment_url,
'sync_datetime': datetime.now().strftime('%d.%m.%Y %H:%M'),
# Standard Equipment-Felder
'name': self.name or '',
'serial_no': self.serial_no or '',
'model': self.model or '',
'ows_area': self.ows_area_id.name if self.ows_area_id else '',
'category': self.category_id.name if self.category_id else '',
'status': self.status_id.name if self.status_id else '',
'status_smiley': self.status_id.smiley if self.status_id and self.status_id.smiley else '',
'location': self.location or '',
'assign_date': self.assign_date.strftime('%d.%m.%Y') if self.assign_date else '',
'cost': str(self.cost) if self.cost else '',
'warranty_date': self.warranty_date.strftime('%d.%m.%Y') if self.warranty_date else '',
'color': str(self.color) if self.color else '',
'note': self.note or '',
'partner_id': self.partner_id.name if self.partner_id else '',
'partner_ref': self.partner_ref or '',
}
# Tags hinzufügen (falls vorhanden)
if hasattr(self, 'tag_ids') and self.tag_ids:
# Komma-separierte Liste für Template
values['tags'] = ', '.join(self.tag_ids.mapped('name'))
# DokuWiki Bullet-Liste für Tabellenzellen
values['tags_list'] = ' '.join(self.tag_ids.mapped('name')) if self.tag_ids else ''
# Anzahl der Tags
values['tags_count'] = str(len(self.tag_ids))
else:
values['tags'] = ''
values['tags_list'] = ''
values['tags_count'] = '0'
# ows.machine Felder hinzufügen (falls verknüpft)
if self.ows_machine_id:
machine = self.ows_machine_id
# Nur existierende Felder hinzufügen
ows_machine_fields = {}
# Standard-Felder von ows.machine
if hasattr(machine, 'name') and machine.name:
ows_machine_fields['ows_machine_id.name'] = machine.name
if hasattr(machine, 'model') and machine.model:
ows_machine_fields['ows_machine_id.model'] = machine.model
if hasattr(machine, 'serial_no') and machine.serial_no:
ows_machine_fields['ows_machine_id.serial_no'] = machine.serial_no
if hasattr(machine, 'location') and machine.location:
ows_machine_fields['ows_machine_id.location'] = machine.location
if hasattr(machine, 'note'):
ows_machine_fields['ows_machine_id.note'] = machine.note or ''
if hasattr(machine, 'ows_category') and machine.ows_category:
ows_machine_fields['ows_machine_id.category'] = machine.ows_category
if hasattr(machine, 'ows_category_icon') and machine.ows_category_icon:
ows_machine_fields['ows_machine_id.category_icon'] = machine.ows_category_icon
values.update(ows_machine_fields)
# Bild-Upload und Referenz (falls vorhanden)
if self.image_1920:
wiki_status_id = self._get_wiki_status_id()
# Media-ID mit gleicher Namespace-Struktur wie Pages (DokuWiki Best Practice)
IrConfigParameter = self.env['ir.config_parameter'].sudo()
equipment_namespace = IrConfigParameter.get_param(
'dokuwiki.equipment_namespace',
default='werkstatt:ausstattung'
)
# Area-Name normalisieren (wie bei Pages)
area_name = self._normalize_wiki_name(self.ows_area_id.name) if self.ows_area_id else 'unbekannt'
# Bild ins Wiki hochladen (erkenne Extension aus Bytes)
try:
import base64
import imghdr
# image_1920 ist base64-kodiert, in bytes umwandeln für XML-RPC
image_bytes = base64.b64decode(self.image_1920)
# Typ/Extension erkennen (jpeg -> .jpg)
img_type = None
try:
img_type = imghdr.what(None, image_bytes)
except Exception:
img_type = None
if img_type == 'jpeg':
ext = '.jpg'
elif img_type:
ext = f'.{img_type}'
elif image_bytes.startswith(b'%PDF'):
ext = '.pdf'
else:
ext = '.bin'
media_id = f"{equipment_namespace}:{area_name}:{wiki_status_id}{ext}"
dokuwiki_client = self.env['dokuwiki.client']
dokuwiki_client.upload_media(media_id, image_bytes, overwrite=True)
# DokuWiki Image-Syntax: {{namespace:file.jpg?300}}
values['image'] = f"{{{{:{media_id}?100}}}}"
values['image_large'] = f"{{{{:{media_id}}}}}"
values['image_id'] = media_id
_logger.info(f"Bild hochgeladen: {media_id}")
except Exception as e:
_logger.error(f"Fehler beim Bild-Upload: {e}", exc_info=True)
values['image'] = ''
values['image_large'] = ''
values['image_id'] = ''
else:
values['image'] = ''
values['image_large'] = ''
values['image_id'] = ''
# View-Typ spezifische Werte
if view_type == 'area':
values['view_type'] = 'Bereich'
values['view_name'] = self.ows_area_id.name if self.ows_area_id else 'Unbekannt'
else:
values['view_type'] = 'Einsatzzweck'
values['view_name'] = 'TODO: Einsatzzweck'
return values
def _generate_wiki_main_page_content_fallback(self, view_type='area'):
"""
Fallback-Methode: Generiert hart-codierten Wiki-Markup-Inhalt,
wenn c_template.txt nicht verfügbar ist.
Dieser Content wird in odoo-status/ gespeichert und ist nur lesbar.
Args:
view_type (str): 'area' oder 'purpose'
Returns:
str: Wiki-Markup-Inhalt für odoo-status Seite
"""
self.ensure_one()
# 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} - Odoo Status ======
**{view_label}:** {view_name}
**Kategorie:** {self.category_id.name if self.category_id else 'Keine'}
**Seriennummer:** {self.serial_no or 'Keine'}
**Modell:** {self.model or 'Keine'}
**Status:** {self.status_id.name if self.status_id else 'Unbekannt'} {self.status_id.smiley if self.status_id and self.status_id.smiley else ''}
===== Technische Daten =====
**Hersteller:** {self.partner_id.name if self.partner_id else 'Unbekannt'}
**Standort:** {self.location or 'Nicht angegeben'}
**Kosten:** {self.cost if self.cost else 'Nicht angegeben'}
**Garantie bis:** {self.warranty_date.strftime('%d.%m.%Y') if self.warranty_date else 'Keine'}
===== Notizen =====
{self.note or 'Keine Notizen'}
----
//Diese Seite wird automatisch von Odoo generiert und ist nur lesbar.//
//Letzte Synchronisation: {datetime.now().strftime('%d.%m.%Y %H:%M')} //
"""
return content
def _generate_wiki_main_page_content(self, view_type='area'):
"""
Generiert den Wiki-Markup-Inhalt für die Odoo-Status-Seite.
Verwendet c_template.txt aus DokuWiki falls verfügbar, sonst Fallback.
Diese Seite wird in odoo-status/ gespeichert und ist nur lesbar.
Args:
view_type (str): 'area' oder 'purpose'
Returns:
str: Wiki-Markup-Inhalt für odoo-status Seite
"""
return self._render_template_from_wiki(view_type)
def _generate_wiki_doku_page_content(self):
"""
DEPRECATED: Diese Methode wird nicht mehr verwendet.
Odoo erstellt keine Benutzer-Dokumentationsseiten mehr.
Benutzer erstellen diese Seiten manuell durch Klick auf den Link in der Übersichtstabelle.
"""
_logger.warning("_generate_wiki_doku_page_content() wurde aufgerufen - diese Methode ist deprecated!")
return ""
def sync_to_dokuwiki(self):
"""
Synchronisiert Equipment-Daten zum DokuWiki.
WICHTIG: Erstellt NUR die Odoo-Status-Seite in odoo-status/ Namespace!
Benutzer-Dokumentationsseiten werden NICHT erstellt - diese erstellen Benutzer
manuell durch Klick auf den Link in der Übersichtstabelle.
Erstellt/aktualisiert:
- wiki_doku_id beim ersten Sync (falls noch nicht gesetzt)
- Odoo-Status-Seite (odoo-status:wiki_doku_id) - wird immer aktualisiert
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_status_id beim ersten Sync setzen (aus Equipment-Namen und comp_serial_no generieren)
if not self.wiki_status_id:
generated_status_id = self._get_wiki_status_id()
if not generated_status_id:
raise UserError("wiki_status_id konnte nicht generiert werden!")
self.write({'wiki_status_id': generated_status_id})
_logger.info(f"Wiki-Status-ID für {self.name} gesetzt: {generated_status_id}")
# wiki_doku_id beim ersten Sync setzen (aus Equipment-Namen generieren, editierbar)
if not self.wiki_doku_id:
generated_doku_id = self._get_wiki_doku_id()
if not generated_doku_id:
generated_doku_id = self._normalize_wiki_name(self.name)
self.write({'wiki_doku_id': generated_doku_id})
_logger.info(f"Wiki-Doku-ID für {self.name} gesetzt: {generated_doku_id}")
dokuwiki_client = self.env['dokuwiki.client']
try:
# Odoo-Status-Seite erstellen/aktualisieren (immer beim Sync)
status_page_id = self._get_wiki_status_page_id()
status_content = self._generate_wiki_main_page_content(view_type='area')
dokuwiki_client.create_page(
status_page_id,
status_content,
f"Synchronisiert von Odoo: {self.name}"
)
_logger.info(f"Odoo-Status-Seite für {self.name} aktualisiert: {status_page_id}")
# WICHTIG: Benutzer-Dokumentationsseiten werden NICHT erstellt!
# Diese werden nur in der Übersichtstabelle verlinkt und von Benutzern
# manuell erstellt durch Klick auf den roten Link.
# 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', 'ows_area_id', 'category_id', 'status_id',
'model', 'partner_id', 'partner_ref', 'location', 'note', 'image_1920', 'tag_ids'}
# Flag ob Übersichtstabelle aktualisiert werden soll
should_update_overview = False
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()
should_update_overview = True
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})
# Optional: Übersichtstabelle aktualisieren
if should_update_overview:
IrConfigParameter = self.env['ir.config_parameter'].sudo()
auto_update_overview = IrConfigParameter.get_param(
'dokuwiki.auto_update_overview_table',
default='False'
)
if auto_update_overview.lower() == 'true':
try:
self.env['maintenance.equipment'].action_sync_overview_table()
_logger.info("Übersichtstabelle automatisch aktualisiert")
except Exception as e:
_logger.warning(f"Automatische Übersichtstabellen-Sync fehlgeschlagen: {e}")
return result
# ==========================================
# Übersichtstabelle (DataTable)
# ==========================================
def _generate_overview_table_row(self, column_data_template):
"""
Generiert eine DokuWiki DataTable Zeile für dieses Equipment mit konfigurierbaren Spalten.
Args:
column_data_template (str): Template mit Platzhaltern, z.B. "{status_smiley}|{partner_id}|{model}"
Returns:
str: Wiki-Markup für eine Tabellenzeile
"""
self.ensure_one()
# Template-Werte vorbereiten (nutzt bestehende Platzhalter-Engine)
values = self._prepare_template_values(view_type='area')
# Platzhalter im Template ersetzen
rendered_data = column_data_template
for key, value in values.items():
placeholder = '{' + key + '}'
rendered_data = rendered_data.replace(placeholder, str(value or '-'))
# Pipe-separierte Werte in DokuWiki-Tabellenzeile umwandeln
columns = rendered_data.split('|')
row = '| ' + ' | '.join(columns) + ' |'
return row
@api.model
def action_sync_overview_table(self):
"""
Aktualisiert die Übersichtstabelle in DokuWiki mit allen Equipment-Einträgen.
Verwendet konfigurierbare Spalten aus Systemparametern (kein Template mehr nötig).
Returns:
dict: Ergebnis-Dictionary mit 'total', 'success', 'error_messages'
"""
IrConfigParameter = self.env['ir.config_parameter'].sudo()
dokuwiki_client = self.env['dokuwiki.client']
# Config Parameter (Defaults werden via _register_hook automatisch gesetzt)
overview_page_id = IrConfigParameter.get_param('dokuwiki.overview_page_id')
overview_title = IrConfigParameter.get_param('dokuwiki.overview_title')
overview_columns = IrConfigParameter.get_param('dokuwiki.overview_columns')
overview_column_data = IrConfigParameter.get_param('dokuwiki.overview_column_data')
# Validierung
if not all([overview_page_id, overview_title, overview_columns, overview_column_data]):
raise UserError("DokuWiki Übersichtstabellen-Parameter nicht konfiguriert! Bitte Modul neu installieren.")
try:
# Alle Equipment mit ows_area_id laden (sortiert nach Bereich, dann Name)
equipment_records = self.search([
('ows_area_id', '!=', False)
], order='ows_area_id, name')
if not equipment_records:
raise UserError("Keine Equipment-Einträge mit Bereich gefunden!")
# Tabellenzeilen generieren
table_rows = []
for equipment in equipment_records:
try:
row = equipment._generate_overview_table_row(overview_column_data)
table_rows.append(row)
except Exception as e:
_logger.warning(f"Fehler beim Generieren der Zeile für {equipment.name}: {e}")
continue
# Rows zusammenfügen
table_rows_markup = '\n'.join(table_rows)
# Spalten-Header generieren (aus overview_columns)
column_headers = overview_columns.split('|')
header_row = '^ ' + ' ^ '.join(column_headers) + ' ^'
# Sync-Zeitstempel
sync_datetime = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
# Komplette Seite zusammenbauen (ohne Template)
overview_content = f"""====== {overview_title} ======
Diese Seite wird automatisch aktualisiert.
**Letztes Update:** {sync_datetime}
<datatable Equipment-Übersicht>
{header_row}
{table_rows_markup}
</datatable>
----
**Hinweis:** Diese Tabelle ist interaktiv:
* Klicke auf die Spaltenköpfe zum Sortieren
* Nutze das Suchfeld zum Filtern
* Klicke auf die Links in der Spalte "Name" für Details
**Status:** 🙂 Maschine/Gerät in gutem Zustand, 🙁 - Maschine/Gerät ist Defekt, Maschine/Gerät wurde ausgemustert
**Sicherheit:** 🟢 **keine** Einweisungspflicht, 🟡 keine **explizite** Einweisungspflicht, 🔴 explizite Einweisungspflicht
(Für Einweisungstermine bitte den Thekendienst befragen oder trage dich [[https://hobbyhimmel.de/so-gehts/einweisungen/|hier]] im Kalender für einen Termin ein)
"""
# Seite in DokuWiki speichern
dokuwiki_client.create_page(
overview_page_id,
overview_content,
f"Automatisches Update der Übersichtstabelle ({len(table_rows)} Equipment)"
)
_logger.info(f"✓ Übersichtstabelle aktualisiert: {len(table_rows)} Equipment in {overview_page_id}")
return {
'total': len(equipment_records),
'success': len(table_rows),
'error_messages': '',
'overview_url': dokuwiki_client.get_wiki_url(overview_page_id),
}
except Exception as e:
error_msg = f"Fehler beim Aktualisieren der Übersichtstabelle: {str(e)}"
_logger.error(error_msg)
return {
'total': 0,
'success': 0,
'error_messages': error_msg,
'overview_url': '',
}
@api.model
def action_clear_template_cache(self):
"""
Leert den Template-Cache und erzwingt ein Neuladen aller Templates aus DokuWiki.
Nützlich nach Template-Änderungen im Wiki.
Returns:
dict: Notification mit Anzahl geleerte Cache-Einträge
"""
global _template_cache
count = len(_template_cache)
cache_keys = list(_template_cache.keys())
_template_cache.clear()
_logger.info(f"Template-Cache geleert: {count} Einträge ({', '.join(cache_keys)})")
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Template-Cache geleert'),
'message': _(f'{count} Template(s) aus dem Cache entfernt. Nächster Sync lädt Templates neu.'),
'type': 'success',
'sticky': False,
}
}
@api.model
def action_reset_wiki_sync_status(self):
"""
Setzt den Wiki-Synchronisationsstatus für alle Equipment zurück.
Nützlich nach Namespace-Änderungen oder wenn alle Equipment neu synchronisiert werden sollen.
Setzt zurück:
- wiki_synced auf False
- wiki_last_sync auf NULL
Optional (auskommentiert):
- wiki_doku_id auf NULL (nur wenn IDs komplett neu generiert werden sollen)
Returns:
dict: Notification mit Anzahl zurückgesetzter Equipment
"""
equipment_records = self.search([
('wiki_synced', '=', True)
])
count = len(equipment_records)
if count == 0:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Wiki-Sync Reset'),
'message': _('Keine synchronisierten Equipment gefunden.'),
'type': 'warning',
'sticky': False,
}
}
# Reset durchführen
equipment_records.write({
'wiki_synced': False,
'wiki_last_sync': False,
'wiki_status_id': False, # Status-ID neu generieren
# wiki_doku_id wird NICHT zurückgesetzt - ist manuell editierbar!
})
_logger.info(f"Wiki-Sync-Status für {count} Equipment zurückgesetzt")
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Wiki-Sync Reset'),
'message': _(f'{count} Equipment wurden zurückgesetzt und müssen neu synchronisiert werden.'),
'type': 'success',
'sticky': False,
}
}

View File

@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class MaintenanceEquipmentStatus(models.Model):
"""
Extension of the maintenance.equipment.status model to add DokuWiki smiley support.
This class extends the standard Odoo maintenance equipment status model to include
a smiley field that can be used for DokuWiki integration. The smiley field allows
administrators to associate DokuWiki emoticon syntax with each maintenance equipment
status, enabling visual representation of equipment status in DokuWiki documentation.
The smiley field accepts DokuWiki emoticon syntax (e.g., ':-)', ':-(', ':-D', etc.)
which can be used to display appropriate emoticons when exporting or displaying
equipment status information in DokuWiki format.
This extension is particularly useful for workshops or maintenance departments that
use DokuWiki as their documentation platform and want to have a visual representation
of equipment status alongside textual information.
"""
_inherit = 'maintenance.equipment.status'
smiley = fields.Char(
string='Smiley',
help='DokuWiki Smiley für diesen Status (z.B. :-) oder :-()'
)

View File

@ -1,54 +0,0 @@
# -*- 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 _register_hook(self):
"""
Hook der bei jedem Modul-Load (auch bei Update) ausgeführt wird.
Initialisiert fehlende DokuWiki-Parameter.
"""
super()._register_hook()
self._init_dokuwiki_parameters()
@api.model
def _init_dokuwiki_parameters(self):
"""
Initialisiert DokuWiki-Parameter beim Modul-Install/-Update 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',
'dokuwiki.equipment_namespace': 'werkstatt:ausstattung', # NEU: Basis-Namespace für Equipment
'dokuwiki.central_documentation_namespace': 'werkstatt:ausstattung:odoo-status', # NEU: Namespace für Odoo-generierte Status-Seiten
'dokuwiki.overview_page_id': 'werkstatt:ausstattung:uebersicht', # Seiten-ID für Übersichtstabelle
'dokuwiki.overview_title': 'Geräte & Maschinen - Übersicht',
'dokuwiki.overview_columns': 'Name|Zustand|Sicherheit|Bereich|Standort|Bild',
'dokuwiki.overview_column_data': '[[{wiki_page_id}|{name}]]|{status_smiley}|{ows_machine_id.category_icon}|{ows_area}|{location}|{image}',
'dokuwiki.auto_update_overview_table': 'False', # Automatische Übersichtstabellen-Aktualisierung bei Equipment-Änderungen
}
# Nur fehlende Parameter anlegen
count_created = 0
count_existing = 0
for key, value in defaults.items():
if not IrConfigParameter.get_param(key):
IrConfigParameter.set_param(key, value)
count_created += 1
_logger.info(f"DokuWiki parameter '{key}' initialized with default value")
else:
count_existing += 1
if count_created > 0:
_logger.info(f"DokuWiki parameters: {count_created} created, {count_existing} already existing")

View File

@ -1,2 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_equipment_wiki_sync_wizard,equipment.wiki.sync.wizard,model_equipment_wiki_sync_wizard,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_equipment_wiki_sync_wizard equipment.wiki.sync.wizard model_equipment_wiki_sync_wizard base.group_user 1 1 1 1

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="maintenance_equipment_status_form_view" model="ir.ui.view">
<field name="name">maintenance.equipment.status.form.dokuwiki</field>
<field name="model">maintenance.equipment.status</field>
<field name="inherit_id" ref="maintenance_equipment_status.maintenance_equipment_status_view_form"/>
<field name="arch" type="xml">
<field name="name" position="after">
<field name="smiley" placeholder="z.B. :-) oder :-("/>
</field>
</field>
</record>
<record id="maintenance_equipment_status_tree_view" model="ir.ui.view">
<field name="name">maintenance.equipment.status.tree.dokuwiki</field>
<field name="model">maintenance.equipment.status</field>
<field name="inherit_id" ref="maintenance_equipment_status.maintenance_equipment_status_view_tree"/>
<field name="arch" type="xml">
<field name="name" position="after">
<field name="smiley" optional="show"/>
</field>
</field>
</record>
</odoo>

View File

@ -1,120 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Equipment Tree View Extension - Wiki Link -->
<record id="maintenance_equipment_view_tree_dokuwiki" model="ir.ui.view">
<field name="name">maintenance.equipment.tree.dokuwiki</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree"/>
<field name="arch" type="xml">
<!-- Wiki Doku-Link nach dem Namen -->
<field name="name" position="after">
<field name="wiki_doku_link" widget="html" string="Wiki Doku" optional="show"/>
<field name="wiki_status_link" widget="html" string="Wiki Status" optional="hide"/>
</field>
</field>
</record>
<!-- 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">
<!-- Bild-Feld oben rechts neben den Smart Buttons -->
<xpath expr="//sheet/div[@name='button_box']" position="before">
<field name="image_1920" widget="image" class="oe_avatar" options="{'preview_image': 'image_1920'}"/>
</xpath>
<!-- 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 Dokumentations-Seite (Benutzer-Doku)" colspan="2">
<field name="wiki_doku_id" string="Seitenname"/>
<field name="wiki_page_url" widget="url" string="🔗 Zur Doku-Seite" class="oe_link"/>
</group>
</group>
<group>
<group string="Wiki Status-Seite (Odoo-Daten, automatisch)">
<field name="wiki_status_id" string="Seitenname" readonly="1"/>
</group>
<group string="Synchronisations-Status">
<field name="wiki_synced" readonly="1"/>
<field name="wiki_last_sync" readonly="1"/>
<field name="wiki_auto_sync"/>
</group>
</group>
<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 <strong>zwei unterschiedliche Seiten</strong>:
</p>
<hr/>
<h5>📊 Wiki Status Seite (automatisch, nur lesbar)</h5>
<ul>
<li><strong>Zweck:</strong> Enthält aktuelle Odoo-Daten (Status, Standort, Hersteller etc.)</li>
<li><strong>Format:</strong> werkstatt:ausstattung:odoo-status:[name-seriennummer]</li>
<li><strong>Eindeutigkeit:</strong> Jedes Equipment hat eine eigene Status-Seite</li>
<li><strong>Update:</strong> Wird bei jeder Synchronisation automatisch aktualisiert</li>
</ul>
<h5>📖 Wiki Dokumentations Seite (manuell, editierbar)</h5>
<ul>
<li><strong>Zweck:</strong> Benutzer-Dokumentation, Anleitungen, Tipps</li>
<li><strong>Format:</strong> werkstatt:ausstattung:[bereich]:[doku-name]</li>
<li><strong>Besonderheit:</strong> Kann von mehreren Equipment geteilt werden (z.B. "akkuschrauber")</li>
<li><strong>Erstellung:</strong> Wird NICHT von Odoo erstellt - Benutzer erstellen diese manuell</li>
</ul>
<hr/>
<p class="mb-0">
<strong>💡 Tipp:</strong> Die Status-Seite kann mit dem include-Plugin in die Dokumentations-Seite eingebunden werden:<br/>
<code>{{page&gt;werkstatt:ausstattung:odoo-status:[name-seriennummer]}}</code>
</p>
<hr/>
<p class="mb-0">
<strong>⚙️ Automatische Synchronisation:</strong> Wenn aktiviert, werden Änderungen automatisch zur Status-Seite übertragen.
</p>
</div>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
from . import equipment_wiki_sync_wizard

View File

@ -1,157 +0,0 @@
# -*- coding: utf-8 -*-
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class EquipmentWikiSyncWizard(models.TransientModel):
_name = 'equipment.wiki.sync.wizard'
_description = 'Massen-Synchronisation von Equipment zu DokuWiki'
sync_mode = fields.Selection([
('all', 'Alle Equipment'),
('area', 'Nach Bereich filtern'),
('unsynced', 'Nur nicht synchronisierte'),
('overview_table', 'Übersichtstabelle aktualisieren'),
('reset_sync', 'Sync-Status zurücksetzen'),
], string='Synchronisations-Modus', default='all', required=True)
ows_area_id = fields.Many2one(
'ows.machine.area',
string='Bereich',
help='Nur Equipment aus diesem Bereich synchronisieren'
)
# Statistik-Felder (nach Sync)
total_count = fields.Integer(string='Gefunden', readonly=True)
success_count = fields.Integer(string='Erfolgreich', readonly=True)
error_count = fields.Integer(string='Fehler', readonly=True)
error_messages = fields.Text(string='Fehlermeldungen', readonly=True)
overview_url = fields.Char(string='Übersichtsseite URL', readonly=True)
@api.onchange('sync_mode')
def _onchange_sync_mode(self):
"""Bereich-Feld nur bei 'area' Modus anzeigen"""
if self.sync_mode != 'area':
self.ows_area_id = False
def action_sync_equipment(self):
"""
Hauptaktion: Equipment synchronisieren basierend auf gewähltem Modus
"""
self.ensure_one()
# Übersichtstabelle-Modus: Schneller Update ohne Equipment-Iteration
if self.sync_mode == 'overview_table':
return self._sync_overview_table()
# Reset-Modus: Sync-Status zurücksetzen
if self.sync_mode == 'reset_sync':
return self.env['maintenance.equipment'].action_reset_wiki_sync_status()
# Equipment-Liste basierend auf Modus ermitteln
domain = []
if self.sync_mode == 'area':
if not self.ows_area_id:
raise UserError("Bitte einen Bereich auswählen!")
domain.append(('ows_area_id', '=', self.ows_area_id.id))
elif self.sync_mode == 'unsynced':
domain.append(('wiki_synced', '=', False))
# Equipment finden
equipment_records = self.env['maintenance.equipment'].search(domain)
if not equipment_records:
raise UserError("Keine Equipment-Einträge gefunden, die synchronisiert werden können!")
total = len(equipment_records)
success = 0
errors = 0
error_list = []
_logger.info(f"Starte Wiki-Synchronisation für {total} Equipment-Einträge")
# Jeden Equipment-Eintrag synchronisieren
for equipment in equipment_records:
try:
# Prüfen ob Bereich gesetzt ist
if not equipment.ows_area_id:
error_msg = f"{equipment.name}: Kein Bereich gesetzt"
error_list.append(error_msg)
errors += 1
_logger.warning(error_msg)
continue
# Standard-Synchronisation (erstellt nur Odoo-Status-Seiten)
equipment.sync_to_dokuwiki()
success += 1
_logger.info(f"{equipment.name} synchronisiert")
except Exception as e:
error_msg = f"{equipment.name}: {str(e)}"
error_list.append(error_msg)
errors += 1
_logger.error(f"{error_msg}")
# Statistik speichern
self.write({
'total_count': total,
'success_count': success,
'error_count': errors,
'error_messages': '\n'.join(error_list) if error_list else 'Keine Fehler',
})
# Ergebnis-View anzeigen
return {
'type': 'ir.actions.act_window',
'name': 'Wiki-Synchronisation Ergebnis',
'res_model': 'equipment.wiki.sync.wizard',
'view_mode': 'form',
'res_id': self.id,
'target': 'new',
'context': {'show_result': True},
}
def _sync_overview_table(self):
"""
Aktualisiert die Übersichtstabelle in DokuWiki.
Ruft die Methode im maintenance.equipment Model auf.
Returns:
dict: Action zum Anzeigen des Ergebnisses
"""
self.ensure_one()
_logger.info("Starte Übersichtstabellen-Synchronisation")
# Equipment Model aufrufen
result = self.env['maintenance.equipment'].action_sync_overview_table()
# Statistik speichern
self.write({
'total_count': result.get('total', 0),
'success_count': result.get('success', 0),
'error_count': result.get('total', 0) - result.get('success', 0),
'error_messages': result.get('error_messages', 'Keine Fehler'),
'overview_url': result.get('overview_url', ''),
})
# Ergebnis-View anzeigen
return {
'type': 'ir.actions.act_window',
'name': 'Übersichtstabelle aktualisiert',
'res_model': 'equipment.wiki.sync.wizard',
'view_mode': 'form',
'res_id': self.id,
'target': 'new',
'context': {'show_result': True},
}
def action_clear_template_cache(self):
"""
Leert den Template-Cache (delegiert an maintenance.equipment).
"""
return self.env['maintenance.equipment'].action_clear_template_cache()

View File

@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Wizard Form View -->
<record id="equipment_wiki_sync_wizard_form" model="ir.ui.view">
<field name="name">equipment.wiki.sync.wizard.form</field>
<field name="model">equipment.wiki.sync.wizard</field>
<field name="arch" type="xml">
<form string="Wiki-Synchronisation">
<group invisible="context.get('show_result', False)">
<group>
<field name="sync_mode" widget="radio"/>
<field name="ows_area_id" invisible="sync_mode != 'area'" required="sync_mode == 'area'"/>
</group>
</group>
<!-- Ergebnis-Anzeige -->
<group invisible="not context.get('show_result', False)">
<group>
<field name="total_count"/>
<field name="success_count"/>
<field name="error_count"/>
</group>
<group>
<field name="overview_url" widget="url" string="Übersichtsseite" invisible="not overview_url"/>
<field name="error_messages" widget="text" colspan="2" invisible="error_count == 0"/>
</group>
</group>
<footer>
<button string="Synchronisieren"
name="action_sync_equipment"
type="object"
class="btn-primary"
invisible="context.get('show_result', False)"/>
<button string="Template-Cache leeren"
name="action_clear_template_cache"
type="object"
class="btn-warning"
invisible="context.get('show_result', False)"
help="Leert den Template-Cache und erzwingt Neuladen beim nächsten Sync"/>
<button string="Schließen"
special="cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<!-- Menu-Action -->
<record id="action_equipment_wiki_sync_wizard" model="ir.actions.act_window">
<field name="name">Wiki-Synchronisation</field>
<field name="res_model">equipment.wiki.sync.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{}</field>
</record>
<!-- Menu-Eintrag unter Wartung → Konfiguration -->
<menuitem id="menu_equipment_wiki_sync"
name="Wiki-Synchronisation"
parent="maintenance.menu_maintenance_configuration"
action="action_equipment_wiki_sync_wizard"
sequence="99"/>
</odoo>

View File

@ -1,139 +0,0 @@
# Open Workshop - Employee Image Generator
Erstelle professionelle Namensschilder (Thekenschilder) für Mitarbeiter direkt in Odoo.
## Beschreibung
Dieses Modul ersetzt die externe "thekenheld" WebApp und integriert die Funktionalität direkt in Odoo's HR-Modul. Mitarbeiter-Namensschilder können nun bequem aus dem Employee-Formular heraus erstellt werden.
## Features
- ✅ **Foto-Upload & Zuschnitt**: Cropper.js Integration für professionelle Bildbearbeitung
- ✅ **Festes Seitenverhältnis**: 369x492 Pixel (optimiert für Thekenschilder)
- ✅ **Text-Overlay**: Name und Schwerpunkte werden automatisch hinzugefügt
- ✅ **Direkte Integration**: Button im Employee-Formular
- ✅ **Sofortige Verfügbarkeit**: Generiertes Bild wird direkt als Employee Avatar gespeichert
- ✅ **POS Integration**: Bilder werden automatisch im Customer Display angezeigt (via open_workshop_pos_customer_display)
## Installation
1. Modul in Odoo installieren
2. Berechtigungen: HR / Mitarbeiter Module erforderlich
## Verwendung
### Namensschild erstellen:
1. Öffne einen Mitarbeiter-Datensatz (HR → Mitarbeiter)
2. Klicke auf den Button **"Namensschild erstellen"** im Header
3. **Schritt 1**: Wähle ein Foto aus (mindestens 369x492 Pixel empfohlen)
4. **Schritt 2**: Schneide das Foto zu - bewege und zoome für den perfekten Ausschnitt
5. **Schritt 3**: Überprüfe Name und Schwerpunkte, passe bei Bedarf an
6. **Vorschau**: Sieh dir das finale Namensschild an
7. Klicke **"Speichern"** - das Bild wird als Employee Avatar gesetzt
### Schwerpunkte definieren:
Im Employee-Formular gibt es ein neues Feld **"Schwerpunkte"** (job_focus):
- Trage hier die Tätigkeitsschwerpunkte ein
- Z.B. "Fahrrad-Reparatur, E-Bikes, Werkstatt"
- Max. 2 Zeilen für optimale Darstellung
- Falls leer, wird alternativ das job_title Feld verwendet
## Technische Details
### Architektur
**Frontend-only Lösung** mit:
- **OWL Component**: Client Action für Dialog
- **Cropper.js**: Professionelles Bild-Cropping
- **Canvas API**: Text-Overlay Generation
- **Base64 Upload**: Direktes Speichern als image_1920
### Dateien
```
open_workshop_employee_imagegenerator/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── hr_employee.py # Erweiterung von hr.employee
├── views/
│ └── hr_employee_views.xml # Button im Form + job_focus Feld
├── static/
│ ├── src/
│ │ ├── js/
│ │ │ └── employee_image_widget.js # OWL Component
│ │ ├── xml/
│ │ │ └── employee_image_widget.xml # Template
│ │ └── css/
│ │ └── employee_image_widget.css # Styles
│ └── lib/
│ └── cropperjs/
│ ├── cropper.js # Cropper.js Library
│ └── cropper.css # Cropper.js Styles
```
### Workflow
1. User klickt "Namensschild erstellen"
2. Python Action öffnet Client Action mit Context (employee_id, name, job_focus)
3. JavaScript Dialog (3 Schritte):
- Upload → FileReader lädt Bild als Data URL
- Crop → Cropper.js erzeugt zugeschnittenes Bild (369x492)
- Text → Canvas API zeichnet Text-Overlay
4. Base64 Image wird zu hr.employee.image_1920 gespeichert
5. Page Reload zeigt neues Bild
### Canvas Text-Overlay
Das generierte Namensschild hat:
- **Foto**: 369x492 Pixel (Vollbild)
- **Text-Overlay**: 120px Höhe am unteren Rand
- **Hintergrund**: Halbtransparent weiß (92% Opazität)
- **Name**: Fett, 24px, zentriert
- **Schwerpunkte**: Normal, 16px, zentriert, max. 2 Zeilen
## Integration
### Mit POS Customer Display
Dieses Modul arbeitet nahtlos mit `open_workshop_pos_customer_display` zusammen:
- Generierte Bilder werden automatisch im Customer Display angezeigt
- Verwendung der hr.employee.public URL
- Keine zusätzliche Konfiguration nötig
### Ersetzt thekenheld WebApp
Die externe Docker-Container WebApp ist nicht mehr nötig:
- ✅ Keine separate Instanz erforderlich
- ✅ Alle Daten in Odoo
- ✅ Berechtigungsverwaltung über Odoo
- ✅ Besserer Workflow
## Migration von thekenheld
Falls bereits Bilder in der alten WebApp erstellt wurden:
1. Bilder aus `/var/www/html/uploads/` kopieren
2. Manuell in Odoo Employee-Datensätze hochladen
3. Oder: Neues Namensschild mit diesem Tool erstellen
## Abhängigkeiten
- `hr` - Human Resources Module
- `web` - Odoo Web Framework
- Cropper.js v1.6.1 (inkludiert)
## Version
18.0.1.0.0
## Lizenz
LGPL-3
## Credits
Entwickelt für Open Workshop / Hobbyhimmel
Basierend auf der thekenheld WebApp

View File

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

View File

@ -1,53 +0,0 @@
# -*- coding: utf-8 -*-
{
'name': 'Open Workshop - Employee Image Generator',
'version': '18.0.1.0.0',
'category': 'Human Resources',
'summary': 'Upload and crop employee photos (369x492) with job focus',
'description': """
Employee Image Generator
=========================
Upload and crop employee photos directly in Odoo.
Features:
---------
* Upload and crop employee photos to fixed 369x492 pixels
* Add job focus areas (Schwerpunkte)
* Integrated Cropper.js for professional image editing
* Fixed crop frame - only image moves and zooms
* Direct integration in Employee form
* Saved photo can be used in POS Customer Display
Usage:
------
1. Go to Employee form
2. Click "Namensschild erstellen" button
3. Upload photo, zoom and position it in fixed crop frame
4. Enter job focus areas (optional)
5. Save - photo and job_focus are saved to employee
""",
'author': 'Open Workshop',
'website': 'https://www.open-workshop.de',
'license': 'LGPL-3',
'depends': ['hr', 'web'],
'data': [
'views/hr_employee_views.xml',
],
'assets': {
'web.assets_backend': [
# Cropper.js CSS
'open_workshop_employee_imagegenerator/static/lib/cropperjs/cropper.css',
'open_workshop_employee_imagegenerator/static/src/css/employee_image_widget.css',
'open_workshop_employee_imagegenerator/static/src/css/badge_template.css',
# Cropper.js Library
'open_workshop_employee_imagegenerator/static/lib/cropperjs/cropper.js',
# Our custom widgets
'open_workshop_employee_imagegenerator/static/src/js/employee_image_widget.js',
'open_workshop_employee_imagegenerator/static/src/xml/employee_image_widget.xml',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
from . import hr_employee

View File

@ -1,32 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class HrEmployee(models.Model):
_inherit = 'hr.employee'
# Zusätzliches Feld für Schwerpunkte (falls nicht job_title verwendet wird)
job_focus = fields.Char(
string='Schwerpunkte',
help='Tätigkeitsschwerpunkte für Namensschild'
)
def action_open_image_generator(self):
"""Öffnet den Image Generator Dialog"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'employee_image_generator',
'name': 'Namensschild erstellen',
'target': 'new',
'params': {
'employee_id': self.id,
'employee_name': self.name,
'job_focus': self.job_focus or self.job_title or '',
},
'context': {
'employee_id': self.id,
'employee_name': self.name,
'job_focus': self.job_focus or self.job_title or '',
}
}

View File

@ -1,9 +0,0 @@
/*!
* Cropper.js v1.6.1
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2023-09-17T03:44:17.565Z
*/.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,96 +0,0 @@
/* Badge Template CSS - 1:1 von thekenheld print.html */
.badge-template {
position: relative;
background: white;
width: 21cm;
height: 29.7cm;
margin: 0 auto;
display: block;
}
.badge-template header,
.badge-template footer {
position: absolute;
left: 0;
right: 0;
background-color: rgb(255, 255, 255);
padding-right: 1.5cm;
padding-left: 1.5cm;
height: 30mm;
}
.badge-template header {
top: 0;
padding-top: 5mm;
padding-bottom: 3mm;
}
.badge-template footer {
bottom: 0;
color: rgb(241, 245, 247);
padding-top: 3mm;
padding-bottom: 5mm;
}
.badge-template .content {
padding-top: 40mm;
padding-bottom: 40mm;
text-align: center;
}
.badge-template .logo {
width: 43mm;
height: 20mm;
padding: 0;
}
.badge-template .headline1 {
height: 13mm;
font-size: 10mm;
font-weight: bold;
}
.badge-template .headline2 {
height: 9mm;
font-size: 6mm;
text-align: center;
}
.badge-template .headline3 {
height: 10mm;
font-size: 7mm;
font-weight: bold;
}
.badge-template .centered-image {
height: 130mm;
width: 100mm;
display: block;
margin: 0 auto;
padding: 5mm 0 0 0;
object-fit: cover;
object-position: center;
}
.badge-template .your-name {
height: 22mm;
font-size: 19mm;
padding: 0;
font-weight: bold;
line-height: 1;
}
.badge-template .topics {
height: 10mm;
font-size: 8mm;
text-align: center;
}
.badge-template .warnung {
font-size: 9mm;
color: red;
flex: 1;
text-align: center;
font-weight: bold;
}

View File

@ -1,94 +0,0 @@
/* Employee Image Generator Styles */
.o_employee_image_generator {
max-width: 900px;
}
.o_employee_image_generator .img-container {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.o_employee_image_generator .img-container img {
max-width: 100%;
display: block;
}
.o_employee_image_generator canvas {
border: 1px solid #dee2e6;
border-radius: 4px;
}
/* Cropper.js Container Anpassungen */
.o_employee_image_generator .cropper-container {
border-radius: 4px;
}
/* Progress Bar */
.o_employee_image_generator .progress {
height: 3px;
background-color: #e9ecef;
}
.o_employee_image_generator .progress-bar {
background-color: #0d6efd;
transition: width 0.3s ease;
}
/* Step Indicators */
.o_employee_image_generator .step-indicator {
font-size: 0.875rem;
}
/* Upload Area */
.o_employee_image_generator .upload-area {
cursor: pointer;
transition: all 0.2s;
}
.o_employee_image_generator .upload-area:hover {
background-color: #f8f9fa;
}
/* Canvas Preview */
.o_employee_image_generator canvas {
display: block;
margin: 0 auto;
}
/* Modal Anpassungen */
.o_employee_image_generator .modal-dialog {
margin: 1.75rem auto;
}
.o_employee_image_generator .modal-content {
border: none;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.o_employee_image_generator .modal-header {
border-bottom: 1px solid #dee2e6;
padding: 1rem 1.5rem;
}
.o_employee_image_generator .modal-body {
padding: 1.5rem;
}
.o_employee_image_generator .modal-footer {
border-top: 1px solid #dee2e6;
padding: 1rem 1.5rem;
}
/* Responsive */
@media (max-width: 768px) {
.o_employee_image_generator .modal-dialog {
max-width: 95%;
margin: 0.5rem auto;
}
.o_employee_image_generator .modal-body {
padding: 1rem;
}
}

View File

@ -1,228 +0,0 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { Component, useState, useRef, onMounted } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
/**
* Employee Image Generator Client Action
*
* Ermöglicht das Erstellen von professionellen Namensschildern für Mitarbeiter.
* Verwendet Cropper.js für Bildbearbeitung und Canvas API für Text-Overlay.
*/
class EmployeeImageGenerator extends Component {
static template = "open_workshop_employee_imagegenerator.ImageGeneratorDialog";
static props = {
"*": true,
};
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
// Hole Context aus props oder action
const context = this.props.action?.context || this.props.context || {};
this.state = useState({
step: 1, // 1: Upload, 2: Crop, 3: Text, 4: Preview
employeeId: context.employee_id,
employeeName: context.employee_name || '',
jobFocus: context.job_focus || '',
companyLogo: null,
imageDataUrl: null,
croppedImageDataUrl: null,
finalImageDataUrl: null,
});
this.fileInput = useRef("fileInput");
this.imagePreview = useRef("imagePreview");
this.cropperImage = useRef("cropperImage");
this.badgeTemplate = useRef("badgeTemplate");
this.cropper = null;
// Zielgröße für das zugeschnittene Foto
this.cropWidth = 369;
this.cropHeight = 492;
// Zielgröße für das finale Badge (A4-Format)
this.badgeWidth = 794;
this.badgeHeight = 1123;
// Umrechnungsfaktor für mm (falls benötigt)
this.pxPerMm = this.badgeWidth / 210;
// Lade Company-Logo beim Start
this.loadCompanyLogo();
}
async loadCompanyLogo() {
try {
// Hole das Logo der aktuellen Company
const company = await this.orm.call('res.company', 'search_read', [[]], {
fields: ['logo'],
limit: 1,
});
if (company && company.length > 0 && company[0].logo) {
// Company-Logo ist base64-codiert, füge data:image/png prefix hinzu
this.state.companyLogo = `data:image/png;base64,${company[0].logo}`;
}
} catch (error) {
console.warn('Konnte Company-Logo nicht laden:', error);
}
}
// Schritt 1: Datei-Upload
onFileSelect(ev) {
const files = ev.target.files;
if (files && files.length > 0) {
const file = files[0];
// Prüfe ob es ein Bild ist
if (!file.type.startsWith('image/')) {
this.notification.add('Bitte wähle eine Bilddatei aus', {
type: 'warning'
});
return;
}
// Lade Bild als Data URL
const reader = new FileReader();
reader.onload = (e) => {
this.state.imageDataUrl = e.target.result;
this.state.step = 2;
// Initialisiere Cropper im nächsten Tick
setTimeout(() => this.initCropper(), 100);
};
reader.readAsDataURL(file);
}
}
// Schritt 2: Initialisiere Cropper.js
initCropper() {
if (this.cropper) {
this.cropper.destroy();
}
const imageElement = this.cropperImage.el;
if (!imageElement || !window.Cropper) {
console.error('Cropper.js not loaded or image element not found');
return;
}
const aspectRatio = this.cropWidth / this.cropHeight;
this.cropper = new window.Cropper(imageElement, {
aspectRatio: aspectRatio,
viewMode: 1, // Erlaubt unbegrenztes Zoomen
dragMode: 'move', // Bild verschieben
autoCropArea: 0.7, // Crop-Rahmen ist 70% des Containers - besser sichtbar
restore: false,
guides: true,
center: true,
highlight: false,
cropBoxMovable: false, // Crop-Rahmen NICHT verschiebbar
cropBoxResizable: false, // Crop-Rahmen NICHT in Größe veränderbar
toggleDragModeOnDblclick: false,
zoomable: true,
zoomOnWheel: true, // Zoom mit Mausrad
zoomOnTouch: true, // Zoom mit Touch-Gesten
minContainerWidth: 200,
minContainerHeight: 200,
ready: function() {
// Setze initiales Zoom-Level auf "fit in container"
this.cropper.zoomTo(0);
},
});
}
// Bild zuschneiden und weiter zu Schritt 3
cropImage() {
if (!this.cropper) {
return;
}
const canvas = this.cropper.getCroppedCanvas({
width: this.cropWidth,
height: this.cropHeight,
});
if (canvas) {
this.state.croppedImageDataUrl = canvas.toDataURL('image/jpeg', 0.9);
this.state.step = 3;
// Destroy cropper um Ressourcen freizugeben
this.cropper.destroy();
this.cropper = null;
// Gehe zu Schritt 3 (das Template ist bereits sichtbar via t-if)
// Die Vorschau wird automatisch angezeigt durch das HTML-Template
}
}
// Schritt 3: Text-Änderung → Vorschau ist Live durch HTML-Template
onTextChange() {
// Das Template aktualisiert sich automatisch durch t-model
// Keine zusätzliche Logik nötig - OWL reactive state macht das automatisch!
}
// Schritt 4: Finale Speicherung - Speichere nur das zugeschnittene Foto + job_focus
async saveFinalImage() {
try {
// Verwende das bereits zugeschnittene Foto (369×492px)
if (!this.state.croppedImageDataUrl) {
this.notification.add('Kein zugeschnittenes Foto vorhanden', { type: 'danger' });
return;
}
// Extrahiere Base64-Daten (entferne data:image/jpeg;base64, prefix)
const imageData = this.state.croppedImageDataUrl.split(',')[1];
// Speichere Foto + job_focus in Odoo
await this.orm.write('hr.employee', [this.state.employeeId], {
image_1920: imageData,
job_focus: this.state.jobFocus || '',
});
this.notification.add('Foto erfolgreich gespeichert!', {
type: 'success',
});
// Schließe Dialog und kehre zurück zum Mitarbeiter-Formular
await this.action.doAction({
type: 'ir.actions.act_window',
res_model: 'hr.employee',
res_id: this.state.employeeId,
views: [[false, 'form']],
target: 'current',
});
} catch (error) {
console.error('Fehler beim Speichern:', error);
this.notification.add(`Fehler beim Speichern: ${error.message}`, {
type: 'danger',
});
}
}
// Abbrechen
cancel() {
if (this.props.close) {
this.props.close();
} else {
this.action.doAction({
type: 'ir.actions.act_window_close'
});
}
}
// Zurück zum vorherigen Schritt
previousStep() {
if (this.state.step > 1) {
this.state.step--;
}
}
}
// Registriere als Client Action
registry.category("actions").add("employee_image_generator", EmployeeImageGenerator);

View File

@ -1,178 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="open_workshop_employee_imagegenerator.ImageGeneratorDialog">
<div class="o_employee_image_generator modal-dialog modal-lg">
<div class="modal-content">
<!-- Header -->
<div class="modal-header">
<h4 class="modal-title">
<i class="fa fa-image me-2"/>
Namensschild erstellen
</h4>
<button type="button" class="btn-close" t-on-click="cancel"/>
</div>
<!-- Body -->
<div class="modal-body">
<!-- Schritt-Anzeige -->
<div class="mb-3">
<div class="progress" style="height: 3px;">
<div class="progress-bar"
role="progressbar"
t-att-style="'width: ' + (state.step * 33) + '%'"
aria-valuenow="state.step"
aria-valuemin="1"
aria-valuemax="3"/>
</div>
<div class="d-flex justify-content-between mt-2 text-muted small">
<span t-att-class="{'text-primary fw-bold': state.step === 1}">1. Bild hochladen</span>
<span t-att-class="{'text-primary fw-bold': state.step === 2}">2. Bild zuschneiden</span>
<span t-att-class="{'text-primary fw-bold': state.step === 3}">3. Text &amp; Vorschau</span>
</div>
</div>
<!-- Schritt 1: Upload -->
<div t-if="state.step === 1" class="text-center p-5">
<i class="fa fa-cloud-upload fa-5x text-muted mb-3"/>
<h5>Wähle ein Foto aus</h5>
<p class="text-muted">Das Bild sollte mindestens 369x492 Pixel groß sein</p>
<input type="file"
t-ref="fileInput"
class="d-none"
accept="image/*"
t-on-change="onFileSelect"/>
<button class="btn btn-primary btn-lg"
t-on-click="() => this.fileInput.el.click()">
<i class="fa fa-folder-open me-2"/>
Datei auswählen
</button>
</div>
<!-- Schritt 2: Crop -->
<div t-if="state.step === 2">
<div class="alert alert-info mb-3">
<i class="fa fa-info-circle me-2"/>
Bewege und zoome das Bild, um den gewünschten Ausschnitt zu wählen
</div>
<div class="img-container" style="max-height: 500px; overflow: hidden; display: flex; align-items: center; justify-content: center;">
<img t-ref="cropperImage"
t-att-src="state.imageDataUrl"
style="max-width: 100%; max-height: 100%; display: block;"/>
</div>
</div>
<!-- Schritt 3: Text und Vorschau -->
<div t-if="state.step === 3">
<div class="row">
<!-- Linke Spalte: Eingabefelder -->
<div class="col-md-6">
<h5 class="mb-3">Textinformationen</h5>
<div class="mb-3">
<label for="employeeName" class="form-label">Name</label>
<input type="text"
class="form-control"
id="employeeName"
t-model="state.employeeName"
t-on-input="onTextChange"
placeholder="Max Mustermann"/>
<small class="form-text text-muted">
Dieser Name wird auf dem Namensschild angezeigt
</small>
</div>
<div class="mb-3">
<label for="jobFocus" class="form-label">Schwerpunkte</label>
<textarea class="form-control"
id="jobFocus"
rows="3"
t-model="state.jobFocus"
t-on-input="onTextChange"
placeholder="z.B. Fahrrad-Reparatur, E-Bikes, Werkstatt"/>
<small class="form-text text-muted">
Tätigkeitsbereiche oder Spezialisierung (optional)
</small>
</div>
<div class="alert alert-light">
<strong>Tipp:</strong> Kurze Texte sehen besser aus.
Maximal 2 Zeilen für Schwerpunkte.
</div>
</div>
<!-- Rechte Spalte: Vorschau -->
<div class="col-md-6">
<h5 class="mb-3">Vorschau</h5>
<div class="border rounded p-3 d-flex justify-content-center align-items-center"
style="background: rgb(204, 204, 204); min-height: 400px;">
<!-- Wrapper nimmt nur den skalierten Platz ein (21cm × 29.7cm × 0.35 = 7.35cm × 10.4cm) -->
<div style="width: 7.35cm; height: 10.4cm; position: relative;">
<div style="width: 21cm; height: 29.7cm; transform: scale(0.35); transform-origin: top left; position: absolute; top: 0; left: 0;">
<div t-ref="badgeTemplate" class="badge-template">
<header>
<div class="logo">
<img t-if="state.companyLogo" t-att-src="state.companyLogo" alt="Logo" class="logo"/>
</div>
</header>
<div class="content">
<div class="headline1">An der Theke für Dich</div>
<div class="headline2">Werkstattaufsicht und Werkzeugausgabe</div>
<img t-if="state.croppedImageDataUrl"
t-att-src="state.croppedImageDataUrl"
alt="avatar"
class="centered-image"/>
<div class="your-name" t-esc="state.employeeName || 'Dein Name'"/>
<hr/>
<div class="headline3">Themenschwerpunkte:</div>
<div class="topics" t-esc="state.jobFocus || 'Deine Schwerpunkte'"/>
</div>
<footer><div class="warnung">! Zugang zur Theke nur für Berechtigte !</div></footer>
</div>
</div>
</div>
</div>
<small class="text-muted d-block mt-2 text-center">
A4-Format (21×29.7cm) - 35% Vorschau
</small>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="modal-footer">
<button type="button"
class="btn btn-secondary"
t-on-click="cancel">
Abbrechen
</button>
<button t-if="state.step > 1 and state.step &lt; 3"
type="button"
class="btn btn-light"
t-on-click="previousStep">
<i class="fa fa-arrow-left me-2"/>
Zurück
</button>
<button t-if="state.step === 2"
type="button"
class="btn btn-primary"
t-on-click="cropImage">
Weiter
<i class="fa fa-arrow-right ms-2"/>
</button>
<button t-if="state.step === 3"
type="button"
class="btn btn-success"
t-on-click="saveFinalImage">
<i class="fa fa-check me-2"/>
Speichern
</button>
</div>
</div>
</div>
</t>
</templates>

View File

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Erweitere das Employee Form View -->
<record id="view_employee_form_image_generator" model="ir.ui.view">
<field name="name">hr.employee.form.image.generator</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<!-- Füge Button nach dem image_1920 Feld ein -->
<xpath expr="//field[@name='image_1920']" position="after">
<button name="action_open_image_generator"
type="object"
string="✏️ Bild bearbeiten"
class="btn-link"
style="margin-left: 8px;"
invisible="not id"/>
</xpath>
<!-- Füge job_focus Feld nach job_title ein -->
<xpath expr="//field[@name='job_title']" position="after">
<field name="job_focus" placeholder="z.B. Fahrrad-Reparatur, E-Bikes, Werkstatt"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -1,716 +0,0 @@
# Projektplan: MQTT-basierte IoT-Events in Odoo 18 Community (Hardware-First mit Shelly PM Mini G3)
Stand: 2026-01-22 (aktualisiert)
Original: 2026-01-10 : https://chatgpt.com/share/696e559d-1640-800f-9d53-f7a9d1e784bd
Ziel: **Odoo-18-Community (self-hosted)** soll **Geräte-Events** (Timer/Maschinenlaufzeit, Waage, Zustandswechsel) über **MQTT** aufnehmen und in Odoo als **saubere, nachvollziehbare Sessions/Events** verarbeiten.
**Änderung der Vorgehensweise**: Entwicklung startet mit **echtem Shelly PM Mini G3** Hardware-Device, das bereits im MQTT Broker integriert ist. Python-Prototyp wird so entwickelt, dass Code später direkt in Odoo-Bridge übernommen werden kann.
---
## 1. Ziele und Nicht-Ziele
### 1.1 Ziele (MVP)
- **Hardware-Integration**: Shelly PM Mini G3 als primäres Test-Device
- **Session-Logik** für Maschinenlaufzeit basierend auf Power-Schwellenwerten (Start/Stop mit Hysterese)
- **Python-Prototyp** (Standalone) der später in Odoo-Bridge übernommen wird:
- MQTT Client für Shelly-Integration
- Event-Normalisierung (Shelly-Format → Unified Event Schema)
- Session-Detection Engine
- Konfigurierbare Device-Mappings und Schwellenwerte
- **Odoo-Integration** (Phase 2):
- Einheitliche **Device-Event-Schnittstelle** (REST/Webhook) inkl. Authentifizierung
- **Event-Log** in Odoo (persistente Rohereignisse + Normalisierung)
- Session-Verwaltung mit Maschinenzuordnung
- Reproduzierbare Tests und eindeutige Fehlerdiagnostik (Logging)
### 1.2 Nicht-Ziele (für Phase 1)
- Keine Enterprise-IoT-Box, keine Enterprise-Module
- Keine Simulatoren in Phase 1 (nur echte Hardware)
- Kein POS-Frontend-Live-Widget (optional erst in späterer Phase)
- Keine Abrechnungslogik/Preisregeln (kann vorbereitet, aber nicht umgesetzt werden)
---
## 2. Zielarchitektur (Docker-First, Sidecar-Pattern)
### 2.1 Komponenten
1. **MQTT Broker**: Mosquitto in Docker (bereits vorhanden)
2. **IoT Bridge Service**: Separater Docker Container
- Image: `iot_mqtt_bridge_for_odoo`
- Source: `iot_bridge/` Unterverzeichnis
- MQTT Client (subscribed auf Topics)
- Session Detection Engine (State Machine)
- Event-Normalisierung & Parsing
- Kommunikation mit Odoo via REST API
3. **Odoo Container**: Business Logic & Konfiguration
- REST API für Bridge-Konfiguration: `GET /ows/iot/config`
- REST API für Event-Empfang: `POST /ows/iot/event`
- Models: Devices, Events, Sessions
- Admin UI für Device-Management
4. **Docker Compose**: Orchestrierung aller Services
- Shared Network
- Shared Volumes (optional)
- Services starten zusammen: `docker compose up -d`
### 2.2 Datenfluss
```
Shelly PM → MQTT Broker ← Bridge Service → REST API → Odoo
(Subscribe) (POST) (Business Logic)
```
1. Bridge subscribed auf MQTT Topics (z.B. `shaperorigin/status/pm1:0`)
2. Bridge normalisiert Payload zu Unified Event Schema
3. Bridge erkennt Sessions via State Machine (Dual-Threshold, Debounce)
4. Bridge sendet Events via `POST /ows/iot/event` an Odoo
5. Odoo speichert Events + aktualisiert Session-Status
### 2.3 Vorteile Sidecar-Pattern
- ✅ **Klare Trennung**: Odoo = Business Logic, Bridge = MQTT/Retry/Queue
- ✅ **Unabhängige Prozesse**: Bridge restart ohne Odoo-Downtime
- ✅ **Docker Best Practice**: Ein Prozess pro Container
- ✅ **Einfaches Setup**: `docker compose up -d` startet alles
- ✅ **Kein Overhead**: Gleiche Network/Volumes, kein Extra-Komplexität
---
## 3. Schnittstellen zwischen Bridge und Odoo
### 3.1 Bridge → Odoo: Event-Empfang
**Endpoint:** `POST /ows/iot/event`
**Authentifizierung (optional):**
- **Empfohlen für**: Produktiv-Umgebung, Multi-Host-Setup, externe Zugriffe
- **Nicht nötig für**: Docker-Compose-Setup (isoliertes Netzwerk)
- Header: `Authorization: Bearer <BRIDGE_API_TOKEN>` (falls aktiviert)
- Token wird Bridge als ENV-Variable übergeben: `ODOO_TOKEN=...`
- Token in Odoo: `ir.config_parameter``ows_iot.bridge_token`
- Aktivierung: `ir.config_parameter``ows_iot.require_token = true`
**Request Body:**
**Variante 1: Session Lifecycle Events**
```json
{
"schema_version": "v1",
"event_uid": "uuid",
"ts": "2026-01-31T10:30:15Z",
"device_id": "shellypmminig3-48f6eeb73a1c",
"event_type": "session_started",
"payload": { "session_id": "sess-abc123" }
}
```
**Variante 2: Session Heartbeat (periodisch, aggregiert)**
```json
{
"schema_version": "v1",
"event_uid": "uuid",
"ts": "2026-01-31T10:35:00Z",
"device_id": "shellypmminig3-48f6eeb73a1c",
"event_type": "session_heartbeat",
"payload": {
"session_id": "sess-abc123",
"interval_start": "2026-01-31T10:30:00Z",
"interval_end": "2026-01-31T10:35:00Z",
"interval_working_s": 200,
"interval_standby_s": 100,
"current_state": "WORKING",
"avg_power_w": 142.3
}
}
```
**Response:**
- `200 OK`: `{ "status": "ok", "event_id": 123, "session_id": 456 }`
- `400 Bad Request`: Schema-Fehler
- `401 Unauthorized`: Token fehlt/ungültig (nur wenn Auth aktiviert)
- `409 Conflict`: Duplikat (wenn `event_uid` bereits existiert)
- `500 Internal Server Error`: Odoo-Fehler
**Idempotenz:**
- `event_uid` hat Unique-Constraint in Odoo
- Wiederholte Events → `409 Conflict` (Bridge ignoriert)
---
### 3.2 Bridge ← Odoo: Konfiguration abrufen
**Endpoint:** `GET /ows/iot/config`
**Authentifizierung (optional):**
- Header: `Authorization: Bearer <BRIDGE_API_TOKEN>` (falls aktiviert)
**Response:**
```json
{
"devices": [
{
"device_id": "shellypmminig3-48f6eeb73a1c",
"mqtt_topic": "shaperorigin/status/pm1:0",
"parser_type": "shelly_pm_mini_g3",
"machine_name": "Shaper Origin",
"session_config": {
"strategy": "power_threshold",
"standby_threshold_w": 20,
"working_threshold_w": 100,
"start_debounce_s": 3,
"stop_debounce_s": 15,
"message_timeout_s": 20,
"heartbeat_interval_s": 300
}
}
]
}
```
**Wann aufgerufen:**
- Bridge-Start: Initiale Config laden
- Alle 5 Minuten: Config-Refresh (neue Devices, geänderte Schwellenwerte)
- Bei 401/403 Response von `/ows/iot/event`: Token evtl. ungültig geworden
---
## 4. Ereignis- und Topic-Standard (Versioniert)
### 4.1 MQTT Topics (v1)
- Maschinenzustand:
`hobbyhimmel/machines/<machine_id>/state`
- Maschinenereignisse:
`hobbyhimmel/machines/<machine_id>/event`
- Waage:
`hobbyhimmel/scales/<scale_id>/event`
- Geräte-Status (optional):
`hobbyhimmel/devices/<device_id>/status`
### 4.2 Gemeinsames JSON Event Schema (v1)
Pflichtfelder:
- `schema_version`: `"v1"`
- `event_uid`: UUID/string
- `ts`: ISO-8601 UTC (z. B. `"2026-01-10T12:34:56Z"`)
- `source`: `"simulator" | "device" | "gateway"`
- `device_id`: string
- `entity_type`: `"machine" | "scale" | "sensor"`
- `entity_id`: string (z. B. machine_id)
- `event_type`: string (siehe unten)
- `payload`: object
- `confidence`: `"high" | "medium" | "low"` (für Sensorfusion)
### 4.3 Event-Typen (v1)
**Maschine/Timer**
- `session_started` (Session-Start, Bridge erkannt Aktivität)
- `session_heartbeat` (periodische Aggregation während laufender Session)
- `session_ended` (Session-Ende nach Timeout oder manuell)
- `session_timeout` (Session automatisch beendet wegen Inaktivität)
- `state_changed` (optional, Echtzeit-State-Change: IDLE/STANDBY/WORKING)
- `fault` (Fehler mit Code/Severity)
**Waage**
- `stable_weight` (stabiler Messwert)
- `weight` (laufend)
- `tare`
- `zero`
- `error`
---
## 5. Odoo Datenmodell (Vorschlag)
### 5.1 `ows.iot.device`
- `name`
- `device_id` (unique)
- `token_hash` (oder Token in separater Tabelle)
- `device_type` (machine/scale/...)
- `active`
- `last_seen`
- `notes`
### 5.2 `ows.iot.event`
- `event_uid` (unique)
- `device_id` (m2o -> device)
- `entity_type`, `entity_id`
- `event_type`
- `timestamp`
- `payload_json` (Text/JSON)
- `confidence`
- `processing_state` (new/processed/error)
- `session_id` (m2o optional)
### 5.3 `ows.machine.session` (Timer-Sessions)
- `machine_id` (Char oder m2o auf bestehendes Maschinenmodell)
- `session_id` (external, von Bridge generiert)
- `start_ts`, `stop_ts`
- `duration_s` (computed: stop_ts - start_ts)
- `total_working_time_s` (Summe aller WORKING-Intervalle)
- `total_standby_time_s` (Summe aller STANDBY-Intervalle)
- `state` (running/stopped/aborted/timeout)
- `origin` (sensor/manual/sim)
- `billing_units` (computed: ceil(total_working_time_s / billing_unit_seconds))
- `event_ids` (o2m → session_heartbeat Events)
> Hinweis: Wenn du bereits `ows.machine` aus deinem open_workshop nutzt, referenziert `machine_id` direkt dieses Modell.
---
## 6. Verarbeitungslogik (Bridge & Odoo)
### 6.1 Bridge: State Tracking & Aggregation
**State Machine (5 Zustände):**
- `IDLE` (0-20W): Maschine aus/Leerlauf
- `STARTING` (Debounce): Power > standby_threshold für < start_debounce_s
- `STANDBY` (20-100W): Maschine an, aber nicht aktiv arbeitend
- `WORKING` (>100W): Maschine arbeitet aktiv
- `STOPPING` (Debounce): Power < standby_threshold für < stop_debounce_s
**Aggregation Logic:**
- Bridge trackt intern State-Wechsel (1 msg/s von Shelly)
- Alle `heartbeat_interval_s` (z.B. 300s = 5 Min):
- Berechne `interval_working_s` (Summe aller WORKING-Zeiten)
- Berechne `interval_standby_s` (Summe aller STANDBY-Zeiten)
- Sende `session_heartbeat` an Odoo
- Bei Session-Start: `session_started` Event
- Bei Session-Ende: `session_ended` Event mit Totals
### 6.2 Odoo: Session-Aggregation & Billing
- Empfängt `session_heartbeat` Events
- Summiert `total_working_time_s` und `total_standby_time_s`
- Berechnet `billing_units` nach Maschinen-Konfiguration:
```python
billing_unit_minutes = machine.billing_unit_minutes # z.B. 5
billing_units = ceil(total_working_time_s / (billing_unit_minutes * 60))
```
- Timeout-Regel: wenn kein Heartbeat für `2 * heartbeat_interval_s` → Session `timeout`
---
## 7. Simulation (Software statt Hardware)
### 7.1 Device Simulator: Maschine
- Konfigurierbar:
- Muster: random, fixed schedule, manuell per CLI
- Zustände: idle/running/fault
- Optional: „power_w“ und „vibration“ als Felder im Payload
- Publiziert MQTT in realistischen Intervallen
### 7.2 Device Simulator: Waage
- Modi:
- stream weight (mehrfach pro Sekunde)
- stable_weight nur auf „stabil“
- tare/zero Events per CLI
### 7.3 Bridge Simulator (MQTT → Odoo)
- Abonniert alle relevanten Topics
- Validiert Schema v1
- POSTet Events an Odoo
- Retry-Queue (lokal) bei Odoo-Ausfall
- Metriken/Logs:
- gesendete Events, Fehlerquoten, Latenz
---
## 8. Milestones & Deliverables (NEU: Hardware-First Approach)
### Phase 1: Python-Prototyp (Standalone, ohne Odoo)
#### M0 Projekt Setup & MQTT Verbindung (0.5 Tag)
**Deliverables**
- Python Projekt Struktur
- Requirements.txt (paho-mqtt, pyyaml, etc.)
- Config-Datei (YAML): MQTT Broker Settings
- MQTT Client Basis-Verbindung testen
**Test**: Verbindung zum MQTT Broker herstellen, Topics anzeigen
---
#### M1 Shelly PM Mini G3 Integration (1 Tag)
**Deliverables**
- MQTT Subscriber für Shelly-Topics
- Parser für Shelly JSON-Payloads (beide Formate):
- Status-Updates (full data mit voltage, current, apower)
- NotifyStatus Events (einzelne Werte)
- Datenextraktion: `apower`, `timestamp`, `device_id`
- Logging aller empfangenen Shelly-Messages
**Test**: Shelly-Daten empfangen und parsen, Console-Output
---
#### M2 Event-Normalisierung & Unified Schema (1 Tag)
**Deliverables**
- Unified Event Schema v1 Implementation
- Shelly-to-Unified Konverter:
- `apower` Updates → `power_measurement` Events
- Enrichment mit `device_id`, `machine_id`, `timestamp`
- Event-UID Generierung
- JSON-Export der normalisierten Events
**Test**: Shelly-Daten werden zu Schema-v1-konformen Events konvertiert
---
#### M3 Session Detection Engine (1-2 Tage)
**Deliverables**
- State Machine für run_start/run_stop Detection
- Konfigurierbare Parameter pro Device:
- `power_threshold` (z.B. 50W)
- `start_debounce_s` (z.B. 3s)
- `stop_debounce_s` (z.B. 15s)
- Session-Objekte:
- `session_id`, `machine_id`, `start_ts`, `stop_ts`, `duration_s`
- `events` (Liste der zugehörigen Events)
- Session-Export (JSON/CSV)
**Test**:
- Maschine anschalten → `run_start` Event
- Maschine ausschalten → `run_stop` Event
- Sessions werden korrekt erkannt und gespeichert
---
#### M4 Multi-Device Support & Config (0.5-1 Tag)
**Deliverables**
- Device-Registry in Config:
```yaml
devices:
- shelly_id: "shellypmminig3-48f6eeb73a1c"
machine_name: "Shaper Origin"
power_threshold: 50
start_debounce_s: 3
stop_debounce_s: 15
```
- Dynamisches Laden mehrerer Devices
- Parallele Session-Tracking pro Device
**Test**: Mehrere Shelly-Devices in Config, jedes wird separat getrackt
---
#### M5 Monitoring & Robustheit (0.5 Tag)
**Deliverables**
- Reconnect-Logik bei MQTT-Disconnect
- Error-Handling bei ungültigen Payloads
- Statistiken: empfangene Events, aktive Sessions, Fehler
- Strukturiertes Logging (Logger-Konfiguration)
**Test**: MQTT Broker Neustart → Client reconnected automatisch
---
### Phase 2: Docker-Container-Architektur
#### M6 IoT Bridge Docker Container (2-3 Tage)
**Deliverables:**
- Verzeichnis: `iot_bridge/`
- `main.py` - Bridge Hauptprogramm
- `mqtt_client.py` - MQTT Client (von Odoo portiert)
- `session_detector.py` - State Machine (von Odoo portiert)
- `parsers/` - Shelly, Tasmota, Generic
- `odoo_client.py` - REST API Client für Odoo
- `config.py` - Config Management (ENV + Odoo)
- `requirements.txt` - Python Dependencies
- `Dockerfile` - Multi-stage Build
- Docker Image: `iot_mqtt_bridge_for_odoo`
- ENV-Variablen:
- `ODOO_URL` (z.B. `http://odoo:8069`) - **Pflicht**
- `MQTT_URL` (z.B. `mqtt://mosquitto:1883`) - **Pflicht**
- `ODOO_TOKEN` (API Token für Bridge) - **Optional**, nur wenn Auth in Odoo aktiviert
- `LOG_LEVEL`, `CONFIG_REFRESH_INTERVAL` - Optional
**Test:**
- Bridge startet via `docker run`
- Verbindet zu MQTT Broker
- Holt Config von Odoo via `GET /ows/iot/config`
- Subscribed auf Topics
---
#### M7 Odoo REST API Endpoints (1 Tag)
**Deliverables:**
- Controller: `controllers/iot_api.py`
- `GET /ows/iot/config` - Bridge-Konfiguration
- `POST /ows/iot/event` - Event-Empfang
- **Optionale** Token-Authentifizierung (Bearer, via `ows_iot.require_token`)
- Event-Validation gegen Schema v1
- Event → Session Mapping
**Test:**
- `curl -H "Authorization: Bearer <token>" http://localhost:8069/ows/iot/config`
- `curl -X POST -H "Authorization: Bearer <token>" -d '{...}' http://localhost:8069/ows/iot/event`
---
#### M8 Docker Compose Integration (0.5 Tag)
**Deliverables:**
- `docker-compose.yaml` Update:
```yaml
services:
odoo:
# ... existing config ...
iot_bridge:
image: iot_mqtt_bridge_for_odoo
environment:
ODOO_URL: http://odoo:8069
MQTT_URL: mqtt://mosquitto:1883
# ODOO_TOKEN: ${IOT_BRIDGE_TOKEN} # Optional: nur für Produktiv
depends_on:
- odoo
- mosquitto
networks:
- odoo_network
restart: unless-stopped
```
- `.env` Template mit `IOT_BRIDGE_TOKEN` (optional, für Produktiv-Setup)
- README Update: Setup-Anleitung
**Test:**
- `docker compose up -d` startet Odoo + Bridge + Mosquitto
- Bridge subscribed Topics
- Events erscheinen in Odoo UI
---
#### M9 End-to-End Tests & Dokumentation (1 Tag)
**Deliverables:**
- Integration Tests: Shelly → MQTT → Bridge → Odoo
- Session Tests: run_start/run_stop Detection
- Container Restart Tests: Bridge/Odoo Recovery
- Dokumentation:
- `iot_bridge/README.md` - Bridge Architektur
- `DEPLOYMENT.md` - Docker Compose Setup
- API Docs - REST Endpoints
**Test:**
- End-to-End: Shelly PM einschalten → Session erscheint in Odoo
- Container Restart → Sessions werden recovered
- Config-Änderung in Odoo → Bridge lädt neu (nach 5 Min)
---
#### M10 Tests & Dokumentation (1 Tag)
**Deliverables**
- Unit Tests: Event-Parsing, Session-Detection
- Integration Tests: MQTT → Odoo End-to-End
- Dokumentation: Setup-Anleitung, Config-Beispiele, API-Docs
---
### Optional M11 POS/Anzeige (später)
- Realtime Anzeige im POS oder auf Display
- Live Power-Consumption in Odoo
---
## 9. Testplan (Simulation-first)
### 9.1 Unit Tests (Odoo)
- Auth: gültiger Token → 200
- ungültig/fehlend → 401/403
- Schema-Validation → 400
- Idempotenz: duplicate `event_uid` → 409 oder duplicate-flag
### 9.2 Integration Tests
- Sequenz: start → heartbeat → stop → Session duration plausibel
- stop ohne start → kein Crash, Event loggt Fehlerzustand
- Timeout: start → keine heartbeat → Session aborted
### 9.3 Last-/Stabilitätstest (Simulator)
- 20 Maschinen, je 1 Event/s, 1h Lauf
- Ziel: Odoo bleibt stabil, Event-Insert performant, Queue läuft nicht über
---
## 10. Betriebs- und Sicherheitskonzept
- Token-Rotation möglich (neues Token, altes deaktivieren)
- Reverse Proxy:
- HTTPS
- Rate limiting auf `/ows/iot/event`
- optional Basic WAF Regeln
- Payload-Größenlimit (z. B. 1664 KB)
- Bridge persistiert Retry-Queue auf Disk
---
## 11. Offene Entscheidungen (später, nicht blocker für MVP)
- Event-Consumer in Odoo vs. Bridge-only Webhook (empfohlen: Webhook)
- Genaues Mapping zu bestehenden open_workshop Modellen (`ows.machine`)
- User-Zuordnung zu Sessions (manuell über UI oder zukünftig via RFID/NFC)
- Abrechnung: Preisregeln, Rundungen, POS-Integration
- Realtime: Odoo Bus vs. eigener WebSocket Service
---
## 12. Nächste Schritte (AKTUALISIERT: Hardware-First)
### Sofort starten (Phase 1 - Python Prototyp):
1. **M0**: Python-Projekt aufsetzen, MQTT-Verbindung testen
2. **M1**: Shelly PM Mini G3 Daten empfangen und parsen
3. **M2**: Event-Normalisierung implementieren (Shelly → Schema v1)
4. **M3**: Session Detection Engine (run_start/run_stop basierend auf Power)
5. **M4**: Multi-Device Config-Support
### Danach (Phase 2 - Odoo Integration):
6. **M6-M9**: Odoo Module + Bridge + Session-Engine
7. **M10**: Tests & Dokumentation
### Offene Informationen benötigt:
- [ ] Genaue MQTT Topic-Namen des Shelly PM Mini G3
- [ ] Typische Power-Werte des Shaper Origin (Idle vs. Running)
- [ ] Gewünschte Schwellenwerte für Start/Stop-Detection
- [ ] MQTT Broker Zugangsdaten (Host, Port, User, Password)
---
## Anhang C: Shelly PM Mini G3 Payload-Beispiele
### Format 1: Full Status Update
```json
{
"id": 0,
"voltage": 234.7,
"current": 0.289,
"apower": 59.7,
"freq": 49.9,
"aenergy": {
"total": 256.325,
"by_minute": [415.101, 830.202, 830.202],
"minute_ts": 1769100576
},
"ret_aenergy": {
"total": 0.000,
"by_minute": [0.000, 0.000, 0.000],
"minute_ts": 1769100576
}
}
```
### Format 2: NotifyStatus Event
```json
{
"src": "shellypmminig3-48f6eeb73a1c",
"dst": "shellypmminig3/events",
"method": "NotifyStatus",
"params": {
"ts": 1769100615.87,
"pm1:0": {
"id": 0,
"current": 0.185
}
}
}
```
### Mapping zu Unified Event Schema
```json
{
"schema_version": "v1",
"event_uid": "generated-uuid",
"ts": "2026-01-22T10:30:15Z",
"source": "shelly_pm_mini_g3",
"device_id": "shellypmminig3-48f6eeb73a1c",
"entity_type": "machine",
"entity_id": "shaper-origin-01",
"event_type": "power_measurement",
"confidence": "high",
"payload": {
"apower": 59.7,
"voltage": 234.7,
"current": 0.289,
"frequency": 49.9,
"total_energy_kwh": 0.256325
}
}
```
### Session Detection Beispiel
```yaml
# Device Config
device_id: shellypmminig3-48f6eeb73a1c
machine_name: Shaper Origin
power_threshold: 50 # Watt
start_debounce_s: 3 # Power > threshold für 3s → run_start
stop_debounce_s: 15 # Power < threshold für 15s run_stop
```
**Session Flow**:
1. `apower: 5W` (idle) → keine Session
2. `apower: 120W` → Power > 50W → Debounce startet
3. Nach 3s immer noch > 50W → `run_start` Event, Session öffnet
4. `apower: 95W, 110W, 88W` → Session läuft weiter
5. `apower: 12W` → Power < 50W Stop-Debounce startet
6. Nach 15s immer noch < 50W `run_stop` Event, Session schließt
7. Session: `duration = stop_ts - start_ts`
---
## Anhang A: Beispiel-Event (Session Heartbeat)
### Szenario: 5 Minuten mit mehreren State-Wechseln
**Bridge-internes Tracking (nicht an Odoo gesendet):**
```
10:00:00 120W WORKING (Session startet)
10:01:30 35W STANDBY (State-Wechsel)
10:02:15 155W WORKING (State-Wechsel)
10:03:00 28W STANDBY (State-Wechsel)
10:04:30 142W WORKING (State-Wechsel)
10:05:00 138W WORKING (Heartbeat-Intervall erreicht)
```
**Aggregiertes Event an Odoo:**
```json
{
"schema_version": "v1",
"event_uid": "heartbeat-sess-abc123-10-05-00",
"ts": "2026-01-31T10:05:00Z",
"device_id": "shellypmminig3-48f6eeb73a1c",
"entity_type": "machine",
"entity_id": "shaper-origin-01",
"event_type": "session_heartbeat",
"confidence": "high",
"payload": {
"session_id": "sess-abc123",
"interval_start": "2026-01-31T10:00:00Z",
"interval_end": "2026-01-31T10:05:00Z",
"interval_working_s": 200,
"interval_standby_s": 100,
"current_state": "WORKING",
"avg_power_w": 142.3,
"state_change_count": 4
}
}
```
**Odoo Verarbeitung:**
```python
# Session Update
session.total_working_time_s += 200 # += interval_working_s
session.total_standby_time_s += 100 # += interval_standby_s
# Billing Berechnung (5-Minuten-Einheiten)
billing_units = ceil(session.total_working_time_s / 300) # 200s / 300s = 0.67 → 1 Einheit
```
## Anhang B: Beispiel-Event (Waage stable_weight)
```json
{
"schema_version": "v1",
"event_uid": "b6d0a2c5-9b1f-4a0b-8b19-7f2e1b8f3d11",
"ts": "2026-01-10T12:40:12Z",
"source": "simulator",
"device_id": "scale-sim-01",
"entity_type": "scale",
"entity_id": "waage-01",
"event_type": "stable_weight",
"confidence": "high",
"payload": {
"weight_g": 1532.4,
"unit": "g",
"stable_ms": 1200
}
}
```

View File

@ -1,381 +0,0 @@
# Implementation Plan: IoT Bridge & Odoo Integration
**Ziel:** Sidecar-Container-Architektur mit periodischer Session-Aggregation
**Stand:** 03.02.2026
**Strategie:** Bridge zuerst (standalone testbar), dann Odoo API, dann Integration
---
## 📦 Bestandsaufnahme
### Vorhanden (wiederverwendbar)
- ✅ `python_prototype/` - Standalone MQTT Client & Session Detector (Basis für Bridge)
- ✅ `services/mqtt_client.py` - Odoo-integrierter MQTT Client (Referenz)
- ✅ `services/session_detector.py` - State Machine Logik (portierbar)
- ✅ `services/parsers/shelly_parser.py` - Shelly PM Parser (direkt übernehmen)
- ✅ `models/` - Odoo Models (anzupassen)
- ✅ Mosquitto MQTT Broker (läuft)
### Neu zu erstellen
- ❌ `iot_bridge/` Container-Source (Skeleton existiert)
- ❌ Odoo REST API Controller
- ❌ Session-Aggregation in Bridge
- ❌ Odoo Session-Model mit Billing-Logik
- ❌ Docker Compose Integration
---
## 🎯 Phase 1: Bridge Container (Standalone)
**Ziel:** Bridge läuft unabhängig, loggt auf Console, nutzt YAML-Config
### 1.1 Bridge Grundstruktur ✅
- [x] `iot_bridge/config.yaml` erstellen (Device-Registry, MQTT Settings)
- [x] `iot_bridge/config.py` - Config Loader (YAML → Dataclass)
- [x] Mock-OdooClient (gibt hardcoded Config zurück, kein HTTP)
- [x] Logger Setup (structlog mit JSON output)
**Test:** ✅ Bridge startet, lädt Config, loggt Status (JSON), Graceful Shutdown funktioniert
---
---
### 1.2 MQTT Client portieren ✅
- [x] `python_prototype/mqtt_client.py``iot_bridge/mqtt_client.py`
- [x] Shelly Parser integrieren (copy from `services/parsers/`)
- [x] Connection Handling (reconnect, error handling)
- [x] Message-Callback registrieren
- [x] TLS/SSL Support (Port 8883)
**Test:** ✅ Bridge empfängt Shelly-Messages (40-55W), parst apower, loggt auf Console (JSON), TLS funktioniert
**Reconnect Test:** ✅ Broker neugestartet → Bridge disconnected (rc=7) → Auto-Reconnect → Re-Subscribe → Messages wieder empfangen
---
### 1.3 Session Detector mit Aggregation ✅
- [x] `session_detector.py` portieren (5-State Machine)
- [x] Aggregation-Logik hinzufügen:
- Interner State-Tracker (1s Updates)
- Timer für `heartbeat_interval_s`
- Berechnung: `interval_working_s`, `interval_standby_s`
- [x] Session-IDs generieren (UUID)
- [x] Events sammeln (event_callback)
- [x] Unit Tests (9 Tests, alle PASSED)
- [x] Shelly Simulator für Testing
- [x] Integration Tests (7 Tests, alle PASSED)
- [x] Lokaler Mosquitto Container (docker-compose.dev.yaml)
**Test:** ✅ Session gestartet (34.5W > 20W threshold), State-Machine funktioniert, Heartbeat-Events nach 10s
**Unit Tests:** ✅ 9/9 passed (test_session_detector.py)
**Integration Tests:** ✅ 7/7 passed (test_bridge_integration.py) - Session Start, Heartbeat, Multi-Device, Timeout
---
### 1.4 Event Queue & Retry Logic ✅
- [x] `event_queue.py`: Event-Queue mit Retry-Logic
- Exponential Backoff (1s → 2s → 4s → ... → max 60s)
- Max 10 Retries
- Background-Thread für Queue-Processing
- Thread-safe mit `collections.deque` und `threading.Lock`
- [x] `event_uid` zu allen Events hinzufügen (UUID)
- [x] Queue-Integration in `main.py`
- `on_event_generated()` nutzt Queue statt direktem send
- Queue-Start/Stop im Lifecycle
- [x] Mock-Odoo Failure-Simulation
- `mock_failure_rate` in config.yaml (0.0-1.0)
- MockOdooClient wirft Exceptions bei failures
- [x] Unit Tests: `test_event_queue.py` (13 Tests, alle PASSED)
- Queue Operations (enqueue, statistics)
- Exponential Backoff Berechnung
- Retry Logic mit Mock-Callback
- Max Retries exceeded
- [x] Integration Tests: `test_retry_logic.py` (2 Tests, PASSED in 48.29s)
- test_retry_on_odoo_failure: Events werden enqueued
- test_eventual_success_after_retries: 50% failure rate → eventual success
**Test:** ✅ Mock-Odoo-Client gibt 500 → Events in Queue → Retry mit Backoff → Success
**Unit Tests:** ✅ 13/13 passed
**Integration Tests:** ✅ 2/2 passed in 48.29s
---
### 1.5 Docker Container Build
- [x] Multi-stage `Dockerfile` finalisiert
- Builder Stage: gcc + pip install dependencies
- Runtime Stage: minimal image, non-root user (bridge:1000)
- Python packages korrekt nach /usr/local/lib/python3.11/site-packages kopiert
- [x] `.dockerignore` erstellt (venv/, tests/, __pycache__, logs/, data/)
- [x] `requirements.txt` erweitert mit PyYAML>=6.0
- [x] `docker build -t iot_mqtt_bridge:latest`
- 3 Build-Iterationen (Package-Path-Fix erforderlich)
- Finale Image: 8b690d20b5f7
- [x] `docker run` erfolgreich getestet
- Volume mount: config.yaml (read-only)
- Network: host (für MQTT-Zugriff)
- Container verbindet sich mit mqtt.majufilo.eu:8883
- Sessions werden erkannt und Events verarbeitet
- [x] Development Setup
- `config.yaml.dev` für lokale Tests (Mosquitto + Odoo)
- `docker-compose.dev.yaml` erweitert mit iot-bridge service
**Test:** ✅ Container läuft produktiv, empfängt MQTT, erkennt Sessions
**Docker:** ✅ Multi-stage build, 71MB final image, non-root user
---
## 🎯 Phase 2: Odoo REST API
**Ziel:** Odoo REST API für Bridge-Config und Event-Empfang
### 2.1 Models anpassen
- [x] **ows.iot.event** Model erstellt (`models/iot_event.py`)
- `event_uid` (unique constraint)
- `event_type` (selection: session_started/updated/stopped/timeout/heartbeat/power_change)
- `payload_json` (JSON Field)
- `device_id`, `session_id` (Char fields)
- Auto-Linking zu `mqtt.device` und `mqtt.session`
- Payload-Extraktion: `power_w`, `state`
- `processed` flag, `processing_error` für Error-Tracking
- [x] **mqtt.device** erweitert:
- `device_id` (External ID für Bridge API)
- Bestehende Felder: `strategy_config` (JSON), session_strategy, parser_type, topic_pattern
- [x] **mqtt.session** - bereits vorhanden:
- `session_id` (external UUID)
- Duration fields: `total_duration_s`, `standby_duration_s`, `working_duration_s`
- State tracking: `status` (running/completed), `current_state`
- Power tracking: `start_power_w`, `end_power_w`, `current_power_w`
**Test:** ✅ Models erstellt, Security Rules aktualisiert
---
### 2.2 REST API Controller
- [x] `controllers/iot_api.py` erstellt
- [x] **GET /ows/iot/config**:
- Returns: Alle aktiven Devices mit `session_config` als JSON
- Auth: public (später API-Key möglich)
- Response Format:
```json
{
"status": "success",
"devices": [{
"device_id": "...",
"mqtt_topic": "...",
"parser_type": "...",
"machine_name": "...",
"session_config": { strategy, thresholds, ... }
}],
"timestamp": "ISO8601"
}
```
- [x] **POST /ows/iot/event**:
- Schema-Validation (event_type, device_id, event_uid, timestamp required)
- Event-UID Duplikat-Check → 409 Conflict (idempotent)
- Event speichern in `ows.iot.event`
- Auto-Processing: Session erstellen/updaten/beenden
- Response Codes: 201 Created, 409 Duplicate, 400 Bad Request, 500 Error
- JSON-RPC Format (Odoo type='json')
**Test:** ✅ Controller erstellt, bereit für manuellen Test
```bash
curl http://localhost:8069/ows/iot/config
curl -X POST http://localhost:8069/ows/iot/event -d '{...}'
```
---
### 2.3 Bridge Client - OdooClient
- [x] **OdooClient** implementiert (`iot_bridge/odoo_client.py`)
- `get_config()`: GET /ows/iot/config (HTTP)
- `send_event()`: POST /ows/iot/event (JSON-RPC)
- Retry-Logic über EventQueue (bereits in Phase 1.4)
- Duplicate Handling: 409 wird als Success behandelt
- Error Handling: Exceptions für 4xx/5xx
- HTTP Session mit requests library
- [x] **config.py** erweitert:
- `OdooConfig`: `base_url`, `database`, `username`, `api_key`
- Entfernt: alte `url`, `token` Felder
- [x] **main.py** angepasst:
- OdooClient Initialisierung mit neuen Parametern
- Conditional: MockOdooClient (use_mock=true) oder OdooClient (use_mock=false)
**Test:** ✅ Code fertig, bereit für Integration Test
---
### 2.4 Integration Testing
- [x] Odoo Modul upgraden (Models + Controller laden)
- ✅ Module installed in OWS_MQTT database
- ✅ Deprecation warnings (non-blocking, documented)
- [x] mqtt.device angelegt: "Shaper Origin" (device_id: shellypmminig3-48f6eeb73a1c)
- ✅ Widget fix: CodeEditor 'ace' → 'text' (Odoo 18 compatibility)
- ✅ connection_id made optional (deprecated legacy field)
- [x] API manuell getestet
- ✅ GET /ows/iot/config → HTTP 200, returns device list
- ✅ POST /ows/iot/event → HTTP 200, creates event records
- [x] `config.yaml.dev`: `use_mock=false` gesetzt
- [x] Docker Compose gestartet: Odoo + Mosquitto + Bridge
- [x] End-to-End Tests:
- ✅ MQTT → Bridge → Odoo event flow working
- ✅ Bridge fetches config from Odoo (1 device)
- ✅ Events stored in ows_iot_event table
- ✅ session_started creates mqtt.session automatically
- ✅ session_heartbeat updates session (controller fix applied)
- [x] Odoo 18 Compatibility Fixes:
- ✅ type='json' routes: Use function parameters instead of request.jsonrequest
- ✅ event_type Selection: Added 'session_heartbeat'
- ✅ Timestamp parsing: ISO format → Odoo datetime format
- ✅ Multiple databases issue: Deleted extra DBs (hh18, odoo18) for db_monodb()
- ✅ auth='none' working with single database
**Outstanding Issues:**
- [x] UI Cleanup - MQTT Connection (deprecated) view removal
- ✅ Menu items hidden (commented out in mqtt_menus.xml)
- ✅ mqtt.message menu hidden (replaced by ows.iot.event)
- ✅ New "IoT Events" menu added
- [x] UI Cleanup - mqtt.device form:
- ✅ connection_id field hidden (invisible="1")
- ✅ device_id field prominent ("External Device ID")
- ✅ Parser Type + Session Strategy fields clarified
- ✅ Help text: "Configuration is sent to IoT Bridge via REST API"
- ✅ Strategy config placeholder updated with all required fields
- [x] ows.iot.event Views created:
- ✅ List view with filters (event type, processed status)
- ✅ Form view with payload display
- ✅ Search/filters: event type, device, session, date grouping
- ✅ Security rules configured
- [x] Create Test Device in Odoo:
- ✅ "Test Device - Manual Simulation" (device_id: test-device-manual)
- ✅ Low thresholds for easy testing (10W standby, 50W working)
- ✅ Test script created: tests/send_test_event.sh
- [x] Verify session auto-create/update workflow:
- ✅ session_started creates mqtt.session
- ✅ session_heartbeat updates durations + power
- ✅ total_duration_s = total_working_s + total_standby_s (calculated in controller)
**Next Steps:**
- [ ] UI Testing: Login to Odoo and verify:
- [ ] MQTT -> Devices: See "Shaper Origin" + "Test Device"
- [ ] MQTT -> Sessions: Check duration calculations (Total = Working + Standby)
- [ ] MQTT -> IoT Events: Verify event list, filters work
- [ ] No "Connections" or "Messages" menus visible
- [ ] Duration Field Validation:
- [ ] Check if Total Hours displays correctly (computed from _s fields)
- [ ] Verify Standby Hours + Working Hours sum equals Total Hours
**Test:** ⏳ Core functionality working, UI cleanup needed
---
## 🎯 Phase 3: Integration & End-to-End
**Ziel:** Bridge ↔ Odoo kommunizieren, Docker Compose Setup
### 3.1 Bridge: Odoo Client implementieren
- [ ] `iot_bridge/odoo_client.py`:
- `get_config()` → HTTP GET zu Odoo
- `send_event()` → HTTP POST zu Odoo
- Error Handling (401, 409, 500)
- [ ] Config-Refresh alle 5 Min (von Odoo laden)
- [ ] ENV-Variablen: `ODOO_URL`, `MQTT_URL`
**Test:** Bridge holt Config von Odoo, sendet Events → Odoo empfängt
---
### 3.2 Docker Compose Setup
- [ ] `docker-compose.yaml` updaten:
- Service: `iot_bridge`
- Depends on: `odoo`, `mosquitto`
- ENV: `ODOO_URL=http://odoo:8069`
- [ ] `.env.example` mit Variablen
- [ ] README Update: Setup-Anleitung
**Test:** `docker compose up -d` → Bridge startet → Config von Odoo → Events in DB
---
### 3.3 End-to-End Tests
- [ ] Shelly PM einschalten → Session erscheint in Odoo
- [ ] Mehrere State-Wechsel → Heartbeat aggregiert korrekt
- [ ] Bridge Restart → Sessions werden recovered
- [ ] Odoo Config ändern → Bridge lädt neu
**Test:** Real-World-Szenario mit echter Hardware durchspielen
---
## 🎯 Phase 4: Polishing & Dokumentation
### 4.1 Error Handling & Monitoring
- [ ] Bridge: Structured Logging (JSON)
- [ ] Odoo: Event-Processing Errors loggen
- [ ] Metriken: Events sent/failed, Session count
- [ ] Health-Checks für beide Services
---
### 4.2 Dokumentation
- [ ] `iot_bridge/README.md` aktualisieren (ENV vars, Config)
- [ ] `DEPLOYMENT.md` - Produktiv-Setup Guide
- [ ] API Docs - REST Endpoints dokumentieren
- [ ] Troubleshooting Guide
---
## 📊 Dependency Graph
```
Phase 1.1-1.4 (Bridge Core)
Phase 1.5 (Docker)
Phase 2 (Odoo API) ← kann parallel zu 1.1-1.4
Phase 3.1 (Integration)
Phase 3.2-3.3 (E2E)
Phase 4 (Polish)
```
---
## 🚀 Quick Start (nächster Schritt)
**Jetzt starten mit:**
1. [ ] Phase 1.1: `iot_bridge/config.yaml` erstellen
2. [ ] Phase 1.2: MQTT Client portieren
3. [ ] Test: Bridge empfängt Shelly-Messages
**Empfohlene Reihenfolge:**
- Phase 1 komplett durchziehen (Bridge standalone funktionsfähig)
- Phase 2 parallel starten (Odoo API)
- Phase 3 Integration (wenn beides fertig)
---
## ✅ Definition of Done
**Phase 1 Done:**
- [ ] Bridge läuft als Docker Container
- [ ] Empfängt Shelly-Messages
- [ ] State-Detection + Aggregation funktioniert
- [ ] Loggt aggregierte Events auf Console
**Phase 2 Done:**
- [ ] Odoo REST API antwortet
- [ ] Events werden in DB gespeichert
- [ ] Sessions werden erstellt/aktualisiert
- [ ] Billing Units werden berechnet
**Phase 3 Done:**
- [ ] Bridge sendet Events an Odoo
- [ ] Docker Compose startet alles zusammen
- [ ] End-to-End Test erfolgreich
**Phase 4 Done:**
- [ ] Dokumentation vollständig
- [ ] Error Handling robust
- [ ] Produktiv-Ready

View File

@ -1,235 +0,0 @@
# Phase 2 - Odoo REST API Testing Guide
## ✅ Was wurde implementiert
### Models (Phase 2.1)
- **ows.iot.event**: Event Log mit unique `event_uid`
- Alle Events von der Bridge werden geloggt
- Auto-Linking zu mqtt.device und mqtt.session
- Payload-Extraktion (power_w, state)
- **mqtt.device**: Erweitert mit `device_id` (External ID)
- Für API-Zugriff durch Bridge
### REST API Controller (Phase 2.2)
- **GET /ows/iot/config**
- Returns: Liste aller aktiven Devices mit session_config
- Auth: public (später API-Key)
- **POST /ows/iot/event**
- Empfängt Events von Bridge
- Duplikat-Check via event_uid (409 Conflict)
- Auto-Processing: Session erstellen/updaten
- Returns: 201 Created, 409 Duplicate, 400 Bad Request, 500 Error
### Bridge Client (Phase 2.3)
- **OdooClient**: Echte REST API Calls
- `get_config()` → GET /ows/iot/config
- `send_event()` → POST /ows/iot/event (JSON-RPC)
- Retry-Logic über EventQueue
## 🧪 Testing-Schritte
### 1. Odoo Modul upgraden
```bash
cd /home/lotzm/gitea.hobbyhimmel/odoo/odoo
docker compose -f docker-compose.dev.yaml exec odoo-dev odoo --upgrade open_workshop_mqtt
```
Oder über UI: Apps → open_workshop_mqtt → Upgrade
### 2. MQTT Device in Odoo anlegen
UI: MQTT → Devices → Create
**Beispiel-Konfiguration:**
- **Name**: Shaper Origin
- **Device ID**: `shellypmminig3-48f6eeb73a1c`
- **MQTT Connection**: (wähle bestehende oder erstelle neue für local Mosquitto)
- **Topic Pattern**: `shaperorigin/#`
- **Parser Type**: Shelly PM Mini G3
- **Session Strategy**: Power Threshold (Dual)
- **Strategy Configuration**:
```json
{
"standby_threshold_w": 20,
"working_threshold_w": 100,
"start_debounce_s": 3,
"stop_debounce_s": 15,
"message_timeout_s": 20,
"heartbeat_interval_s": 300
}
```
### 3. API manuell testen
**Test 1: GET /ows/iot/config**
```bash
curl http://localhost:8069/ows/iot/config
```
Expected Response:
```json
{
"status": "success",
"devices": [
{
"device_id": "shellypmminig3-48f6eeb73a1c",
"mqtt_topic": "shaperorigin/status/pm1:0",
"parser_type": "shelly_pm",
"machine_name": "Shaper Origin",
"session_config": {
"strategy": "power_threshold",
"standby_threshold_w": 20,
...
}
}
],
"timestamp": "2026-02-05T15:30:00.000000Z"
}
```
**Test 2: POST /ows/iot/event**
```bash
curl -X POST http://localhost:8069/ows/iot/event \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "call",
"params": {
"event_uid": "test-' $(uuidgen) '",
"event_type": "session_started",
"device_id": "shellypmminig3-48f6eeb73a1c",
"session_id": "test-session-123",
"timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)'",
"payload": {
"power_w": 47.9,
"state": "starting"
}
},
"id": null
}'
```
Expected Response:
```json
{
"jsonrpc": "2.0",
"id": null,
"result": {
"status": "success",
"message": "Event received and processed",
"event_uid": "test-...",
"event_id": 1,
"code": 201
}
}
```
**Test 3: Duplicate Event (sollte 409 zurückgeben)**
```bash
# Gleichen curl nochmal ausführen → sollte code: 409 zurückgeben
```
### 4. config.yaml.dev für echtes Odoo anpassen
```yaml
odoo:
base_url: "http://odoo-dev:8069"
database: "your-db-name" # ← WICHTIG: Deinen DB-Namen eintragen
username: "admin"
api_key: "" # Aktuell nicht benötigt (auth='public')
use_mock: false # ← VON true AUF false ÄNDERN
```
### 5. Bridge im Docker Compose starten
```bash
cd /home/lotzm/gitea.hobbyhimmel/odoo/odoo
docker compose -f docker-compose.dev.yaml up -d iot-bridge
docker compose -f docker-compose.dev.yaml logs -f iot-bridge
```
**Erwartete Logs:**
```
Loading configuration from /app/config.yaml
INFO: bridge_started config_file=/app/config.yaml devices=1
INFO: using_real_odoo_client base_url=http://odoo-dev:8069 database=...
INFO: config_loaded device_count=1
INFO: event_queue_started
INFO: session_detector_initialized device=shellypmminig3-48f6eeb73a1c
INFO: connected_to_mqtt broker=mosquitto port=1883
INFO: subscribed_to_topic topic=shaperorigin/status/pm1:0
INFO: bridge_ready status=running
```
### 6. End-to-End Test
**Option A: Mit echtem Shelly Device**
- Sende MQTT-Nachrichten an lokalen Mosquitto
- Bridge empfängt → Session Detection → Events an Odoo
**Option B: Mit MQTT Simulator**
```bash
mosquitto_pub -h localhost -t "shaperorigin/status/pm1:0" \
-m '{"apower": 45.5, "timestamp": 1234567890}'
```
**Prüfen in Odoo:**
1. IoT Events: `ows.iot.event` Records
2. MQTT Sessions: `mqtt.session` Records
3. Logs: Bridge Logs zeigen erfolgreiche API Calls
### 7. Troubleshooting
**Problem: "ModuleNotFoundError: No module named 'requests'"**
→ Docker Image neu bauen (ist schon in requirements.txt)
**Problem: "Connection refused to odoo-dev"**
→ Prüfe dass Odoo Container läuft: `docker compose ps`
**Problem: "Database not found"**
→ Korrekten DB-Namen in config.yaml.dev eintragen
**Problem: "Event UID constraint violation"**
→ Normal bei Restarts - Bridge sendet Events nochmal
→ API returned 409 (duplicate) → wird als Success behandelt
## 📊 Monitoring
### Odoo UI
- **IoT Events**: Settings → Technical → IoT Events
- **MQTT Sessions**: MQTT → Sessions
- **MQTT Devices**: MQTT → Devices
### Bridge Logs
```bash
docker compose -f docker-compose.dev.yaml logs -f iot-bridge
```
### MQTT Messages (Debug)
```bash
mosquitto_sub -h localhost -t "shaperorigin/#" -v
```
## ✅ Success Criteria
- [ ] GET /ows/iot/config returns device list
- [ ] POST /ows/iot/event creates ows.iot.event record
- [ ] Duplicate event_uid returns 409
- [ ] Bridge starts with use_mock=false
- [ ] Bridge fetches config from Odoo
- [ ] Bridge sends events to Odoo
- [ ] mqtt.session is created for session_started event
- [ ] mqtt.session is updated for session_updated event
- [ ] mqtt.session is completed for session_stopped event
- [ ] No errors in Bridge or Odoo logs
## 🎯 Nächste Schritte
Nach erfolgreichem Test:
- Phase 2 Commit
- Optional: API-Key Authentication hinzufügen
- Optional: Health-Check Endpoint für Bridge
- Phase 3: Production Deployment

View File

@ -1,165 +0,0 @@
# Open Workshop MQTT
**MQTT IoT Bridge for Odoo 18** - Sidecar-Container-Architektur
## Architektur-Übersicht
```
┌─────────────┐ MQTT ┌──────────────┐ REST API ┌────────────┐
│ Shelly PM │ ────────────────► │ IoT Bridge │ ──────────────► │ Odoo 18 │
│ (Hardware) │ │ (Container) │ │ (Business) │
└─────────────┘ └──────────────┘ └────────────┘
│ │
▼ │
┌──────────────┐ │
│ Mosquitto │ ◄──────────────────────┘
│ MQTT Broker │ (Config via API)
└──────────────┘
```
### Komponenten
1. **IoT Bridge** (Separater Docker Container)
- MQTT Client (subscribed auf Device-Topics)
- Session Detection Engine (State Machine)
- Event-Parsing & Normalisierung
- REST Client für Odoo-Kommunikation
- Image: `iot_mqtt_bridge_for_odoo`
- Source: `iot_bridge/` Verzeichnis
2. **Odoo Module** (Business Logic)
- Device-Management UI
- REST API für Bridge (`/ows/iot/config`, `/ows/iot/event`)
- Event-Speicherung & Session-Verwaltung
- Analytics & Reporting
3. **MQTT Broker** (Mosquitto)
- Message-Transport zwischen Hardware und Bridge
## Features
- ✅ **Sidecar-Pattern** - Bridge als separater Container (Docker Best Practice)
- ✅ **Klare Trennung** - Odoo = Business, Bridge = MQTT/Retry/Queue
- ✅ **Auto-Config** - Bridge holt Device-Config von Odoo via REST API
- ✅ **Session Detection** - Dual-Threshold, Debounce, Timeout (in Bridge)
- ✅ **Flexible Parsers** - Shelly PM Mini G3, Tasmota, Generic JSON
- ✅ **Analytics** - Pivot tables and graphs for runtime analysis
- ✅ **Auto-Reconnect** - Exponential backoff on MQTT connection loss
- ✅ **Idempotenz** - Event-UID verhindert Duplikate
## Installation
### 1. Docker Compose Setup
```yaml
services:
odoo:
# ... existing config ...
iot_bridge:
image: iot_mqtt_bridge_for_odoo
build:
context: ./extra-addons/open_workshop/open_workshop_mqtt/iot_bridge
environment:
ODOO_URL: http://odoo:8069
ODOO_TOKEN: ${IOT_BRIDGE_TOKEN}
MQTT_URL: mqtt://mosquitto:1883
depends_on:
- odoo
- mosquitto
restart: unless-stopped
```
### 2. Odoo Module Installation
```bash
docker compose exec odoo odoo -u open_workshop_mqtt
```
### 3. Bridge Token generieren
Odoo UI:
- Settings → Technical → System Parameters
- Create: `ows_iot.bridge_token` = `<generated-token>`
Add to `.env`:
```
IOT_BRIDGE_TOKEN=<same-token>
```
### 4. Start Services
```bash
docker compose up -d
```
## Quick Start
1. **Add Device in Odoo**
- MQTT → Devices → Create
- Device ID: `shellypmminig3-48f6eeb73a1c`
- MQTT Topic: `shaperorigin/status/pm1:0`
- Parser: Shelly PM Mini G3
- Session Strategy: Power Threshold
- Standby: 20W
- Working: 100W
- Debounce: 3s / 15s
2. **Bridge Auto-Config**
- Bridge holt Device-Config via `GET /ows/iot/config`
- Subscribed auf Topic automatisch
- Startet Session Detection
3. **Monitor Sessions**
- MQTT → Sessions
- View runtime analytics in Pivot/Graph views
## Session Detection Strategies
### Power Threshold (Recommended)
- Dual threshold detection (standby/working)
- Configurable debounce timers
- Timeout detection
- Reference: `python_prototype/session_detector.py`
### Last Will Testament
- Uses MQTT LWT for offline detection
- Immediate session end on device disconnect
### Manual
- Start/stop sessions via buttons or MQTT commands
## Configuration Example
**Shelly PM Mini G3:**
```json
{
"standby_threshold_w": 20,
"working_threshold_w": 100,
"start_debounce_s": 3,
"stop_debounce_s": 15,
"message_timeout_s": 20
}
```
## Optional: Maintenance Integration
Install `open_workshop_mqtt_maintenance` (separate module) to link MQTT devices to `maintenance.equipment`.
## Technical Reference
See `python_prototype/` directory for:
- Implementation reference
- Test suite (pytest)
- Session detection logic
- MQTT client implementation
## Support
- Documentation: See `python_prototype/README.md`
- Issues: GitHub Issues
- Tests: `pytest python_prototype/tests/ -v`
## License
LGPL-3

View File

@ -1,336 +0,0 @@
# TODO - Open Workshop MQTT (Stand: 30. Januar 2026)
## Status-Übersicht
### ✅ Phase 1: Python Prototyp (ABGESCHLOSSEN)
- [x] M0: Projekt Setup & MQTT Verbindung
- [x] M1: Shelly PM Mini G3 Integration
- [x] M2: Event-Normalisierung & Unified Schema
- [x] M3: Session Detection Engine
- [x] M4: Multi-Device Support & Config
- [x] M5: Monitoring & Robustheit
- [x] Unit Tests (pytest)
- [x] Integration Tests (mit echtem MQTT Broker)
**Ergebnis:** `python_prototype/` ist vollständig implementiert und getestet
---
### 🔄 Phase 2: ARCHITEKTUR-REDESIGN (IN ARBEIT)
**ALTE Architektur (VERALTET):**
```
Shelly PM → MQTT Broker → Odoo (MQTT Client) → Session Detection → Sessions
```
**Problem:** MQTT Client in Odoo = zu eng gekoppelt, Container-Restart-Issues
**NEUE Architektur (Sidecar-Pattern):**
```
Shelly PM → MQTT Broker ← Bridge Container → REST API → Odoo (Business Logic)
```
✅ **Vorteile:**
- Klare Prozess-Trennung (Odoo = HTTP+Business, Bridge = MQTT+Retry)
- Bridge restart unabhängig von Odoo
- Docker Best Practice (ein Prozess pro Container)
- Einfache Orchestrierung via `docker compose up -d`
#### ✅ M6: Odoo Modul Grundgerüst (FERTIG - 100%)
- [x] Modul `open_workshop_mqtt` erstellt
- [x] Models implementiert:
- [x] `mqtt.connection` - MQTT Broker Verbindungen
- [x] `mqtt.device` - IoT Device Management
- [x] `mqtt.session` - Runtime Sessions
- [x] `mqtt.message` - Message Log (Debug)
- [x] Views erstellt (List, Form, Kanban, Pivot, Graph)
- [x] Security Rules (`ir.model.access.csv`)
- [x] Services:
- [x] `mqtt_client.py` - MQTT Client (mit TLS, Auto-Reconnect)
- [x] `iot_bridge_service.py` - Singleton Service für Connection Management
- [x] **MQTT Client in Odoo** - Subscribed DIREKT auf MQTT Topics ✅
- [x] Parsers:
- [x] `shelly_parser.py` - Shelly PM Mini G3
- [x] Generic Parser Support
**Code-Location:** `/models/`, `/services/`, `/views/`
**Flow:** Shelly → MQTT → Odoo (kein REST API nötig!)
---
#### ~~❌ M7: REST Endpoint & Authentication~~ (GESTRICHEN!)
**Begründung:** Odoo hat bereits MQTT Client → REST API überflüssig!
**NICHT NÖTIG** weil:
- Odoo subscribed DIREKT auf MQTT Topics (via `mqtt_client.py`)
- Keine externe Bridge erforderlich
- `python_prototype/` bleibt als Standalone-Tool für Tests/Entwicklung
---
#### ~~❌ M8: Python-Bridge Integration~~ (GESTRICHEN!)
**Begründung:** Python Prototype bleibt Standalone für Tests. Odoo macht Production!
---
#### ~~❌ M7: Session-Engine in Odoo~~ (GESTRICHEN - wird zu Bridge!)
**Alte Architektur (in Odoo implementiert):**
- ✅ SessionDetector State Machine in `services/session_detector.py`
- ✅ 5-State Machine: IDLE → STARTING → STANDBY/WORKING → STOPPING → IDLE
- ✅ Dual-Threshold Detection + Debounce
- ✅ Alle 7 Unit Tests grün
**NEUE Architektur:**
- 🔄 SessionDetector wird in Bridge Container portiert
- 🔄 Odoo bekommt nur noch fertige Events (run_start/run_stop)
- 🔄 Sessions werden in Odoo via REST API erstellt
**Code wird wiederverwendet:**
- `services/session_detector.py``iot_bridge/session_detector.py` (leicht angepasst)
- State Machine Logic bleibt identisch
- Nur Odoo-Abhängigkeiten (env, cursor) werden entfernt
---
#### 🔧 M7: IoT Bridge Docker Container (IN ARBEIT - 0%)
**Ziel:** Separater Container für MQTT Bridge
**Location:** `iot_bridge/` Unterverzeichnis
**Tasks:**
- [ ] Projekt-Struktur erstellen:
```
iot_bridge/
main.py # Bridge Hauptprogramm
mqtt_client.py # MQTT Client (aus services/ portiert)
session_detector.py # State Machine (aus services/ portiert)
odoo_client.py # REST API Client für Odoo
config.py # Config Management
parsers/
shelly_parser.py
tasmota_parser.py
requirements.txt
Dockerfile
README.md
```
- [ ] Docker Image: `iot_mqtt_bridge_for_odoo`
- [ ] ENV-Variablen:
- `ODOO_URL` (z.B. `http://odoo:8069`)
- `ODOO_TOKEN` (API Token)
- `MQTT_URL` (z.B. `mqtt://mosquitto:1883`)
- [ ] Code-Portierung aus Odoo:
- SessionDetector State Machine
- MQTT Client (ohne Odoo-Abhängigkeiten)
- Shelly Parser
- [ ] Retry-Queue bei Odoo-Ausfall (lokal, in-memory oder SQLite)
- [ ] Config-Refresh alle 5 Min via `GET /ows/iot/config`
**Test:**
- `docker build -t iot_mqtt_bridge_for_odoo iot_bridge/`
- `docker run -e ODOO_URL=... -e ODOO_TOKEN=... iot_mqtt_bridge_for_odoo`
---
#### 🔧 M8: Odoo REST API Endpoints (IN ARBEIT - 0%)
**Ziel:** Odoo bietet REST API für Bridge-Kommunikation
**Tasks:**
- [ ] Controller: `controllers/iot_api.py`
- [ ] `GET /ows/iot/config` - Device-Config für Bridge
- [ ] `POST /ows/iot/event` - Event-Empfang von Bridge
- [ ] Authentifizierung:
- [ ] Token in `ir.config_parameter`: `ows_iot.bridge_token`
- [ ] Bearer Token Validation in Controller
- [ ] Event-Validation:
- [ ] Schema v1 prüfen
- [ ] Unique-Constraint auf `event_uid`
- [ ] 409 Conflict bei Duplikaten
- [ ] Session-Mapping:
- [ ] Events → Session-Updates
- [ ] run_start/run_stop Events
**Test:**
```bash
# Config abrufen
curl -H "Authorization: Bearer <token>" http://localhost:8069/ows/iot/config
# Event senden
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"schema_version":"v1","event_uid":"...","device_id":"..."}' \
http://localhost:8069/ows/iot/event
```
---
#### 🔧 M9: Docker Compose Integration (TODO - 0%)
**Tasks:**
- [ ] `docker-compose.yaml` Update:
```yaml
services:
iot_bridge:
image: iot_mqtt_bridge_for_odoo
build:
context: ./extra-addons/open_workshop/open_workshop_mqtt/iot_bridge
environment:
ODOO_URL: http://odoo:8069
ODOO_TOKEN: ${IOT_BRIDGE_TOKEN}
MQTT_URL: mqtt://mosquitto:1883
depends_on:
- odoo
- mosquitto
restart: unless-stopped
```
- [ ] `.env` Template mit `IOT_BRIDGE_TOKEN`
- [ ] `DEPLOYMENT.md` - Setup-Anleitung
**Test:**
- `docker compose up -d` startet Odoo + Bridge
- Bridge logs zeigen MQTT connection
- Events erscheinen in Odoo UI
- Standby/Working Thresholds ✅
- Debounce (Start: 3s, Stop: 15s) ✅
- 5-State Machine (IDLE → STARTING → STANDBY/WORKING → STOPPING → IDLE) ✅
- Timeout Detection (20s) ✅
- Duration Tracking (standby_duration_s, working_duration_s) ✅
- State Recovery nach Restart ✅
5. **Views & UI** - Forms, Lists, Pivot Tables, Graphs ✅
6. **ENV-Passing Architecture** - Keine Cursor-Fehler mehr! ✅
### ❌ TESTS BROKEN (Code IST NICHT FERTIG!)
1. **8 Unit Tests schlagen fehl** - Tests verwenden alte Signaturen
- Änderung nötig: `SessionDetector(device)``SessionDetector(device.id, device.name)`
- Änderung nötig: `detector.process_power_event(power, ts)``detector.process_power_event(env, power, ts)`
- Alle Assertions müssen mit `env` arbeiten
- Aufwand: **~1-2 Stunden** (nicht 30 Min - Tests müssen auch laufen!)
2. **Keine grünen Tests = NICHT FERTIG!ctor(device)` → `SessionDetector(device.id, device.name)`
- Änderung: `detector.process_power_event(power, ts)``detector.process_power_event(env, power, ts)`
- Aufwand: **~30 Minuten**
### ~~NICHT MEHR NÖTIG~~
- ~~REST API Endpoint~~ - Odoo macht MQTT direkt ✅
- ~~Event Storage Model~~ - `mqtt.message` reicht für Debug ✅
- ~~Python Bridge~~ - Odoo ist die Bridge ✅
---
## Phase 3: Optional Features (Noch nicht gestartet)
### 🟡 MEDIUM PRIORITY (Optional M9)
5. **POS Integration** (später)
- Realtime Display
- WebSocket Feed
- Live Power Consumption
6. **Maintenance Integration**
- Link zu `maintenance.equipment`
- Usage Tracking
- Separate Module `open_workshop_mqtt_maintenance`
### 🟢 LOW PRIORITY (Nice-to-have)
7. **Performance & Scale**
- Last-Tests (20 Devices, 1h)
- Queue Optimization
- Database Indexes
8. **Security Hardening**
- IP Allowlist
- Rate Limiting (Reverse Proxy)
- Token Rotation
---
## Bekannte Probleme
### 🐛 BUGS
1. **Tests hängen mit echtem MQTT Broker**
- Location: `tests/test_mqtt_connection.py`, etc.
- Reason: TransactionCase + Background Threads inkompatibel
- Solution: Mock-basierte Tests (erstellt, aber nicht aktiv)
- Status: WORKAROUND erstellt, muss aktiviert werden
2. **Manual Session Start/Stop TODO**
- Location: `models/mqtt_device.py:327, 349`
- Methods: `action_start_session()`, `action_stop_session()`
- Status: Placeholder, nicht implementiert
3. **Topic Matching ohne Wildcards**
- Location: `services/iot_bridge_service.py:498`
- TODO Comment: Implement proper topic matching (+, #)
- Status: Funktioniert nur mit exakten Topics
### ⚠️ TECHNICAL DEBT
1. **Session Detection zu simpel**
- Problem: `power > 0 = start` ist zu vereinfacht
- Impact: Flackernde Sessions bei Power-Spikes
- Solution: Session-Engine aus Python portieren (M7) ← **DAS IST ALLES**
- [ ] **M7**: REST Endpoint `/ows/iot/event` funktioniert
- [ ] Auth Tests: ✅ Alle Tests grün
- [ ] Manual Test: `curl -H "Authorization: Bearer xxx" -X POST ...` → 200 OK
- [ ] **M7**: Events werden in `mqtt.event` persistiert
- [ ] Test: Event in DB sichtbar nach POST
- [ ] **M8**: Python Bridge sendet Events an Odoo
- [ ] Integration Test: ✅ Event kommt in Odoo an
- [ ] Manual Test: Bridge läuft, Events in Odoo UI sichtbar
- [ ] **M9**: Session-Detection läuft in Odoo (Dual-Threshold!)
- [ ] Unit Tests: ✅ State Machine korrekt
- [ ] Integration Test: ✅ Shelly → Session mit Debounce
- [ ] Manual Test: Maschine an/aus → Session startet/stoppt korrekt
- [ ] **End-to-End**: Shelly → MQTT → Bridge → Odoo → Session sichtbar
- [ ] Test: Kompletter Flow funktioniert
- [ ] **Dokumentation**: API, Setup, Troubleshooting komplett
**Test-Driven Development:**
- Jeder Milestone hat Tests BEVOR Code geschrieben wird
- Keine manuelle UI-Klickerei zum Testen
- `run-tests.sh` läuft jederzeit durch
**Geschätzter Aufwand:** 4-6 Tage (bei fokussierter TDD-rt
- [ ] Python Bridge sendet Events an Odoo (statt nur File)
- [ ] Events werden in `mqtt.event` persistiert
- [ ] Session-Detection läuft in Odoo (nicht nur Python)
- [ ] Sessions werden automatisch bei Events aktualisiert
- [ ] Tests laufen durch (Mock-basiert)
- [ ] End-to-End Test: Shelly → MQTT → Bridge → Odoo → Session sichtbar
- [ ] Dokumentation komplett (API, Setup, Troubleshooting)
**Geschätzter Aufwand:** 4-6 Tage (bei fokussierter Arbeit)
---
## Changelog
**2026-01-30 16:40 Uhr (Phase 2 FERTIG!):**
- Phase 1: ✅ Komplett (Python Prototype)
- Phase 2: ✅ **100% FERTIG!** - Code + Tests komplett!
- SessionDetector vollständig portiert + env-passing refactoring
- Live-Tests erfolgreich: State Machine funktioniert mit echten MQTT Messages!
- **Alle 26 Unit Tests grün!** (0 failed, 0 errors)
- Test-Fixes:
- test_session_detector.py: Alle 7 Tests an env-passing angepasst
- test_mqtt_mocked.py: Alle 4 Service-Tests repariert
- run-tests.sh: Verbesserte Ausgabe mit klarer Zusammenfassung
- **SessionDetector ist produktionsreif!**
**2026-01-29 (Env-Passing Refactoring):** Odoo Cursor Management gelöst
- Komplettes Refactoring: SessionDetector speichert nur IDs
- 20+ Method Signatures geändert (env-passing)
- Keine "Cursor already closed" Fehler mehr
- Live getestet mit echten MQTT Messages
**2026-01-28 (Update 2):** Architektur vereinfacht - Odoo macht ALLES direkt
- Phase 1: ✅ Komplett (Python Prototype)
- Phase 2: 🚧 70% (Odoo Integration)
- M7+M8 (REST/Bridge) GESTRICHEN → 2 Tage gespart!
- Nur noch: M7 (Session-Engine portieren) + M8 (Tests reparieren)
- Geschätzt: **2-3 Tage** statt 4-6!

View File

@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@ -1,49 +0,0 @@
# -*- coding: utf-8 -*-
{
'name': 'Open Workshop MQTT',
'version': '18.0.1.0.0',
'category': 'IoT',
'summary': 'MQTT IoT Device Integration for Workshop Equipment Tracking',
'description': """
Open Workshop MQTT Module
=========================
Connect MQTT IoT devices (Shelly, Tasmota, etc.) to Odoo and track:
- Device runtime sessions
- Power consumption
- Machine usage analytics
Features:
- Flexible device parsers (Shelly PM Mini G3, Tasmota, Generic)
- Multiple session detection strategies (Power threshold, Last Will, Manual)
- Auto-reconnect with exponential backoff
- State recovery after restart
- Real-time device monitoring
Optional Integration:
- Connect devices to maintenance.equipment (via open_workshop_mqtt_maintenance)
""",
'author': 'Open Workshop',
'website': 'https://github.com/your-repo/open_workshop',
'license': 'LGPL-3',
'depends': [
'base',
'web', # Needed for HTTP controllers
],
'external_dependencies': {
'python': [
'paho-mqtt', # pip install paho-mqtt
],
},
'data': [
'security/ir.model.access.csv',
'views/mqtt_device_views.xml',
'views/mqtt_session_views.xml',
'views/iot_event_views.xml',
'views/mqtt_menus.xml',
],
'demo': [],
'installable': True,
'application': True,
'auto_install': False,
}

View File

@ -1,34 +0,0 @@
#!/usr/bin/env python3
import sys
import odoo
from odoo import api, SUPERUSER_ID
odoo.tools.config.parse_config(['--database=OWS_MQTT'])
with api.Environment.manage():
with odoo.registry('OWS_MQTT').cursor() as cr:
env = api.Environment(cr, SUPERUSER_ID, {})
# Get all routes
from odoo.http import root
print("All registered routes containing 'iot' or 'ows':")
count = 0
for rule in root.get_db_router('OWS_MQTT').url_map.iter_rules():
path = str(rule.rule)
if 'iot' in path.lower() or 'ows' in path.lower():
print(f" {rule.methods} {path}")
count += 1
print(f"\nTotal: {count} routes found")
if count == 0:
print("\n❌ No IoT routes found - controller not loaded!")
print("\nChecking if controllers module can be imported:")
try:
from odoo.addons.open_workshop_mqtt import controllers
print("✅ controllers module imported successfully")
from odoo.addons.open_workshop_mqtt.controllers import iot_api
print("✅ iot_api module imported successfully")
print(f"✅ Controller class: {iot_api.IotApiController}")
except Exception as e:
print(f"❌ Import failed: {e}")

View File

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

View File

@ -1,312 +0,0 @@
# -*- coding: utf-8 -*-
"""
IoT Bridge REST API Controller
Provides endpoints for IoT Bridge to fetch config and send events
"""
from odoo import http, _
from odoo.http import request, Response, route
import json
import logging
from datetime import datetime
_logger = logging.getLogger(__name__)
class IotApiController(http.Controller):
"""REST API for IoT Bridge integration"""
# ========== GET /ows/iot/config ==========
@route('/ows/iot/config', type='http', auth='none', methods=['GET'], csrf=False, save_session=False)
def get_iot_config(self, **kw):
"""
Get IoT device configuration for Bridge
Returns JSON with all active devices and their session configs
Example Response:
{
"devices": [
{
"device_id": "shellypmminig3-48f6eeb73a1c",
"mqtt_topic": "shaperorigin/status/pm1:0",
"parser_type": "shelly_pm_mini_g3",
"machine_name": "Shaper Origin",
"session_config": {
"strategy": "power_threshold",
"standby_threshold_w": 20,
"working_threshold_w": 100,
"start_debounce_s": 3,
"stop_debounce_s": 15,
"message_timeout_s": 20,
"heartbeat_interval_s": 300
}
}
]
}
"""
try:
# Get database name from query parameter or use monodb
db_name = kw.get('db') or request.db or http.db_monodb()
if not db_name:
return Response(
json.dumps({'status': 'error', 'error': 'No database specified. Use ?db=DATABASE_NAME'}),
content_type='application/json', status=400
)
# Ensure we have environment with correct database
http.db_monodb = lambda force=False: db_name # Temporary override for this request
# Get all active MQTT devices
devices = request.env['mqtt.device'].sudo().search([
('active', '=', True)
])
config_devices = []
for device in devices:
# Parse strategy_config JSON
session_config = {}
if device.strategy_config:
try:
session_config = json.loads(device.strategy_config)
except json.JSONDecodeError:
_logger.warning(f"Invalid JSON in device {device.id} strategy_config")
# Add strategy type
session_config['strategy'] = device.session_strategy or 'power_threshold'
# Build device config
device_config = {
'device_id': device.device_id,
'mqtt_topic': device.topic_pattern.replace('/#', '/status/pm1:0'), # Adjust topic
'parser_type': device.parser_type.replace('_', '_'), # e.g., shelly_pm → shelly_pm_mini_g3
'machine_name': device.name,
'session_config': session_config
}
config_devices.append(device_config)
response_data = {
'status': 'success',
'devices': config_devices,
'timestamp': datetime.utcnow().isoformat() + 'Z'
}
return Response(
json.dumps(response_data, indent=2),
content_type='application/json',
status=200
)
except Exception as e:
_logger.exception("Error in get_iot_config")
error_response = {
'status': 'error',
'error': str(e),
'timestamp': datetime.utcnow().isoformat() + 'Z'
}
return Response(
json.dumps(error_response, indent=2),
content_type='application/json',
status=500
)
# ========== POST /ows/iot/event ==========
@route('/ows/iot/event', type='json', auth='none', methods=['POST'], csrf=False, save_session=False)
def receive_iot_event(self, event_uid=None, event_type=None, device_id=None,
session_id=None, timestamp=None, payload=None, **kw):
"""
Receive IoT event from Bridge
Expected JSON-RPC params:
{
"event_uid": "550e8400-e29b-41d4-a716-446655440000",
"event_type": "session_started",
"device_id": "shellypmminig3-48f6eeb73a1c",
"session_id": "a1b2c3d4",
"timestamp": "2026-02-05T15:09:48.544202Z",
"payload": {
"power_w": 47.9,
"state": "starting",
...
}
}
Returns:
- 201 Created: Event successfully created
- 409 Conflict: Event UID already exists (duplicate)
- 400 Bad Request: Invalid payload
- 500 Internal Server Error: Processing failed
"""
try:
# Validate required fields
if not all([event_uid, event_type, device_id, timestamp]):
missing = [f for f, v in [('event_uid', event_uid), ('event_type', event_type),
('device_id', device_id), ('timestamp', timestamp)] if not v]
return {
'status': 'error',
'error': f"Missing required fields: {', '.join(missing)}",
'code': 400
}
# Check for duplicate event_uid
existing_event = request.env['ows.iot.event'].sudo().search([
('event_uid', '=', event_uid)
], limit=1)
if existing_event:
_logger.info(f"Duplicate event UID: {event_uid} - returning 409")
return {
'status': 'duplicate',
'message': 'Event UID already exists',
'event_uid': event_uid,
'code': 409
}
# Create event record
# Parse ISO timestamp to Odoo datetime format
from dateutil import parser as date_parser
try:
dt = date_parser.isoparse(timestamp)
odoo_timestamp = dt.strftime('%Y-%m-%d %H:%M:%S')
except Exception as e:
return {
'status': 'error',
'error': f"Invalid timestamp format: {str(e)}",
'code': 400
}
event_vals = {
'event_uid': event_uid,
'event_type': event_type,
'device_id': device_id,
'session_id': session_id,
'timestamp': odoo_timestamp,
'payload_json': json.dumps(payload or {}, indent=2),
}
event = request.env['ows.iot.event'].sudo().create(event_vals)
_logger.info(f"Created IoT event: {event.event_uid} type={event.event_type} device={event.device_id}")
# Process event (update/create session)
self._process_event(event)
return {
'status': 'success',
'message': 'Event received and processed',
'event_uid': event.event_uid,
'event_id': event.id,
'code': 201
}
except Exception as e:
_logger.exception("Error in receive_iot_event")
return {
'status': 'error',
'error': str(e),
'code': 500
}
def _process_event(self, event):
"""
Process IoT event: create or update mqtt.session
Args:
event: ows.iot.event record
"""
try:
payload = event.get_payload_dict()
# Find or create mqtt.session
session = None
if event.session_id:
session = request.env['mqtt.session'].sudo().search([
('session_id', '=', event.session_id)
], limit=1)
# Handle different event types
if event.event_type == 'session_started':
if not session:
# Create new session
device = request.env['mqtt.device'].sudo().search([
('device_id', '=', event.device_id)
], limit=1)
if device:
session = request.env['mqtt.session'].sudo().create({
'session_id': event.session_id,
'device_id': device.id,
'start_time': event.timestamp,
'start_power_w': payload.get('power_w', 0.0),
'status': 'running',
'current_state': payload.get('state', 'idle'),
'last_message_time': event.timestamp,
})
_logger.info(f"Created session: {session.session_id} for device {device.name}")
else:
_logger.warning(f"Device not found for event: {event.device_id}")
elif event.event_type in ['session_updated', 'session_heartbeat'] and session:
# Update existing session
update_vals = {
'last_message_time': event.timestamp,
}
# Update power (different field names for different event types)
if 'avg_power_w' in payload:
update_vals['current_power_w'] = payload['avg_power_w']
elif 'power_w' in payload:
update_vals['current_power_w'] = payload['power_w']
# Update durations (heartbeat uses total_working_s/total_standby_s)
if 'total_working_s' in payload:
update_vals['working_duration_s'] = int(payload['total_working_s'])
if 'total_standby_s' in payload:
update_vals['standby_duration_s'] = int(payload['total_standby_s'])
# Calculate total duration from working + standby
if 'total_working_s' in payload and 'total_standby_s' in payload:
total = int(payload['total_working_s']) + int(payload['total_standby_s'])
update_vals['total_duration_s'] = total
# Fallback to explicit duration fields
if 'total_duration_s' in payload:
update_vals['total_duration_s'] = int(payload['total_duration_s'])
if 'standby_duration_s' in payload:
update_vals['standby_duration_s'] = int(payload['standby_duration_s'])
if 'working_duration_s' in payload:
update_vals['working_duration_s'] = int(payload['working_duration_s'])
# Update state
if 'current_state' in payload:
update_vals['current_state'] = payload['current_state']
session.write(update_vals)
_logger.debug(f"Updated session: {session.session_id}")
elif event.event_type in ['session_stopped', 'session_timeout'] and session:
# End session
end_vals = {
'end_time': event.timestamp,
'end_power_w': payload.get('power_w', 0.0),
'status': 'completed',
'end_reason': 'timeout' if event.event_type == 'session_timeout' else 'power_drop',
}
# Final duration updates
if 'total_duration_s' in payload:
end_vals['total_duration_s'] = int(payload['total_duration_s'])
if 'standby_duration_s' in payload:
end_vals['standby_duration_s'] = int(payload['standby_duration_s'])
if 'working_duration_s' in payload:
end_vals['working_duration_s'] = int(payload['working_duration_s'])
session.write(end_vals)
_logger.info(f"Ended session: {session.session_id} reason={end_vals['end_reason']}")
# Mark event as processed
event.mark_processed()
except Exception as e:
_logger.exception(f"Error processing event {event.event_uid}")
event.mark_processed(error=str(e))

View File

@ -1,61 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
env/
ENV/
# Testing
.pytest_cache/
.coverage
htmlcov/
tests/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
logs/
*.log
# Data
data/
# Config (will be mounted as volume)
config.yaml
# Git
.git/
.gitignore
# Documentation
README.md
*.md
docs/
# Development files
docker-compose.dev.yaml

View File

@ -1,48 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Testing
.pytest_cache/
.coverage
htmlcov/
*.log
# Local config
.env
.env.local
config.local.yml
# SQLite (if using for retry queue)
*.db
*.sqlite3

View File

@ -1,57 +0,0 @@
# Multi-stage build for IoT MQTT Bridge
# Stage 1: Builder - Install dependencies
FROM python:3.11-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc && \
rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# Stage 2: Runtime - Minimal image
FROM python:3.11-slim
LABEL maintainer="Open Workshop MQTT IoT Bridge"
LABEL description="MQTT Bridge for Odoo IoT Device Integration with Session Detection"
LABEL version="1.0.0"
WORKDIR /app
# Copy installed packages from builder to site-packages (not user directory)
COPY --from=builder /root/.local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
# Copy application code
COPY *.py ./
COPY config.yaml.example ./
# Create non-root user for security
RUN useradd -m -u 1000 bridge && \
chown -R bridge:bridge /app && \
mkdir -p /app/logs /app/data && \
chown -R bridge:bridge /app/logs /app/data
# Switch to non-root user
USER bridge
# Environment variables (can be overridden at runtime)
ENV BRIDGE_CONFIG=/app/config.yaml \
PYTHONUNBUFFERED=1 \
LOG_LEVEL=INFO
# Volume for config and logs
VOLUME ["/app/config.yaml", "/app/logs", "/app/data"]
# Expose optional health check port (if implemented later)
# EXPOSE 8080
# Health check (optional - requires health endpoint implementation)
# HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
# CMD python -c "import socket; s=socket.socket(); s.connect(('localhost',8080)); s.close()" || exit 1
# Run bridge with unbuffered output
CMD ["python", "-u", "main.py"]

View File

@ -1,402 +0,0 @@
# IoT MQTT Bridge for Odoo
**Separater Docker Container für MQTT-IoT-Device-Integration**
## Architektur-Übersicht
```
┌─────────────┐ MQTT ┌──────────────┐ REST API ┌────────────┐
│ Shelly PM │ ────────────────► │ IoT Bridge │ ──────────────► │ Odoo 18 │
│ (Hardware) │ │ (THIS!) │ │ (Business) │
└─────────────┘ └──────────────┘ └────────────┘
│ │
▼ │
┌──────────────┐ │
│ Mosquitto │ ◄──────────────────────┘
│ MQTT Broker │ (Config via API)
└──────────────┘
```
## Zweck
Die IoT Bridge ist ein **eigenständiger Python-Service** der:
1. **MQTT-Verbindung verwaltet**
- Subscribed auf Device-Topics (z.B. Shelly PM Mini G3)
- Auto-Reconnect bei Verbindungsabbruch
- Exponential Backoff
2. **Event-Normalisierung durchführt**
- Parser für verschiedene Device-Typen (Shelly, Tasmota, Generic)
- Konvertiert zu Unified Event Schema v1
- Generiert eindeutige Event-UIDs
3. **Session Detection** (State Machine)
- Dual-Threshold Detection (Standby/Working)
- Debounce Timer (Start/Stop)
- Timeout Detection
- 5-State Machine: IDLE → STARTING → STANDBY/WORKING → STOPPING
4. **Odoo-Kommunikation** (REST API)
- Holt Device-Config: `GET /ows/iot/config`
- Sendet Events: `POST /ows/iot/event`
- Bearer Token Authentication
- Retry-Queue bei Odoo-Ausfall
## Projekt-Struktur
```
iot_bridge/
├── main.py # Haupt-Entry-Point
├── mqtt_client.py # MQTT Client (paho-mqtt)
├── session_detector.py # State Machine für Session Detection
├── odoo_client.py # REST API Client für Odoo
├── config.py # Config Management (ENV + Odoo)
├── parsers/
│ ├── __init__.py
│ ├── base_parser.py # Abstract Parser Interface
│ ├── shelly_parser.py # Shelly PM Mini G3
│ ├── tasmota_parser.py # Tasmota (optional)
│ └── generic_parser.py # Generic JSON
├── requirements.txt # Python Dependencies
├── Dockerfile # Multi-stage Build
└── README.md # Dieses Dokument
```
## Konfiguration
### ENV-Variablen
Die Bridge wird ausschließlich über Umgebungsvariablen konfiguriert:
| Variable | Pflicht | Default | Beschreibung |
|----------|---------|---------|--------------|
| `ODOO_URL` | ✅ | - | Odoo Base-URL (z.B. `http://odoo:8069`) |
| `ODOO_TOKEN` | ✅ | - | API Token für Authentifizierung |
| `MQTT_URL` | ✅ | - | MQTT Broker URL (z.B. `mqtt://mosquitto:1883`) |
| `MQTT_USERNAME` | ❌ | `None` | MQTT Username (optional) |
| `MQTT_PASSWORD` | ❌ | `None` | MQTT Password (optional) |
| `LOG_LEVEL` | ❌ | `INFO` | Logging Level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
| `CONFIG_REFRESH_INTERVAL` | ❌ | `300` | Config-Refresh in Sekunden (5 Min) |
### Odoo-Konfiguration
Die Bridge holt Device-spezifische Konfiguration von Odoo via:
**Request:** `GET /ows/iot/config`
**Response:**
```json
{
"devices": [
{
"device_id": "shellypmminig3-48f6eeb73a1c",
"mqtt_topic": "shaperorigin/status/pm1:0",
"parser_type": "shelly_pm_mini_g3",
"machine_name": "Shaper Origin",
"session_config": {
"strategy": "power_threshold",
"standby_threshold_w": 20,
"working_threshold_w": 100,
"start_debounce_s": 3,
"stop_debounce_s": 15,
"message_timeout_s": 20
}
}
]
}
```
## Event-Flow
### 1. MQTT Message empfangen
```python
# Topic: shaperorigin/status/pm1:0
# Payload (Shelly Format):
{
"id": 0,
"voltage": 234.7,
"current": 0.289,
"apower": 120.5, # ← Power-Wert!
"aenergy": { "total": 256.325 }
}
```
### 2. Parser normalisiert zu Event Schema v1
```python
{
"schema_version": "v1",
"event_uid": "b6d0a2c5-9b1f-4a0b-8b19-7f2e1b8f3d11",
"ts": "2026-01-31T10:30:15.123Z",
"device_id": "shellypmminig3-48f6eeb73a1c",
"event_type": "power_measurement",
"payload": {
"apower": 120.5,
"voltage": 234.7,
"current": 0.289,
"total_energy_kwh": 0.256325
}
}
```
### 3. Session Detector analysiert
```
State Machine:
IDLE (Power < 20W)
↓ Power > 100W
STARTING (Debounce 3s)
↓ 3s vergangen, Power > 100W
WORKING (Session läuft)
↓ Power < 100W
STOPPING (Debounce 15s)
↓ 15s vergangen, Power < 20W
IDLE (Session beendet)
```
### 4. Events an Odoo senden
**run_start Event:**
```python
POST /ows/iot/event
Authorization: Bearer <token>
{
"schema_version": "v1",
"event_uid": "...",
"event_type": "run_start",
"device_id": "shellypmminig3-48f6eeb73a1c",
"ts": "2026-01-31T10:30:18.500Z",
"payload": {
"power_w": 120.5,
"reason": "power_threshold"
}
}
```
**run_stop Event:**
```python
POST /ows/iot/event
{
"event_type": "run_stop",
"payload": {
"power_w": 8.2,
"reason": "normal",
"duration_s": 187.3
}
}
```
## Docker Integration
### Dockerfile
```dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Run as non-root
RUN useradd -m -u 1000 bridge && chown -R bridge:bridge /app
USER bridge
CMD ["python", "-u", "main.py"]
```
### docker-compose.yaml
```yaml
services:
iot_bridge:
image: iot_mqtt_bridge_for_odoo
build:
context: ./extra-addons/open_workshop/open_workshop_mqtt/iot_bridge
environment:
ODOO_URL: http://odoo:8069
ODOO_TOKEN: ${IOT_BRIDGE_TOKEN}
MQTT_URL: mqtt://mosquitto:1883
LOG_LEVEL: INFO
depends_on:
- odoo
- mosquitto
networks:
- odoo_network
restart: unless-stopped
```
## Development
### Lokales Testen
```bash
# 1. Python Dependencies installieren
cd iot_bridge/
python -m venv venv
source venv/bin/activate # Linux/Mac
pip install -r requirements.txt
# 2. ENV-Variablen setzen
export ODOO_URL=http://localhost:8069
export ODOO_TOKEN=your-token-here
export MQTT_URL=mqtt://localhost:1883
export LOG_LEVEL=DEBUG
# 3. Bridge starten
python main.py
```
### Docker Build & Run
```bash
# Build
docker build -t iot_mqtt_bridge_for_odoo .
# Run
docker run --rm \
-e ODOO_URL=http://odoo:8069 \
-e ODOO_TOKEN=your-token \
-e MQTT_URL=mqtt://mosquitto:1883 \
iot_mqtt_bridge_for_odoo
```
## Monitoring
### Logs
```bash
# Docker Container Logs
docker compose logs -f iot_bridge
# Wichtige Log-Events:
# - [INFO] Bridge started, connecting to MQTT...
# - [INFO] MQTT connected, subscribing to topics...
# - [INFO] Config loaded: 3 devices
# - [DEBUG] Message received: topic=..., power=120.5W
# - [INFO] Session started: device=..., session_id=...
# - [WARNING] Odoo API error, retrying in 5s...
```
### Health Check
Die Bridge sollte einen Health-Check-Endpoint anbieten:
```python
# GET http://bridge:8080/health
{
"status": "ok",
"mqtt_connected": true,
"odoo_reachable": true,
"devices_configured": 3,
"active_sessions": 1,
"uptime_seconds": 3600
}
```
## Fehlerbehandlung
### MQTT Disconnect
- Auto-Reconnect mit Exponential Backoff
- Topics werden nach Reconnect neu subscribed
- Laufende Sessions werden **nicht** beendet
### Odoo Unreachable
- Events werden in lokaler Queue gespeichert (in-memory oder SQLite)
- Retry alle 5 Sekunden
- Max. 1000 Events in Queue (älteste werden verworfen)
### Config-Reload
- Alle 5 Minuten: `GET /ows/iot/config`
- Neue Devices → subscribe Topics
- Gelöschte Devices → unsubscribe Topics
- Geänderte Schwellenwerte → SessionDetector aktualisieren
## Testing
### Manuelle Tests
```bash
# Shelly Simulator für Tests
python tests/tools/shelly_simulator.py --scenario session_end
python tests/tools/shelly_simulator.py --scenario full_session
python tests/tools/shelly_simulator.py --scenario timeout
python3 tests/tools/shelly_simulator.py --broker localhost --port 1883 --no-tls --username "" --password "" --scenario full_session
### Unit Tests
```bash
pytest tests/test_session_detector.py -v
pytest tests/test_parsers.py -v
pytest tests/test_odoo_client.py -v
```
### Integration Tests
```bash
# Requires: Running MQTT Broker + Odoo instance
pytest tests/integration/ -v
```
## Production Deployment
### Best Practices
1. **Token Security**
- Token in `.env` (nicht in Git)
- Regelmäßige Token-Rotation
- Separate Tokens pro Umgebung (dev/staging/prod)
2. **Logging**
- `LOG_LEVEL=INFO` in Production
- `LOG_LEVEL=DEBUG` nur für Troubleshooting
- Log-Aggregation (z.B. via Docker Logging Driver)
3. **Monitoring**
- Health-Check in Docker Compose
- Alerts bei Container-Restart
- Metrics: Events/s, Queue-Größe, Odoo-Latenz
4. **Scaling**
- Eine Bridge-Instanz pro MQTT-Broker
- Mehrere Broker → mehrere Bridge-Container
- Shared Subscriptions (MQTT 5.0) für Load-Balancing
## Roadmap
### Phase 1 (MVP)
- [x] Architektur-Design
- [ ] MQTT Client Implementation
- [ ] Shelly Parser
- [ ] Session Detector (aus Odoo portiert)
- [ ] Odoo REST Client
- [ ] Dockerfile
### Phase 2 (Features)
- [ ] Retry-Queue (SQLite)
- [ ] Health-Check-Endpoint
- [ ] Tasmota Parser
- [ ] Generic JSON Parser
- [ ] Config-Hot-Reload
### Phase 3 (Production)
- [ ] Integration Tests
- [ ] Docker Compose Example
- [ ] Deployment Guide
- [ ] Monitoring Dashboard
- [ ] Performance Tuning
## License
LGPL-3 (same as Odoo)

View File

@ -1,105 +0,0 @@
"""Configuration loader for IoT Bridge."""
import yaml
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
@dataclass
class MQTTConfig:
broker: str
port: int
username: Optional[str] = None
password: Optional[str] = None
client_id: str = "iot_bridge"
keepalive: int = 60
use_tls: bool = False
@dataclass
class OdooConfig:
base_url: Optional[str] = None
database: Optional[str] = None
username: Optional[str] = None
api_key: Optional[str] = None
use_mock: bool = True
mock_failure_rate: float = 0.0 # For testing retry logic (0.0-1.0)
@dataclass
class LoggingConfig:
level: str = "INFO"
format: str = "json"
log_file: Optional[str] = None
@dataclass
class EventQueueConfig:
max_retries: int = 3
initial_retry_delay_s: float = 2.0
max_retry_delay_s: float = 60.0
retry_backoff_factor: float = 2.0
@dataclass
class SessionConfig:
strategy: str
standby_threshold_w: float
working_threshold_w: float
start_debounce_s: float
stop_debounce_s: float
message_timeout_s: float
heartbeat_interval_s: float
@dataclass
class DeviceConfig:
device_id: str
mqtt_topic: str
parser_type: str
machine_name: str
session_config: SessionConfig
@dataclass
class BridgeConfig:
mqtt: MQTTConfig
odoo: OdooConfig
logging: LoggingConfig
event_queue: EventQueueConfig
devices: List[DeviceConfig]
def load_config(config_path: str = "config.yaml") -> BridgeConfig:
"""Load configuration from YAML file."""
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
with open(path, 'r') as f:
data = yaml.safe_load(f)
mqtt_config = MQTTConfig(**data['mqtt'])
odoo_config = OdooConfig(**data.get('odoo', {}))
logging_config = LoggingConfig(**data.get('logging', {}))
event_queue_config = EventQueueConfig(**data.get('event_queue', {}))
devices = []
for dev_data in data.get('devices', []):
session_cfg = SessionConfig(**dev_data['session_config'])
device = DeviceConfig(
device_id=dev_data['device_id'],
mqtt_topic=dev_data['mqtt_topic'],
parser_type=dev_data['parser_type'],
machine_name=dev_data['machine_name'],
session_config=session_cfg
)
devices.append(device)
return BridgeConfig(
mqtt=mqtt_config,
odoo=odoo_config,
logging=logging_config,
event_queue=event_queue_config,
devices=devices
)

View File

@ -1,38 +0,0 @@
# IoT Bridge Configuration
# This file is used when running the bridge standalone (without Odoo API)
# WARNING: Contains credentials - DO NOT commit to git!
mqtt:
broker: "mqtt.majufilo.eu"
port: 8883
username: "mosquitto"
password: "jer7Pehr"
client_id: "iot_bridge_standalone"
keepalive: 60
use_tls: true
odoo:
# Odoo REST API configuration for development
base_url: "http://odoo-dev:8069"
database: "OWS_MQTT"
username: "admin"
api_key: "" # Optional: Add API key for authentication
use_mock: false
logging:
level: "INFO" # DEBUG, INFO, WARNING, ERROR
format: "json" # json or text
devices:
- device_id: "shellypmminig3-48f6eeb73a1c"
mqtt_topic: "shaperorigin/status/pm1:0"
parser_type: "shelly_pm_mini_g3"
machine_name: "Shaper Origin"
session_config:
strategy: "power_threshold"
standby_threshold_w: 20
working_threshold_w: 100
start_debounce_s: 3
stop_debounce_s: 15
message_timeout_s: 20
heartbeat_interval_s: 30 # 30 seconds for testing (300 = 5 minutes in production)

View File

@ -1,57 +0,0 @@
# IoT Bridge Development Configuration
# For use with docker-compose.dev.yaml and local Mosquitto
mqtt:
broker: "mosquitto" # Docker service name from docker-compose
port: 1883 # Unencrypted for local development
username: "" # Leave empty if Mosquitto allows anonymous
password: ""
client_id: "iot_bridge_dev"
keepalive: 60
use_tls: false # No TLS for local testing
odoo:
# Local Odoo instance from docker-compose
base_url: "http://odoo-dev:8069"
database: "OWS_MQTT"
username: "admin"
api_key: "" # Add your API key here when ready for Phase 2
use_mock: false # Using real Odoo REST API
logging:
level: "DEBUG" # More verbose for development
format: "json"
# Event Queue Configuration
event_queue:
max_retries: 3
initial_retry_delay_s: 2
max_retry_delay_s: 60
retry_backoff_factor: 2.0
devices:
- device_id: "shellypmminig3-48f6eeb73a1c"
mqtt_topic: "shaperorigin/status/pm1:0"
parser_type: "shelly_pm_mini_g3"
machine_name: "Shaper Origin"
session_config:
strategy: "power_threshold"
standby_threshold_w: 20
working_threshold_w: 100
start_debounce_s: 3
stop_debounce_s: 15
message_timeout_s: 20
heartbeat_interval_s: 300 # 5 minutes
- device_id: "testshelly-simulator"
mqtt_topic: "testshelly/status/pm1:0"
parser_type: "shelly_pm_mini_g3"
machine_name: "Test Shelly Simulator"
session_config:
strategy: "power_threshold"
standby_threshold_w: 20
working_threshold_w: 100
start_debounce_s: 3
stop_debounce_s: 15
message_timeout_s: 20
heartbeat_interval_s: 30 # 30 seconds for faster testing

View File

@ -1,35 +0,0 @@
# IoT Bridge Configuration Example
# Copy this file to config.yaml and fill in your values
mqtt:
broker: "your-mqtt-broker.com"
port: 8883 # 1883 for unencrypted, 8883 for TLS
username: "your_username"
password: "your_password"
client_id: "iot_bridge_standalone"
keepalive: 60
use_tls: true # Set to true for port 8883
odoo:
# URL for Odoo API (when not using mock)
# url: "http://localhost:8069"
# token: ""
use_mock: true
logging:
level: "INFO" # DEBUG, INFO, WARNING, ERROR
format: "json" # json or text
devices:
- device_id: "shellypmminig3-48f6eeb73a1c"
mqtt_topic: "shaperorigin/status/pm1:0"
parser_type: "shelly_pm_mini_g3"
machine_name: "Shaper Origin"
session_config:
strategy: "power_threshold"
standby_threshold_w: 20
working_threshold_w: 100
start_debounce_s: 3
stop_debounce_s: 15
message_timeout_s: 20
heartbeat_interval_s: 300 # 5 minutes

View File

@ -1,197 +0,0 @@
"""
Event Queue with Retry Logic for IoT Bridge
Handles queuing and retry of events sent to Odoo with exponential backoff.
"""
import uuid
import time
import threading
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, Callable
import logging
@dataclass
class QueuedEvent:
"""Represents an event in the queue with retry metadata."""
event_uid: str
event_type: str
device_id: str
payload: Dict[str, Any]
created_at: datetime
retry_count: int = 0
next_retry_time: Optional[datetime] = None
max_retries: int = 10
def should_retry(self, current_time: datetime) -> bool:
"""Check if event should be retried now."""
if self.retry_count >= self.max_retries:
return False
if self.next_retry_time is None:
return True
return current_time >= self.next_retry_time
def calculate_next_retry(self) -> datetime:
"""Calculate next retry time with exponential backoff."""
# Exponential backoff: 1s, 2s, 4s, 8s, 16s, ..., max 60s
delay_seconds = min(2 ** self.retry_count, 60)
return datetime.utcnow() + timedelta(seconds=delay_seconds)
def increment_retry(self):
"""Increment retry counter and set next retry time."""
self.retry_count += 1
self.next_retry_time = self.calculate_next_retry()
class EventQueue:
"""Thread-safe event queue with retry logic."""
def __init__(self, send_callback: Callable[[Dict[str, Any]], bool], logger: Optional[logging.Logger] = None):
"""
Initialize event queue.
Args:
send_callback: Function to send event to Odoo. Returns True on success, False on failure.
logger: Logger instance for queue operations.
"""
self.queue = deque()
self.lock = threading.Lock()
self.send_callback = send_callback
self.logger = logger or logging.getLogger(__name__)
# Statistics
self.stats = {
'pending_count': 0,
'sent_count': 0,
'failed_count': 0,
'retry_count': 0
}
# Background processing
self.running = False
self.process_thread = None
def enqueue(self, event: Dict[str, Any]) -> str:
"""
Add event to queue.
Args:
event: Event dictionary with event_type, device_id, payload
Returns:
event_uid: Unique identifier for the event
"""
# Generate UID if not present
event_uid = event.get('event_uid', str(uuid.uuid4()))
queued_event = QueuedEvent(
event_uid=event_uid,
event_type=event['event_type'],
device_id=event['device_id'],
payload=event,
created_at=datetime.utcnow()
)
with self.lock:
self.queue.append(queued_event)
self.stats['pending_count'] = len(self.queue)
self.logger.debug(f"event_enqueued uid={event_uid[:8]} type={event['event_type']} queue_size={self.stats['pending_count']}")
return event_uid
def process_queue(self):
"""Process events in queue (runs in background thread)."""
while self.running:
try:
self._process_next_event()
time.sleep(0.1) # Small delay between processing attempts
except Exception as e:
self.logger.error(f"queue_processing_error error={str(e)}")
def _process_next_event(self):
"""Process next event in queue that's ready for (re)try."""
with self.lock:
if not self.queue:
return
# Find first event ready for retry
current_time = datetime.utcnow()
event_to_process = None
for i, event in enumerate(self.queue):
if event.should_retry(current_time):
event_to_process = event
# Remove from queue for processing
del self.queue[i]
self.stats['pending_count'] = len(self.queue)
break
if not event_to_process:
return
# Send event (outside lock to avoid blocking queue)
success = False
try:
success = self.send_callback(event_to_process.payload)
except Exception as e:
self.logger.error(f"event_send_exception uid={event_to_process.event_uid[:8]} error={str(e)}")
success = False
with self.lock:
if success:
# Event sent successfully
self.stats['sent_count'] += 1
self.logger.info(f"event_sent_success uid={event_to_process.event_uid[:8]} type={event_to_process.event_type} attempts={event_to_process.retry_count + 1}")
else:
# Send failed
if event_to_process.retry_count >= event_to_process.max_retries:
# Max retries exceeded
self.stats['failed_count'] += 1
self.logger.error(f"event_send_failed_permanently uid={event_to_process.event_uid[:8]} type={event_to_process.event_type} max_retries={event_to_process.max_retries}")
else:
# Re-queue with backoff
event_to_process.increment_retry()
self.queue.append(event_to_process)
self.stats['pending_count'] = len(self.queue)
self.stats['retry_count'] += 1
next_retry_delay = (event_to_process.next_retry_time - datetime.utcnow()).total_seconds()
self.logger.warning(f"event_send_failed_retry uid={event_to_process.event_uid[:8]} type={event_to_process.event_type} retry_count={event_to_process.retry_count} next_retry_in={next_retry_delay:.1f}s")
def start(self):
"""Start background queue processing."""
if self.running:
self.logger.warning("queue_already_running")
return
self.running = True
self.process_thread = threading.Thread(target=self.process_queue, daemon=True, name="EventQueueProcessor")
self.process_thread.start()
self.logger.info("queue_processor_started")
def stop(self):
"""Stop background queue processing."""
if not self.running:
return
self.running = False
if self.process_thread:
self.process_thread.join(timeout=5)
self.logger.info(f"queue_processor_stopped pending={self.stats['pending_count']} sent={self.stats['sent_count']} failed={self.stats['failed_count']}")
def get_stats(self) -> Dict[str, int]:
"""Get queue statistics."""
with self.lock:
return self.stats.copy()
def get_queue_size(self) -> int:
"""Get current queue size."""
with self.lock:
return len(self.queue)

Some files were not shown because too many files have changed in this diff Show More