Compare commits
81 Commits
18.0-Multi
...
18.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b9b8d025e | |||
| d0b7a5737d | |||
| 8a414ed3ac | |||
| 5a27dc6a65 | |||
| c55b0e59d2 | |||
| 989720ed21 | |||
| 6676433d46 | |||
| c1df940daf | |||
| 92f9548d34 | |||
| b46fed0f8e | |||
| 90e3422e8b | |||
| f1b0c50fbf | |||
| 7f827cf7eb | |||
| dfb77411d9 | |||
| 59539e0201 | |||
| b6a0f0462d | |||
| 5fcaef0336 | |||
| bb3e492d30 | |||
| aeb8e5660b | |||
| 4c03959437 | |||
| 75d91984d1 | |||
| 77cd3f4595 | |||
| 4684d33241 | |||
| 85a90f90fd | |||
| 0d27ca36b0 | |||
| b85ec97d9c | |||
| f9cbcffe34 | |||
| fe09d1439f | |||
| 23b9867eec | |||
| 00df958985 | |||
| 98c96018d6 | |||
| b2f7a5edbf | |||
| d6c2986ebf | |||
| 67184e7e01 | |||
| ee0e6c8b98 | |||
| 1c9829a7aa | |||
| 1abeb97afa | |||
| daddb51327 | |||
| 5d1faa7b5b | |||
| 21b0d6305f | |||
| 988421a0c5 | |||
| b26e70dbe9 | |||
| 38d0becfad | |||
| 943d48af58 | |||
| 09f28c8070 | |||
| 40a5fe8b0f | |||
| 31a40e95d7 | |||
| 91209dead4 | |||
| 9cb6dc8ac5 | |||
| 93385abb0f | |||
| e443f8709d | |||
| b151894c39 | |||
| 0c4b58e0a6 | |||
| e460acddf3 | |||
| a1c6903893 | |||
| 72a46e3657 | |||
| 641bfb3ade | |||
| a73c0cb299 | |||
| 4626379d6e | |||
| 9be8f320f7 | |||
| d6a98cfbd0 | |||
| 0135035a54 | |||
| f79e126c8c | |||
| 253d289633 | |||
| de317f46e6 | |||
| 8ae586cca6 | |||
| e18f2880a4 | |||
| 43cf5754fd | |||
| 4c048b5ca9 | |||
| 686b201792 | |||
| bd235e3ca0 | |||
| a5f8fd32c3 | |||
| 3fa050f153 | |||
| e53c4028c9 | |||
| 5057985024 | |||
| 00f33faebe | |||
| 2eff81ce54 | |||
| 92490aeb9c | |||
| 1b203eeb10 | |||
| ed339b0e2e | |||
| e1feeb6d75 |
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
|||
# Test Logs
|
||||
test_*.log
|
||||
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
|
@ -15,7 +19,7 @@ dist/
|
|||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
#lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
|
@ -159,3 +163,8 @@ cython_debug/
|
|||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# DokuWiki API Test Script (enthält Credentials)
|
||||
test_dokuwiki.py
|
||||
|
||||
# Python Virtual Environment
|
||||
venv/
|
||||
|
|
|
|||
46
Checkliste.md
Normal file
46
Checkliste.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# ✅ 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.
|
||||
323
FEATURE_REQUEST/open_workshop_IoT.md
Normal file
323
FEATURE_REQUEST/open_workshop_IoT.md
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
# 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.5–1 Tag)
|
||||
**Deliverables**
|
||||
- Git-Repo Struktur
|
||||
- Docker Compose: mosquitto + simulator + bridge
|
||||
- Odoo Modul Skeleton `ows_iot_bridge`
|
||||
|
||||
### M1 – Odoo Endpoint & Modelle (1–2 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 (1–2 Tage)
|
||||
**Deliverables**
|
||||
- Modell `ows.machine.session`
|
||||
- Event → Session Zuordnung
|
||||
- Timeout/Abbruch-Logik
|
||||
- Parameter für Debounce/Timeout
|
||||
|
||||
### M3 – Simulatoren & End-to-End Test (1–2 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. 16–64 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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -68,7 +68,8 @@ open_workshop/
|
|||
├── open_workshop/ # ✅ Alt-Modul (installable=False, Kompatibilität)
|
||||
├── open_workshop_base/ # ✅ FERTIG – Backend, _inherits maintenance.equipment
|
||||
├── open_workshop_pos/ # ✅ FERTIG – POS-Integration (JS, XML, UI)
|
||||
└── open_workshop_api/ # ⏳ GEPLANT – REST API für WordPress
|
||||
├── open_workshop_dokuwiki/ # ⏳ IN ENTWICKLUNG – DokuWiki-Integration
|
||||
└── open_workshop_api/ # 💤 OPTIONAL – REST API (zurückgestellt)
|
||||
```
|
||||
|
||||
**Entfernt:** open_workshop_maintenance (verworfen - Funktionalität direkt in open_workshop_base integriert)
|
||||
|
|
@ -81,7 +82,7 @@ open_workshop/
|
|||
|
||||
Enthält:
|
||||
|
||||
- ✅ `ows.machine` mit _inherits zu `maintenance.equipment`
|
||||
- ✅ `ows.machine` mit _inherits zu `maintenance.equipment` (delegiert alle Felder von maintenance.equipment transparent an ows.machine)
|
||||
- ✅ `ows.machine.area` (Bereiche mit JSONB-Namen)
|
||||
- ✅ `ows.machine.product` (Nutzungsprodukte)
|
||||
- ✅ `ows.machine.training` (Einweisungsprodukte)
|
||||
|
|
@ -140,87 +141,282 @@ Vollständig separiertes POS-Modul mit:
|
|||
|
||||
---
|
||||
|
||||
# 5. Modul: open_workshop_api ⏳ GEPLANT
|
||||
# 5. Modul: open_workshop_dokuwiki ⏳ IN ENTWICKLUNG
|
||||
|
||||
**Status: NOCH NICHT IMPLEMENTIERT**
|
||||
**Status: IN ENTWICKLUNG (Branch: feature/dokuwiki-integration)**
|
||||
|
||||
Dieses Modul stellt die **REST API** bereit, über die WordPress öffentlich verfügbare Daten abholt.
|
||||
Dieses Modul integriert **DokuWiki** als Dokumentationssystem für Equipment.
|
||||
|
||||
## 5.1 Ziele
|
||||
|
||||
- Minimaler externer Zugriff (nur API-Endpunkte)
|
||||
- JSON-Ausgabe für WordPress
|
||||
- Keine Odoo-Website erforderlich
|
||||
- Odoo selbst bleibt **nicht öffentlich erreichbar**
|
||||
- Automatische Wiki-Seiten für jedes Equipment
|
||||
- **Invertierte Include-Architektur**:
|
||||
- Odoo generiert geschützte Hauptseite mit Equipment-Daten
|
||||
- Hauptseite inkludiert User-Dokumentation via Include Plugin
|
||||
- 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 API-Endpunkte
|
||||
## 5.2 Architektur
|
||||
|
||||
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/machine/<id>
|
||||
```
|
||||
|
||||
Empfohlen:
|
||||
|
||||
```
|
||||
GET /api/v1/areas
|
||||
GET /api/v1/files/<attachment_id>
|
||||
GET /api/v1/events (später für Kurse)
|
||||
```
|
||||
|
||||
## 5.3 JSON Beispiel
|
||||
**Hinweis:** Mit DokuWiki kann die öffentliche Dokumentation direkt über das Wiki erfolgen.
|
||||
Eine separate REST API ist optional und wird nur bei Bedarf implementiert.
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
## 6.1 Warum DokuWiki statt WordPress?
|
||||
|
||||
## 5.4 Sicherheitsmechanismen
|
||||
- **Einfacher**: Keine separate Datenbank, file-based
|
||||
- **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
|
||||
|
||||
- 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.2 Zukunft: WordPress optional
|
||||
|
||||
---
|
||||
Falls später gewünscht, kann WordPress **zusätzlich** die öffentliche Maschinenliste anzeigen:
|
||||
- DokuWiki für **Dokumentation** (intern/extern lesbar)
|
||||
- WordPress für **Präsentation** (extern mit Bildern, SEO)
|
||||
- Beide Systeme greifen auf Odoo-Daten zu
|
||||
|
||||
# 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]
|
||||
```
|
||||
Aktuell hat DokuWiki-Integration **Priorität**.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -229,46 +425,58 @@ Beispiel:
|
|||
```
|
||||
Internet
|
||||
|
|
||||
WordPress (Frontend, CDN)
|
||||
DokuWiki (Dokumentation)
|
||||
WordPress (optional: Präsentation)
|
||||
|
|
||||
fetch JSON via REST API
|
||||
XML-RPC / REST API
|
||||
|
|
||||
----------------------------
|
||||
| API Gateway (open_workshop_api)
|
||||
| Exposed only: /api/v1/*
|
||||
| Odoo Backend (LAN/VPN)
|
||||
| open_workshop_dokuwiki
|
||||
| maintenance.equipment
|
||||
----------------------------
|
||||
|
|
||||
Odoo Backend (LAN/VPN)
|
||||
Maschinen, POS, Benutzer, Einweisungen
|
||||
POS, Benutzer, Einweisungen
|
||||
```
|
||||
|
||||
**Kein direkter Zugriff auf Odoo-Weboberfläche!**
|
||||
**Fokus:** Equipment-Dokumentation über DokuWiki, WordPress später optional.
|
||||
|
||||
---
|
||||
|
||||
# 8. Sicherheit
|
||||
|
||||
- Reverse Proxy (Traefik/Nginx) muss alle Odoo-Backoffice-URLS sperren
|
||||
- Nur `/api/v1/*` darf öffentlich sein
|
||||
**DokuWiki ACL:**
|
||||
- Namespace `ausruestung:*` lesbar für alle (@ALL = 1)
|
||||
- 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
|
||||
- Rate-Limit + Firewall-Regeln
|
||||
- Token-Authentifizierung optional zusätzlich einsetzbar
|
||||
- Rate-Limit + Firewall
|
||||
- Token-Auth
|
||||
|
||||
---
|
||||
|
||||
# 9. Vorteile dieser Architektur
|
||||
|
||||
✔ **WordPress bleibt ultraschnell** (1000 Besucher/Tag problemlos)
|
||||
✔ **DokuWiki für Dokumentation** – Wiki-Syntax perfekt für Anleitungen
|
||||
✔ **Versionierung eingebaut** – History für alle Änderungen
|
||||
✔ **Odoo bleibt sicher** hinter Firewall
|
||||
✔ **Keine Benutzerkosten** → alle Ehrenamtlichen können intern mitarbeiten
|
||||
✔ **Keine Benutzerkosten** – alle Ehrenamtlichen können intern mitarbeiten
|
||||
✔ **Modular, wartbar, zukunftssicher**
|
||||
✔ **API erlaubt feine Steuerung**, welche Daten öffentlich sind
|
||||
✔ **Keine Last auf DSL-Leitung** (WordPress hostet extern)
|
||||
✔ **Maintenance-Integration** ohne Extra-Modul direkt in Base
|
||||
✔ **Intelligente Sync** – Nur strukturierte Daten werden überschrieben, Freitext bleibt erhalten
|
||||
✔ **Öffentlich lesbar** – Anleitungen für alle verfügbar
|
||||
✔ **WordPress optional** – Kann später für Marketing/SEO ergänzt werden
|
||||
|
||||
---
|
||||
|
||||
# 10. Implementierungsstand (08.12.2025)
|
||||
# 10. Implementierungsstand (13.12.2025)
|
||||
|
||||
| Schritt | Status | Details |
|
||||
|---------|--------|---------|
|
||||
|
|
@ -276,45 +484,55 @@ Beispiel:
|
|||
| 2. open_workshop_pos | ✅ **FERTIG** | POS-Integration komplett |
|
||||
| 3. ~~open_workshop_maintenance~~ | ❌ **VERWORFEN** | Direkt in Base integriert |
|
||||
| 4. Maintenance Integration | ✅ **FERTIG** | _inherits Pattern implementiert |
|
||||
| 5. Migration Workflow | ✅ **FERTIG** | SQL + Python, CI/CD integriert |
|
||||
| 6. open_workshop_api | ⏳ **GEPLANT** | REST API für WordPress |
|
||||
| 7. WordPress Plugin | ⏳ **GEPLANT** | Frontend-Integration |
|
||||
| 5. Equipment-View Integration | ✅ **FERTIG** | Related fields, Menu-Migration |
|
||||
| 6. Migration Workflow | ✅ **FERTIG** | SQL + Python, CI/CD integriert |
|
||||
| 7. open_workshop_dokuwiki | ⏳ **IN ENTWICKLUNG** | Branch: feature/dokuwiki-integration |
|
||||
| 8. open_workshop_api | 💤 **OPTIONAL** | Zurückgestellt, bei Bedarf |
|
||||
|
||||
---
|
||||
|
||||
# 11. Nächste Schritte
|
||||
|
||||
1. **open_workshop_api** entwickeln
|
||||
- REST Controller implementieren
|
||||
- JSON-Serializer für machines/areas
|
||||
- CORS und Security konfigurieren
|
||||
- Token-Auth optional hinzufügen
|
||||
1. **open_workshop_dokuwiki** entwickeln
|
||||
- DokuWiki XML-RPC Client implementieren
|
||||
- Template-System für Equipment-Seiten
|
||||
- Sync-Mechanismus (Odoo → Wiki)
|
||||
- Smart Button "Wiki öffnen"
|
||||
- ACL-Konfiguration dokumentieren
|
||||
|
||||
2. **WordPress Plugin** anpassen
|
||||
- API-Endpunkte konfigurieren
|
||||
- Shortcode-Rendering
|
||||
- Caching implementieren
|
||||
2. **DokuWiki aufsetzen**
|
||||
- Installation auf Server
|
||||
- Namespace `ausruestung:` anlegen
|
||||
- ACL konfigurieren (@odoo Gruppe)
|
||||
- XML-RPC API aktivieren
|
||||
|
||||
3. **Testing & Deployment**
|
||||
- API-Tests schreiben
|
||||
- Reverse Proxy konfigurieren
|
||||
- Performance-Tests
|
||||
- Go-Live vorbereiten
|
||||
3. **Testing**
|
||||
- Equipment-Seiten automatisch generieren
|
||||
- Sync bei Änderungen testen
|
||||
- ACL-Rechte validieren
|
||||
- Performance testen
|
||||
|
||||
4. **Optional: WordPress Plugin**
|
||||
- Falls öffentliche Präsentation gewünscht
|
||||
- REST API dann implementieren
|
||||
- Caching-Layer hinzufügen
|
||||
|
||||
---
|
||||
|
||||
# 12. Endfazit
|
||||
|
||||
Diese Architektur hat sich bewährt:
|
||||
Diese Architektur vereint das Beste aus beiden Welten:
|
||||
|
||||
- ✅ **Technisch korrekt** – _inherits Pattern statt separatem Modul
|
||||
- ✅ **Performant** – Maintenance.equipment als Single Source of Truth
|
||||
- ✅ **Sicher** – API-Layer trennt intern/extern
|
||||
- ✅ **Kostenoptimiert** – Keine Odoo.sh Lizenzen nötig
|
||||
- ✅ **Dokumentiert** – DokuWiki für strukturierte Anleitungen
|
||||
- ✅ **Versioniert** – History-System eingebaut
|
||||
- ✅ **Öffentlich zugänglich** – Wiki lesbar für alle
|
||||
- ✅ **Kostenoptimiert** – Keine zusätzlichen Lizenzen
|
||||
- ✅ **Langfristig erweiterbar** – Modularer Aufbau
|
||||
- ✅ **Produktiv im Einsatz** – Migration erfolgreich abgeschlossen
|
||||
|
||||
Die API ist **zentrales Zukunftsmodul** für die WordPress-Integration.
|
||||
**DokuWiki-Integration ist der nächste logische Schritt** für bessere Equipment-Dokumentation.
|
||||
|
||||
**Letztes Update: 08.12.2025**
|
||||
**Letztes Update: 13.12.2025**
|
||||
|
||||
|
|
|
|||
415
README.md
Normal file
415
README.md
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ from odoo import models, fields
|
|||
class MaintenanceEquipment(models.Model):
|
||||
_inherit = 'maintenance.equipment'
|
||||
|
||||
qr_code = fields.Binary("QR Code")
|
||||
qr_code = fields.Binary("QR Code", attachment=True)
|
||||
comp_serial_no = fields.Char("Inventory Serial No", tracking=True)
|
||||
serial_no = fields.Char('Mfg. Serial Number', copy=False)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<div class="o_label_data">
|
||||
<div class="text-center o_label_right_column">
|
||||
<t t-if="equipment.qr_code">
|
||||
<div t-field="equipment.qr_code" t-options="{'widget': 'image'}" style="width: 130px;margin-top: -40px;"/>
|
||||
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')" style="width: 130px;margin-top: -40px;"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="text-left" style="line-height:normal;word-wrap: break-word;">
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
</div>
|
||||
<div class= "text-center o_label_right_column">
|
||||
<t t-if="equipment.qr_code">
|
||||
<div t-field="equipment.qr_code" t-options="{'widget': 'image'}" style="width:95px;padding:0px;margin-top:-10px"/>
|
||||
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')" style="width:95px;padding:0px;margin-top:-10px"/>
|
||||
</t>
|
||||
</div>
|
||||
<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:5%">:</td>
|
||||
<td colspan="2" style="width:70%;word-wrap: break-word;">
|
||||
<t t-esc="equipment.name"/>
|
||||
<span t-field="equipment.name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:25%">Model</td>
|
||||
<td style="width:5%">:</td>
|
||||
<td style="width:35%;word-wrap: break-word;">
|
||||
<t t-esc="equipment.model"/>
|
||||
<span t-field="equipment.model"/>
|
||||
</td>
|
||||
<td rowspan="5" style="width:35%">
|
||||
<t t-if="equipment.name">
|
||||
<div t-field="equipment.qr_code" t-options="{'widget': 'image'}"
|
||||
style="width:30mm;height:10mm;"/>
|
||||
<t t-if="equipment.qr_code">
|
||||
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')"
|
||||
style="width:30mm;height:30mm;"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -87,23 +87,22 @@
|
|||
<td style="width:25%">Mfg Serial</td>
|
||||
<td style="width:5%">:</td>
|
||||
<td style="width:35%;font-size:10px;word-wrap: break-word;">
|
||||
<t t-esc="equipment.serial_no"/>
|
||||
<span t-field="equipment.serial_no"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:25%">Serial</td>
|
||||
<td style="width:5%">:</td>
|
||||
<td style="width:35%;word-wrap: break-word;">
|
||||
<t t-esc="equipment.comp_serial_no"/>
|
||||
<span t-field="equipment.comp_serial_no"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:25%">Warranty Date</td>
|
||||
<td style="width:5%">:</td>
|
||||
<td style="width:35%;word-wrap: break-word;">
|
||||
<t t-esc="equipment.warranty_date"/>
|
||||
<span t-field="equipment.warranty_date"/>
|
||||
</td>
|
||||
<!-- <td style="width:35%;"></td> -->
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:25%"></td>
|
||||
|
|
|
|||
|
|
@ -1,84 +1,34 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# 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
|
||||
|
||||
|
||||
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):
|
||||
_name = 'report.aspl_equipment_qrcode_generator.maintenance_quip'
|
||||
_description = 'Equipment QR-code Report'
|
||||
|
||||
def _get_report_values(self, docids, data):
|
||||
return _prepare_data(self.env, data)
|
||||
def _get_report_values(self, docids, data=None):
|
||||
"""
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ class EquipmentLabelLayout(models.TransientModel):
|
|||
rows = 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')
|
||||
def _compute_dimensions(self):
|
||||
for wizard in self:
|
||||
|
|
@ -28,9 +38,62 @@ class EquipmentLabelLayout(models.TransientModel):
|
|||
|
||||
|
||||
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'
|
||||
data = {
|
||||
'equipment_label_layout_id':self.id
|
||||
'equipment_label_layout_id': self.id
|
||||
}
|
||||
|
||||
return self.env.ref(xml_id).report_action(None, data=data)
|
||||
# report_action benötigt die Equipment IDs als docids
|
||||
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})
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<group>
|
||||
<field name="equipment_ids" invisible="1"/>
|
||||
<group>
|
||||
<field name="print_format" widget="radio"/>
|
||||
</group>
|
||||
|
|
|
|||
|
|
@ -1,300 +0,0 @@
|
|||
<?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 <token>).', '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();
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
=== 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.
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../../../usr/lib/python3/dist-packages/odoo"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from . import models
|
||||
from . import controllers
|
||||
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
'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.
|
||||
""",
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# Datei: open_workshop/controllers/__init__.py
|
||||
|
||||
from . import pos_access
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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)
|
||||
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from . import ows_models
|
||||
from . import pos_order
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,589 +0,0 @@
|
|||
# -*- 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')
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
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,9 +0,0 @@
|
|||
.category-color-circle {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
// @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);
|
||||
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
// @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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
// 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 };
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<!-- 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>
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<!-- 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>
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
<!-- 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>
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# ✅ 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.
|
||||
|
|
@ -1,20 +1,21 @@
|
|||
{
|
||||
'name': 'Open Workshop Base',
|
||||
'license': 'AGPL-3',
|
||||
'version': '18.0.1.0.4', # Migration läuft bei 18.0.1.0.4
|
||||
'version': '18.0.1.0.5', # Migration läuft bei 18.0.1.0.4
|
||||
'summary': 'Kern-Modul für Maschinenfreigaben - vereinfachte Equipment-Integration',
|
||||
'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance'],
|
||||
'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance', 'maintenance_equipment_status'],
|
||||
'author': 'matthias.lotz',
|
||||
'category': 'Manufacturing',
|
||||
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/maintenance_equipment_status_data.xml',
|
||||
'views/machine_product_training_views.xml',
|
||||
'views/menu_views.xml',
|
||||
'views/machine_area_views.xml',
|
||||
'views/machine_views.xml',
|
||||
'views/maintenance_equipment_views.xml',
|
||||
'views/res_partner_view.xml',
|
||||
'views/menu_views.xml',
|
||||
'data/data.xml',
|
||||
],
|
||||
'installable': True,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?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>
|
||||
|
|
@ -99,7 +99,55 @@ def migrate(cr, version):
|
|||
except Exception as e:
|
||||
_logger.error(f"Failed to migrate machine {m_id}: {e}")
|
||||
|
||||
# 3. Validierung
|
||||
# 3. Synchronisiere related fields von ows.machine → maintenance.equipment
|
||||
# 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")
|
||||
remaining = cr.fetchone()[0]
|
||||
|
||||
|
|
@ -109,3 +157,4 @@ def migrate(cr, version):
|
|||
_logger.info(f"✅ Successfully migrated {migrated_count} machines to equipment")
|
||||
|
||||
_logger.info("Post-migration completed")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from . import ows_models
|
||||
from . import pos_order
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -400,6 +400,128 @@ AVAILABLE_COLORS = [
|
|||
('#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):
|
||||
_name = 'ows.machine.area'
|
||||
_table = 'ows_machine_area'
|
||||
|
|
@ -513,43 +635,33 @@ class OwsMachine(models.Model):
|
|||
|
||||
# Keine eigenen SQL Constraints - Equipment hat bereits unique constraint für serial_no
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""
|
||||
Beim Erstellen einer ows.machine:
|
||||
1. Equipment IMMER automatisch erstellen
|
||||
1. Equipment nur erstellen wenn equipment_id NICHT angegeben wurde
|
||||
2. Area → Location synchronisieren
|
||||
3. serial_no und name vom User übernehmen
|
||||
"""
|
||||
# Equipment IMMER automatisch erstellen
|
||||
equipment_vals = {
|
||||
'name': vals.get('name', 'Neue Maschine'),
|
||||
'serial_no': vals.get('serial_no', False),
|
||||
}
|
||||
# Equipment nur erstellen wenn noch nicht vorhanden
|
||||
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 = {
|
||||
'name': vals.get('name', 'Neue Maschine'),
|
||||
'serial_no': vals.get('serial_no', False),
|
||||
}
|
||||
|
||||
equipment = self.env['maintenance.equipment'].with_context(skip_ows_machine_creation=True).create(equipment_vals)
|
||||
vals['equipment_id'] = equipment.id
|
||||
|
||||
# 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
|
||||
|
||||
return super(OwsMachine, self).create(vals)
|
||||
return super(OwsMachine, self).create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
"""
|
||||
Bei Updates:
|
||||
1. Area → Location synchronisieren
|
||||
2. Name/Serial_no → Equipment synchronisieren
|
||||
1. 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
|
||||
equipment_vals = {}
|
||||
if 'name' in vals:
|
||||
|
|
@ -666,6 +778,14 @@ class OwsMachineProduct(models.Model):
|
|||
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')
|
||||
|
||||
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):
|
||||
_name = 'ows.machine.training'
|
||||
_table = 'ows_machine_training'
|
||||
|
|
@ -673,3 +793,11 @@ class OwsMachineTraining(models.Model):
|
|||
|
||||
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')
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
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
|
||||
|
|
@ -7,9 +7,6 @@
|
|||
<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_base.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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<odoo>
|
||||
<!-- Tree View: Nutzungsprodukte -->
|
||||
<!-- Tree View: Nutzungsprodukte (normale Ansicht) -->
|
||||
<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>
|
||||
|
|
@ -10,8 +10,20 @@
|
|||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View: Nutzungsprodukte (vereinfacht für Equipment) -->
|
||||
<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 -->
|
||||
<!-- Tree View: Einweisungsprodukte (normale Ansicht) -->
|
||||
<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>
|
||||
|
|
@ -22,6 +34,18 @@
|
|||
</list>
|
||||
</field>
|
||||
</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 -->
|
||||
<record id="action_machine_product" model="ir.actions.act_window">
|
||||
|
|
|
|||
|
|
@ -6,20 +6,27 @@
|
|||
<field name="model">ows.machine</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Ausrüstung suchen">
|
||||
<!-- OWS Felder -->
|
||||
<field name="name" string="Name"/>
|
||||
<field name="serial_no" string="Code"/>
|
||||
<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/>
|
||||
<filter string="Kategorie 1 (grün)" name="filter_green" domain="[('category', '=', 'green')]"/>
|
||||
<filter string="Kategorie 2 (gelb)" name="filter_yellow" domain="[('category', '=', 'yellow')]"/>
|
||||
<filter string="Kategorie 3 (rot)" name="filter_red" domain="[('category', '=', 'red')]"/>
|
||||
<filter string="Kategorie grün" name="filter_green" domain="[('category', '=', 'green')]"/>
|
||||
<filter string="Kategorie gelb" name="filter_yellow" domain="[('category', '=', 'yellow')]"/>
|
||||
<filter string="Kategorie rot" name="filter_red" domain="[('category', '=', 'red')]"/>
|
||||
<separator/>
|
||||
<filter string="Aktiv" name="filter_active" domain="[('active', '=', True)]"/>
|
||||
<filter string="Archiviert" name="filter_inactive" domain="[('active', '=', False)]"/>
|
||||
<separator/>
|
||||
<group expand="0" string="Gruppieren nach">
|
||||
<group expand="1" string="Gruppieren nach">
|
||||
<filter string="Bereich" name="group_area" context="{'group_by': 'area_id'}"/>
|
||||
<filter string="Kategorie" name="group_category" context="{'group_by': 'category'}"/>
|
||||
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
|
|
@ -33,7 +40,7 @@
|
|||
<list sample="1" multi_edit="1">
|
||||
<field name="category_icon" string="⚙" readonly="1"/>
|
||||
<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="area_id" widget="many2one" optional="show"/>
|
||||
<field name="location" optional="hide"/>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,80 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Inherit maintenance.equipment form view to make location readonly -->
|
||||
<record id="view_equipment_form_location_readonly" model="ir.ui.view">
|
||||
<field name="name">maintenance.equipment.form.location.readonly</field>
|
||||
<!-- Erweitere Equipment Form mit OWS-Feldern -->
|
||||
<record id="view_equipment_form_ows_extension" model="ir.ui.view">
|
||||
<field name="name">maintenance.equipment.form.ows</field>
|
||||
<field name="model">maintenance.equipment</field>
|
||||
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='location']" position="attributes">
|
||||
<attribute name="readonly">1</attribute>
|
||||
<attribute name="help">Wird automatisch aus dem Bereich (ows.machine) synchronisiert. Änderungen nur über Bereich möglich.</attribute>
|
||||
<!-- Füge OWS-Felder in die Details-Gruppe ein -->
|
||||
<xpath expr="//field[@name='location']" position="after">
|
||||
<field name="ows_area_id" string="Bereich"/>
|
||||
<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>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
<!-- menu_views.xml -->
|
||||
<odoo>
|
||||
<!-- Maschinenliste -->
|
||||
<record id="action_machine_list" model="ir.actions.act_window">
|
||||
<!-- Equipment-Liste mit OWS-Filter (ersetzt Standard Equipment Action) -->
|
||||
<record id="action_equipment_ows" model="ir.actions.act_window">
|
||||
<field name="name">Ausrüstung</field>
|
||||
<field name="res_model">ows.machine</field>
|
||||
<field name="res_model">maintenance.equipment</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>
|
||||
|
||||
<!-- Trainingsprodukt-Liste -->
|
||||
|
|
@ -14,42 +19,32 @@
|
|||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Menüstruktur -->
|
||||
<!-- Oberstes Menü -->
|
||||
<menuitem id="menu_machine_root"
|
||||
name="Ausrüstung"
|
||||
sequence="10"/>
|
||||
<!-- Integration in Maintenance Menü -->
|
||||
|
||||
<!-- Bereiche unter Maintenance → Configuration -->
|
||||
<menuitem id="menu_maintenance_ows_areas"
|
||||
name="Bereiche"
|
||||
parent="maintenance.menu_maintenance_config"
|
||||
action="action_machine_area_list"
|
||||
sequence="50"/>
|
||||
|
||||
<!-- 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 Ausrüstung"
|
||||
parent="menu_machine_config"
|
||||
action="open_workshop_base.action_machine_list"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Menücontainer: Zuordnungen -->
|
||||
<menuitem id="menu_machine_list"
|
||||
<!-- Zuordnungen Container unter Maintenance → Configuration -->
|
||||
<menuitem id="menu_maintenance_ows_assignments"
|
||||
name="Zuordnungen"
|
||||
parent="menu_machine_config"
|
||||
sequence="20"/>
|
||||
parent="maintenance.menu_maintenance_config"
|
||||
sequence="51"/>
|
||||
|
||||
<!-- Untermenü: Nutzungsprodukte -->
|
||||
<menuitem id="menu_machine_product"
|
||||
<!-- Nutzungsprodukte unter Zuordnungen -->
|
||||
<menuitem id="menu_maintenance_machine_product"
|
||||
name="Nutzungsprodukte"
|
||||
parent="menu_machine_list"
|
||||
parent="menu_maintenance_ows_assignments"
|
||||
action="action_machine_product"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Untermenü: Einweisungsprodukte -->
|
||||
<menuitem id="menu_machine_training"
|
||||
<!-- Einweisungsprodukte unter Zuordnungen -->
|
||||
<menuitem id="menu_maintenance_machine_training"
|
||||
name="Einweisungsprodukte"
|
||||
parent="menu_machine_list"
|
||||
parent="menu_maintenance_ows_assignments"
|
||||
action="action_machine_training"
|
||||
sequence="20"/>
|
||||
|
||||
|
|
|
|||
456
open_workshop_dokuwiki/README.md
Normal file
456
open_workshop_dokuwiki/README.md
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
# 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
|
||||
4
open_workshop_dokuwiki/__init__.py
Normal file
4
open_workshop_dokuwiki/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import wizard
|
||||
from .hooks import post_init_hook, pre_init_hook
|
||||
68
open_workshop_dokuwiki/__manifest__.py
Normal file
68
open_workshop_dokuwiki/__manifest__.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# -*- 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,
|
||||
}
|
||||
8
open_workshop_dokuwiki/data/ir_config_parameter.xml
Normal file
8
open_workshop_dokuwiki/data/ir_config_parameter.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
DokuWiki System Parameters werden über post_init_hook initialisiert
|
||||
Dadurch werden sie bei Install und Update korrekt gehandhabt
|
||||
und User-Änderungen bleiben erhalten
|
||||
-->
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?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>
|
||||
15
open_workshop_dokuwiki/hooks.py
Normal file
15
open_workshop_dokuwiki/hooks.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# -*- 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()
|
||||
101
open_workshop_dokuwiki/migrations/18.0.2.1.0/pre-migration.py
Normal file
101
open_workshop_dokuwiki/migrations/18.0.2.1.0/pre-migration.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# -*- 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
|
||||
5
open_workshop_dokuwiki/models/__init__.py
Normal file
5
open_workshop_dokuwiki/models/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import dokuwiki_client
|
||||
from . import maintenance_equipment
|
||||
from . import maintenance_equipment_status
|
||||
from . import res_config_settings
|
||||
392
open_workshop_dokuwiki/models/dokuwiki_client.py
Normal file
392
open_workshop_dokuwiki/models/dokuwiki_client.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
# -*- 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}"
|
||||
974
open_workshop_dokuwiki/models/maintenance_equipment.py
Normal file
974
open_workshop_dokuwiki/models/maintenance_equipment.py
Normal file
|
|
@ -0,0 +1,974 @@
|
|||
# -*- 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# -*- 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 :-()'
|
||||
)
|
||||
54
open_workshop_dokuwiki/models/res_config_settings.py
Normal file
54
open_workshop_dokuwiki/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# -*- 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")
|
||||
2
open_workshop_dokuwiki/security/ir.model.access.csv
Normal file
2
open_workshop_dokuwiki/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
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
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
<?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>
|
||||
120
open_workshop_dokuwiki/views/maintenance_equipment_views.xml
Normal file
120
open_workshop_dokuwiki/views/maintenance_equipment_views.xml
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?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>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>
|
||||
2
open_workshop_dokuwiki/wizard/__init__.py
Normal file
2
open_workshop_dokuwiki/wizard/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import equipment_wiki_sync_wizard
|
||||
157
open_workshop_dokuwiki/wizard/equipment_wiki_sync_wizard.py
Normal file
157
open_workshop_dokuwiki/wizard/equipment_wiki_sync_wizard.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# -*- 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()
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?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>
|
||||
139
open_workshop_employee_imagegenerator/README.md
Normal file
139
open_workshop_employee_imagegenerator/README.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# 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
|
||||
2
open_workshop_employee_imagegenerator/__init__.py
Normal file
2
open_workshop_employee_imagegenerator/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
53
open_workshop_employee_imagegenerator/__manifest__.py
Normal file
53
open_workshop_employee_imagegenerator/__manifest__.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# -*- 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,
|
||||
}
|
||||
2
open_workshop_employee_imagegenerator/models/__init__.py
Normal file
2
open_workshop_employee_imagegenerator/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import hr_employee
|
||||
32
open_workshop_employee_imagegenerator/models/hr_employee.py
Normal file
32
open_workshop_employee_imagegenerator/models/hr_employee.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# -*- 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 '',
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/*!
|
||||
* 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
20
open_workshop_employee_imagegenerator/static/lib/html2canvas.min.js
vendored
Normal file
20
open_workshop_employee_imagegenerator/static/lib/html2canvas.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,96 @@
|
|||
/* 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/* 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
/** @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);
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
<?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 & 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 < 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>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?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>
|
||||
716
open_workshop_mqtt/FEATURE_REQUEST_OPEN_WORKSHOP_MQTT_IoT.md
Normal file
716
open_workshop_mqtt/FEATURE_REQUEST_OPEN_WORKSHOP_MQTT_IoT.md
Normal file
|
|
@ -0,0 +1,716 @@
|
|||
# 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. 16–64 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
|
||||
}
|
||||
}
|
||||
```
|
||||
381
open_workshop_mqtt/IMPLEMENTATION_PLAN.md
Normal file
381
open_workshop_mqtt/IMPLEMENTATION_PLAN.md
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
# 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
|
||||
235
open_workshop_mqtt/PHASE2_TESTING.md
Normal file
235
open_workshop_mqtt/PHASE2_TESTING.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# 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
|
||||
165
open_workshop_mqtt/README.md
Normal file
165
open_workshop_mqtt/README.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# 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
|
||||
336
open_workshop_mqtt/TODO.md
Normal file
336
open_workshop_mqtt/TODO.md
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
# 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!
|
||||
4
open_workshop_mqtt/__init__.py
Normal file
4
open_workshop_mqtt/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
49
open_workshop_mqtt/__manifest__.py
Normal file
49
open_workshop_mqtt/__manifest__.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# -*- 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,
|
||||
}
|
||||
34
open_workshop_mqtt/check_routes.py
Normal file
34
open_workshop_mqtt/check_routes.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#!/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}")
|
||||
3
open_workshop_mqtt/controllers/__init__.py
Normal file
3
open_workshop_mqtt/controllers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import iot_api
|
||||
312
open_workshop_mqtt/controllers/iot_api.py
Normal file
312
open_workshop_mqtt/controllers/iot_api.py
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
# -*- 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))
|
||||
61
open_workshop_mqtt/iot_bridge/.dockerignore
Normal file
61
open_workshop_mqtt/iot_bridge/.dockerignore
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# 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
|
||||
48
open_workshop_mqtt/iot_bridge/.gitignore
vendored
Normal file
48
open_workshop_mqtt/iot_bridge/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# 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
|
||||
57
open_workshop_mqtt/iot_bridge/Dockerfile
Normal file
57
open_workshop_mqtt/iot_bridge/Dockerfile
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# 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"]
|
||||
402
open_workshop_mqtt/iot_bridge/README.md
Normal file
402
open_workshop_mqtt/iot_bridge/README.md
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
# 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)
|
||||
105
open_workshop_mqtt/iot_bridge/config.py
Normal file
105
open_workshop_mqtt/iot_bridge/config.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""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
|
||||
)
|
||||
38
open_workshop_mqtt/iot_bridge/config.yaml
Normal file
38
open_workshop_mqtt/iot_bridge/config.yaml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# 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)
|
||||
57
open_workshop_mqtt/iot_bridge/config.yaml.dev
Normal file
57
open_workshop_mqtt/iot_bridge/config.yaml.dev
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# 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
|
||||
35
open_workshop_mqtt/iot_bridge/config.yaml.example
Normal file
35
open_workshop_mqtt/iot_bridge/config.yaml.example
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# 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
|
||||
197
open_workshop_mqtt/iot_bridge/event_queue.py
Normal file
197
open_workshop_mqtt/iot_bridge/event_queue.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""
|
||||
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)
|
||||
35
open_workshop_mqtt/iot_bridge/logger_setup.py
Normal file
35
open_workshop_mqtt/iot_bridge/logger_setup.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Logging setup for IoT Bridge."""
|
||||
import logging
|
||||
import structlog
|
||||
from config import LoggingConfig
|
||||
|
||||
|
||||
def setup_logging(config: LoggingConfig):
|
||||
"""Configure structured logging with structlog."""
|
||||
|
||||
# Set log level
|
||||
log_level = getattr(logging, config.level.upper(), logging.INFO)
|
||||
logging.basicConfig(level=log_level)
|
||||
|
||||
# Configure structlog
|
||||
if config.format == "json":
|
||||
renderer = structlog.processors.JSONRenderer()
|
||||
else:
|
||||
renderer = structlog.dev.ConsoleRenderer()
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
renderer,
|
||||
],
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
return structlog.get_logger()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user