Compare commits

..

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

128 changed files with 2246 additions and 7308 deletions

11
.gitignore vendored
View File

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

View File

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

View File

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

View File

@ -68,8 +68,7 @@ open_workshop/
├── open_workshop/ # ✅ Alt-Modul (installable=False, Kompatibilität)
├── open_workshop_base/ # ✅ FERTIG Backend, _inherits maintenance.equipment
├── open_workshop_pos/ # ✅ FERTIG POS-Integration (JS, XML, UI)
├── open_workshop_dokuwiki/ # ⏳ IN ENTWICKLUNG DokuWiki-Integration
└── open_workshop_api/ # 💤 OPTIONAL REST API (zurückgestellt)
└── open_workshop_api/ # ⏳ GEPLANT REST API für WordPress
```
**Entfernt:** open_workshop_maintenance (verworfen - Funktionalität direkt in open_workshop_base integriert)
@ -82,7 +81,7 @@ open_workshop/
Enthält:
- ✅ `ows.machine` mit _inherits zu `maintenance.equipment` (delegiert alle Felder von maintenance.equipment transparent an ows.machine)
- ✅ `ows.machine` mit _inherits zu `maintenance.equipment`
- ✅ `ows.machine.area` (Bereiche mit JSONB-Namen)
- ✅ `ows.machine.product` (Nutzungsprodukte)
- ✅ `ows.machine.training` (Einweisungsprodukte)
@ -141,282 +140,87 @@ Vollständig separiertes POS-Modul mit:
---
# 5. Modul: open_workshop_dokuwiki ⏳ IN ENTWICKLUNG
# 5. Modul: open_workshop_api ⏳ GEPLANT
**Status: IN ENTWICKLUNG (Branch: feature/dokuwiki-integration)**
**Status: NOCH NICHT IMPLEMENTIERT**
Dieses Modul integriert **DokuWiki** als Dokumentationssystem für Equipment.
Dieses Modul stellt die **REST API** bereit, über die WordPress öffentlich verfügbare Daten abholt.
## 5.1 Ziele
- 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
- Minimaler externer Zugriff (nur API-Endpunkte)
- JSON-Ausgabe für WordPress
- Keine Odoo-Website erforderlich
- Odoo selbst bleibt **nicht öffentlich erreichbar**
## 5.2 Architektur
## 5.2 API-Endpunkte
```
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
Pflicht:
```
GET /api/v1/machines
GET /api/v1/machine/<id>
GET /api/v1/areas
```
**Hinweis:** Mit DokuWiki kann die öffentliche Dokumentation direkt über das Wiki erfolgen.
Eine separate REST API ist optional und wird nur bei Bedarf implementiert.
Empfohlen:
## 6.1 Warum DokuWiki statt WordPress?
```
GET /api/v1/areas
GET /api/v1/files/<attachment_id>
GET /api/v1/events (später für Kurse)
```
- **Einfacher**: Keine separate Datenbank, file-based
- **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
## 5.3 JSON Beispiel
## 6.2 Zukunft: WordPress optional
```json
{
"id": 12,
"name": "Formatkreissäge",
"status": "available",
"area": "Holzwerkstatt",
"image_url": "https://odoo.example.org/api/v1/file/29",
"manual_url": "https://odoo.example.org/api/v1/file/55",
"training_required": true
}
```
Falls später gewünscht, kann WordPress **zusätzlich** die öffentliche Maschinenliste anzeigen:
- DokuWiki für **Dokumentation** (intern/extern lesbar)
- WordPress für **Präsentation** (extern mit Bildern, SEO)
- Beide Systeme greifen auf Odoo-Daten zu
## 5.4 Sicherheitsmechanismen
Aktuell hat DokuWiki-Integration **Priorität**.
- CORS nur für WordPress-Domain erlauben
- Optionaler Token im Header (`Authorization: Bearer <token>`)
- Kein Zugriff auf `/web`, `/api/web`, `/pos`
- Rate Limiting via Reverse Proxy
---
# 6. WordPress Integration ⏳ GEPLANT
**Status: VORBEREITET (API noch nicht implementiert)**
WordPress bleibt die öffentliche Website.
## 6.1 Warum?
- Sehr hohe Performance (CDN, Caching)
- SEO-optimiert
- Keine Belastung deiner Internetleitung
- Keine Sicherheitsrisiken im internen Odoo
- Keine Lizenzkosten
## 6.2 WordPress Plugin (bereitgestellt)
Das Plugin:
- Ruft Odoo-API ab
- Rendert Maschinenliste via Shortcode
- Konsumiert JSON-Daten
- Unterstützt Token-Auth
Beispiel:
```
[openworkshop_machines]
```
---
@ -425,58 +229,46 @@ Aktuell hat DokuWiki-Integration **Priorität**.
```
Internet
|
DokuWiki (Dokumentation)
WordPress (optional: Präsentation)
WordPress (Frontend, CDN)
|
XML-RPC / REST API
fetch JSON via REST API
|
----------------------------
| Odoo Backend (LAN/VPN)
| open_workshop_dokuwiki
| maintenance.equipment
| API Gateway (open_workshop_api)
| Exposed only: /api/v1/*
----------------------------
POS, Benutzer, Einweisungen
|
Odoo Backend (LAN/VPN)
Maschinen, POS, Benutzer, Einweisungen
```
**Fokus:** Equipment-Dokumentation über DokuWiki, WordPress später optional.
**Kein direkter Zugriff auf Odoo-Weboberfläche!**
---
# 8. Sicherheit
**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/*`
- Reverse Proxy (Traefik/Nginx) muss alle Odoo-Backoffice-URLS sperren
- Nur `/api/v1/*` darf öffentlich sein
- HTTPS erzwingen
- Rate-Limit + Firewall
- Token-Auth
- Rate-Limit + Firewall-Regeln
- Token-Authentifizierung optional zusätzlich einsetzbar
---
# 9. Vorteile dieser Architektur
**DokuWiki für Dokumentation** Wiki-Syntax perfekt für Anleitungen
**Versionierung eingebaut** History für alle Änderungen
**WordPress bleibt ultraschnell** (1000 Besucher/Tag problemlos)
**Odoo bleibt sicher** hinter Firewall
**Keine Benutzerkosten** alle Ehrenamtlichen können intern mitarbeiten
**Keine Benutzerkosten** → alle Ehrenamtlichen können intern mitarbeiten
**Modular, wartbar, zukunftssicher**
**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
**API erlaubt feine Steuerung**, welche Daten öffentlich sind
**Keine Last auf DSL-Leitung** (WordPress hostet extern)
**Maintenance-Integration** ohne Extra-Modul direkt in Base
---
# 10. Implementierungsstand (13.12.2025)
# 10. Implementierungsstand (08.12.2025)
| Schritt | Status | Details |
|---------|--------|---------|
@ -484,55 +276,45 @@ Aktuell hat DokuWiki-Integration **Priorität**.
| 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. 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 |
| 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 |
---
# 11. Nächste Schritte
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
1. **open_workshop_api** entwickeln
- REST Controller implementieren
- JSON-Serializer für machines/areas
- CORS und Security konfigurieren
- Token-Auth optional hinzufügen
2. **DokuWiki aufsetzen**
- Installation auf Server
- Namespace `ausruestung:` anlegen
- ACL konfigurieren (@odoo Gruppe)
- XML-RPC API aktivieren
2. **WordPress Plugin** anpassen
- API-Endpunkte konfigurieren
- Shortcode-Rendering
- Caching implementieren
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
3. **Testing & Deployment**
- API-Tests schreiben
- Reverse Proxy konfigurieren
- Performance-Tests
- Go-Live vorbereiten
---
# 12. Endfazit
Diese Architektur vereint das Beste aus beiden Welten:
Diese Architektur hat sich bewährt:
- ✅ **Technisch korrekt** _inherits Pattern statt separatem Modul
- ✅ **Performant** Maintenance.equipment als Single Source of Truth
- ✅ **Dokumentiert** DokuWiki für strukturierte Anleitungen
- ✅ **Versioniert** History-System eingebaut
- ✅ **Öffentlich zugänglich** Wiki lesbar für alle
- ✅ **Kostenoptimiert** Keine zusätzlichen Lizenzen
- ✅ **Sicher** API-Layer trennt intern/extern
- ✅ **Kostenoptimiert** Keine Odoo.sh Lizenzen nötig
- ✅ **Langfristig erweiterbar** Modularer Aufbau
- ✅ **Produktiv im Einsatz** Migration erfolgreich abgeschlossen
**DokuWiki-Integration ist der nächste logische Schritt** für bessere Equipment-Dokumentation.
Die API ist **zentrales Zukunftsmodul** für die WordPress-Integration.
**Letztes Update: 13.12.2025**
**Letztes Update: 08.12.2025**

415
README.md
View File

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

View File

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

View File

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

View File

@ -1,34 +1,84 @@
# -*- 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=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
}
def _get_report_values(self, docids, data):
return _prepare_data(self.env, data)

View File

@ -16,16 +16,6 @@ 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:
@ -38,62 +28,9 @@ 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
}
# 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})
return self.env.ref(xml_id).report_action(None, data=data)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +1,20 @@
{
'name': 'Open Workshop Base',
'license': 'AGPL-3',
'version': '18.0.1.0.5', # Migration läuft bei 18.0.1.0.4
'version': '18.0.1.0.4', # Migration läuft bei 18.0.1.0.4
'summary': 'Kern-Modul für Maschinenfreigaben - vereinfachte Equipment-Integration',
'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance', 'maintenance_equipment_status'],
'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance'],
'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,

View File

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

View File

@ -99,55 +99,7 @@ def migrate(cr, version):
except Exception as e:
_logger.error(f"Failed to migrate machine {m_id}: {e}")
# 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
# 3. Validierung
cr.execute("SELECT COUNT(*) FROM ows_machine WHERE equipment_id IS NULL")
remaining = cr.fetchone()[0]
@ -157,4 +109,3 @@ def migrate(cr, version):
_logger.info(f"✅ Successfully migrated {migrated_count} machines to equipment")
_logger.info("Post-migration completed")

View File

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

View File

@ -194,7 +194,7 @@ class ResPartner(models.Model):
<tbody>
"""
machines = self.env['ows.machine'].search([('area_id', '=', area.id), ('category', '=', 'red')], order="name")
machines = self.env['ows.machine'].search([('area_id', '=', area.id)], order="name")
for machine in machines:
access = self.env['ows.machine.access'].search([
@ -400,128 +400,6 @@ 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'
@ -635,33 +513,43 @@ class OwsMachine(models.Model):
# Keine eigenen SQL Constraints - Equipment hat bereits unique constraint für serial_no
@api.model_create_multi
def create(self, vals_list):
@api.model
def create(self, vals):
"""
Beim Erstellen einer ows.machine:
1. Equipment nur erstellen wenn equipment_id NICHT angegeben wurde
1. Equipment IMMER automatisch erstellen
2. Area Location synchronisieren
3. serial_no und name vom User übernehmen
"""
# 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
# Equipment IMMER automatisch erstellen
equipment_vals = {
'name': vals.get('name', 'Neue Maschine'),
'serial_no': vals.get('serial_no', False),
}
return super(OwsMachine, self).create(vals_list)
# 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)
def write(self, vals):
"""
Bei Updates:
1. Name/Serial_no Equipment synchronisieren
1. Area Location synchronisieren
2. Name/Serial_no Equipment synchronisieren
"""
# Area → Location Mapping
if 'area_id' in vals and vals['area_id']:
area = self.env['ows.machine.area'].browse(vals['area_id'])
if area and area.name:
vals['location'] = area.name
# Name/Serial_no an Equipment weiterleiten
equipment_vals = {}
if 'name' in vals:
@ -724,7 +612,7 @@ class OwsMachine(models.Model):
_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'), ('equipment_id.status_id.name', '!=', 'Ausgemustert')], order="name")
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([
@ -732,7 +620,6 @@ class OwsMachine(models.Model):
('machine_id', '=', machine.id),
], limit=1))
machine_list.append({
'id': machine.id,
'name': machine.name,
'has_access': has_access,
})
@ -779,14 +666,6 @@ 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'
@ -794,11 +673,3 @@ 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

View File

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

View File

@ -7,6 +7,9 @@
<field name="view_mode">list,form</field>
</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>

View File

@ -1,5 +1,5 @@
<odoo>
<!-- Tree View: Nutzungsprodukte (normale Ansicht) -->
<!-- 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>
@ -10,20 +10,8 @@
</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 (normale Ansicht) -->
<!-- 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>
@ -34,18 +22,6 @@
</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">

View File

@ -6,27 +6,20 @@
<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 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')]"/>
<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')]"/>
<separator/>
<filter string="Aktiv" name="filter_active" domain="[('active', '=', True)]"/>
<filter string="Archiviert" name="filter_inactive" domain="[('active', '=', False)]"/>
<separator/>
<group expand="1" string="Gruppieren nach">
<group expand="0" string="Gruppieren nach">
<filter string="Bereich" name="group_area" context="{'group_by': 'area_id'}"/>
<filter string="Kategorie" name="group_category" context="{'group_by': 'category'}"/>
</group>
</search>
</field>
@ -40,7 +33,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"/>

View File

@ -1,80 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- 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>
<!-- 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>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
<field name="arch" type="xml">
<!-- 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 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>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,54 +0,0 @@
# -*- coding: utf-8 -*-
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
@api.model
def _register_hook(self):
"""
Hook der bei jedem Modul-Load (auch bei Update) ausgeführt wird.
Initialisiert fehlende DokuWiki-Parameter.
"""
super()._register_hook()
self._init_dokuwiki_parameters()
@api.model
def _init_dokuwiki_parameters(self):
"""
Initialisiert DokuWiki-Parameter beim Modul-Install/-Update falls sie nicht existieren.
Wird automatisch beim Laden des Moduls aufgerufen.
"""
IrConfigParameter = self.env['ir.config_parameter'].sudo()
# Default-Parameter
defaults = {
'dokuwiki.url': 'https://wiki.hobbyhimmel.de',
'dokuwiki.user': 'odoo.odoo',
'dokuwiki.password': 'CHANGE_ME',
'dokuwiki.equipment_namespace': 'werkstatt:ausstattung', # NEU: Basis-Namespace für Equipment
'dokuwiki.central_documentation_namespace': 'werkstatt:ausstattung:odoo-status', # NEU: Namespace für Odoo-generierte Status-Seiten
'dokuwiki.overview_page_id': 'werkstatt:ausstattung:uebersicht', # Seiten-ID für Übersichtstabelle
'dokuwiki.overview_title': 'Geräte & Maschinen - Übersicht',
'dokuwiki.overview_columns': 'Name|Zustand|Sicherheit|Bereich|Standort|Bild',
'dokuwiki.overview_column_data': '[[{wiki_page_id}|{name}]]|{status_smiley}|{ows_machine_id.category_icon}|{ows_area}|{location}|{image}',
'dokuwiki.auto_update_overview_table': 'False', # Automatische Übersichtstabellen-Aktualisierung bei Equipment-Änderungen
}
# Nur fehlende Parameter anlegen
count_created = 0
count_existing = 0
for key, value in defaults.items():
if not IrConfigParameter.get_param(key):
IrConfigParameter.set_param(key, value)
count_created += 1
_logger.info(f"DokuWiki parameter '{key}' initialized with default value")
else:
count_existing += 1
if count_created > 0:
_logger.info(f"DokuWiki parameters: {count_created} created, {count_existing} already existing")

View File

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

View File

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

View File

@ -1,120 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Equipment Tree View Extension - Wiki Link -->
<record id="maintenance_equipment_view_tree_dokuwiki" model="ir.ui.view">
<field name="name">maintenance.equipment.tree.dokuwiki</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree"/>
<field name="arch" type="xml">
<!-- Wiki Doku-Link nach dem Namen -->
<field name="name" position="after">
<field name="wiki_doku_link" widget="html" string="Wiki Doku" optional="show"/>
<field name="wiki_status_link" widget="html" string="Wiki Status" optional="hide"/>
</field>
</field>
</record>
<!-- Equipment Form View Extension -->
<record id="maintenance_equipment_view_form_dokuwiki" model="ir.ui.view">
<field name="name">maintenance.equipment.form.dokuwiki</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
<field name="arch" type="xml">
<!-- Bild-Feld oben rechts neben den Smart Buttons -->
<xpath expr="//sheet/div[@name='button_box']" position="before">
<field name="image_1920" widget="image" class="oe_avatar" options="{'preview_image': 'image_1920'}"/>
</xpath>
<!-- Smart Button im Header -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_open_wiki"
type="object"
class="oe_stat_button"
icon="fa-book"
invisible="not wiki_page_url">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Wiki</span>
<span class="o_stat_text text-success" invisible="not wiki_synced">Synchronisiert</span>
<span class="o_stat_text text-warning" invisible="wiki_synced">Nicht synchronisiert</span>
</div>
</button>
</xpath>
<!-- Wiki-Felder in neuem Tab -->
<xpath expr="//notebook" position="inside">
<page string="Wiki" name="wiki">
<group>
<group string="Wiki Dokumentations-Seite (Benutzer-Doku)" colspan="2">
<field name="wiki_doku_id" string="Seitenname"/>
<field name="wiki_page_url" widget="url" string="🔗 Zur Doku-Seite" class="oe_link"/>
</group>
</group>
<group>
<group string="Wiki Status-Seite (Odoo-Daten, automatisch)">
<field name="wiki_status_id" string="Seitenname" readonly="1"/>
</group>
<group string="Synchronisations-Status">
<field name="wiki_synced" readonly="1"/>
<field name="wiki_last_sync" readonly="1"/>
<field name="wiki_auto_sync"/>
</group>
</group>
<group>
<group string="Aktionen">
<button name="action_sync_to_dokuwiki"
string="Zum Wiki synchronisieren"
type="object"
class="btn-primary"
icon="fa-refresh"/>
<button name="action_open_wiki"
string="Wiki im Browser öffnen"
type="object"
class="btn-secondary"
icon="fa-external-link"
invisible="not wiki_page_url"/>
</group>
</group>
<group string="Hilfe">
<div class="alert alert-info" role="alert">
<h4 class="alert-heading">📚 DokuWiki-Integration</h4>
<p>
Dieses Equipment ist mit DokuWiki verbunden. Die Synchronisation erstellt <strong>zwei unterschiedliche Seiten</strong>:
</p>
<hr/>
<h5>📊 Wiki Status Seite (automatisch, nur lesbar)</h5>
<ul>
<li><strong>Zweck:</strong> Enthält aktuelle Odoo-Daten (Status, Standort, Hersteller etc.)</li>
<li><strong>Format:</strong> werkstatt:ausstattung:odoo-status:[name-seriennummer]</li>
<li><strong>Eindeutigkeit:</strong> Jedes Equipment hat eine eigene Status-Seite</li>
<li><strong>Update:</strong> Wird bei jeder Synchronisation automatisch aktualisiert</li>
</ul>
<h5>📖 Wiki Dokumentations Seite (manuell, editierbar)</h5>
<ul>
<li><strong>Zweck:</strong> Benutzer-Dokumentation, Anleitungen, Tipps</li>
<li><strong>Format:</strong> werkstatt:ausstattung:[bereich]:[doku-name]</li>
<li><strong>Besonderheit:</strong> Kann von mehreren Equipment geteilt werden (z.B. "akkuschrauber")</li>
<li><strong>Erstellung:</strong> Wird NICHT von Odoo erstellt - Benutzer erstellen diese manuell</li>
</ul>
<hr/>
<p class="mb-0">
<strong>💡 Tipp:</strong> Die Status-Seite kann mit dem include-Plugin in die Dokumentations-Seite eingebunden werden:<br/>
<code>{{page&gt;werkstatt:ausstattung:odoo-status:[name-seriennummer]}}</code>
</p>
<hr/>
<p class="mb-0">
<strong>⚙️ Automatische Synchronisation:</strong> Wenn aktiviert, werden Änderungen automatisch zur Status-Seite übertragen.
</p>
</div>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'Open Workshop POS',
'version': '18.0.1.0.1',
'version': '18.0.1.0.0',
'category': 'Point of Sale',
'summary': 'POS Integration für Open Workshop - Machine Access & Customer UI',
'description': """
@ -27,7 +27,6 @@ Autor: HobbyHimmel
'depends': [
'open_workshop_base',
'point_of_sale',
'l10n_de', # Kontenplan für Deutschland (erstellt Bankjournal für POS Demo-Daten)
],
'data': [
# Views
@ -39,24 +38,12 @@ Autor: HobbyHimmel
'open_workshop_pos/static/src/js/ows_pos_customer_sidebar.js',
'open_workshop_pos/static/src/js/ows_machine_access_list.js',
'open_workshop_pos/static/src/js/ows_product_screen_template_patch.js',
'open_workshop_pos/static/src/js/ows_product_screen_default_category.js',
'open_workshop_pos/static/src/js/ows_order_patch.js',
'open_workshop_pos/static/src/js/ows_receipt_header_patch.js',
'open_workshop_pos/static/src/js/ows_control_buttons_patch.js',
'open_workshop_pos/static/src/js/ows_product_card_patch.js',
'open_workshop_pos/static/src/js/ows_partner_search_mode_patch.js',
# XML Templates
'open_workshop_pos/static/src/xml/ows_pos_sidebar.xml',
'open_workshop_pos/static/src/xml/ows_pos_customer_sidebar.xml',
'open_workshop_pos/static/src/xml/ows_machine_access_list.xml',
'open_workshop_pos/static/src/xml/ows_product_screen_template_patch.xml',
'open_workshop_pos/static/src/xml/ows_product_card_patch.xml',
'open_workshop_pos/static/src/xml/ows_receipt_header_patch.xml',
'open_workshop_pos/static/src/xml/ows_voucher_codes_patch.xml',
'open_workshop_pos/static/src/xml/ows_control_buttons_patch.xml',
'open_workshop_pos/static/src/xml/ows_payment_screen_patch.xml',
'open_workshop_pos/static/src/xml/ows_partner_list_patch.xml',
# CSS
'open_workshop_pos/static/src/css/pos.css',
@ -65,5 +52,4 @@ Autor: HobbyHimmel
'installable': True,
'application': False,
'auto_install': False,
'post_init_hook': 'post_init_hook',
}

View File

@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
import logging
_logger = logging.getLogger(__name__)
def post_init_hook(env):
"""
Workaround für Odoo 18 pos_sms Bug:
- pos_sms hat auto_install=True in älteren Odoo 18 Versionen
- Das Modul definiert ein Feld pos_sms_receipt_template_id in res.config.settings
- Wenn pos_sms nicht installiert ist, aber die View geladen wird, gibt es einen Fehler
Dieser Hook:
1. Deaktiviert auto_install für pos_sms falls vorhanden
2. Löscht verwaiste Views von pos_sms
"""
_logger.info("🔧 Open Workshop POS: Prüfe pos_sms Kompatibilität...")
# Prüfe ob pos_sms Modul existiert
pos_sms_module = env['ir.module.module'].search([('name', '=', 'pos_sms')], limit=1)
if not pos_sms_module:
_logger.info("✅ pos_sms Modul nicht gefunden - kein Patch nötig")
return
# Deaktiviere auto_install wenn das Modul nicht installiert ist
if pos_sms_module.state != 'installed':
_logger.info(f" pos_sms Status: {pos_sms_module.state}")
# Lösche verwaiste Views von pos_sms (falls vorhanden)
orphaned_views = env['ir.ui.view'].search([
('arch_db', 'ilike', 'pos_sms_receipt_template_id')
])
if orphaned_views:
_logger.warning(
f"🗑️ Lösche {len(orphaned_views)} verwaiste pos_sms View(s): "
f"{orphaned_views.mapped('name')}"
)
orphaned_views.sudo().unlink()
_logger.info("✅ Verwaiste Views erfolgreich gelöscht")
else:
_logger.info("✅ Keine verwaisten pos_sms Views gefunden")
else:
_logger.info("✅ pos_sms ist installiert - kein Patch nötig")

View File

@ -1,2 +1,2 @@
# -*- coding: utf-8 -*-
from . import pos_order
# POS-spezifische Modelle (falls benötigt)

View File

@ -55,9 +55,3 @@
overflow: hidden;
text-overflow: ellipsis;
}
.bg-200, .text-bg-200 {
--background-color: rgb(122, 122, 122);
background-color: var(--background-color) !important;
}

View File

@ -1,20 +0,0 @@
/** @odoo-module **/
import { ControlButtons } from "@point_of_sale/app/screens/product_screen/control_buttons/control_buttons";
import { patch } from "@web/core/utils/patch";
patch(ControlButtons.prototype, {
/**
* Override to hide internal note button
*/
get showInternalNote() {
return false;
},
/**
* Override to show pricelist button always (not just in modal)
*/
get showPricelistButton() {
return !this.props.showRemainingButtons && !this.ui.isSmall;
},
});

View File

@ -1,36 +0,0 @@
/** @odoo-module **/
import { PosOrder } from "@point_of_sale/app/models/pos_order";
import { patch } from "@web/core/utils/patch";
// Patch the PosOrder to collect voucher codes from orderlines
patch(PosOrder.prototype, {
export_for_printing(baseUrl, headerData) {
const result = super.export_for_printing(baseUrl, headerData);
// Add partner name to headerData if partner exists
const partner = this.get_partner();
if (partner && result.headerData) {
result.headerData.partnerName = partner.name;
}
// Collect all voucher codes from orderlines
const voucherCodes = [];
for (const line of this.lines) {
if (line.voucher_code) {
voucherCodes.push({
code: line.voucher_code,
name: line.product_id?.display_name || '',
value: line.get_price_with_tax()
});
}
}
// Add voucher codes to result if any exist
if (voucherCodes.length > 0) {
result.voucherCodes = voucherCodes;
}
return result;
}
});

View File

@ -1,75 +0,0 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { PartnerList } from "@point_of_sale/app/screens/partner_list/partner_list";
import { unaccent } from "@web/core/utils/strings";
patch(PartnerList.prototype, {
setup() {
super.setup(...arguments);
this.state.searchMode = this.state.searchMode || "all";
},
setSearchMode(mode) {
if (this.state.searchMode === mode) {
return;
}
this.state.searchMode = mode;
this.state.currentOffset = 0;
this.state.previousQuery = "";
},
getPartners() {
if (this.state.searchMode !== "name") {
return super.getPartners();
}
const searchWord = unaccent((this.state.query || "").trim(), false).toLowerCase();
const partners = this.pos.models["res.partner"].getAll();
if (!searchWord) {
return partners
.slice(0, 1000)
.toSorted((a, b) =>
this.props.partner?.id === a.id
? -1
: this.props.partner?.id === b.id
? 1
: (a.name || "").localeCompare(b.name || "")
);
}
const exactMatches = partners.filter((partner) => {
const name = unaccent(partner.name || "", false).toLowerCase();
return name === searchWord;
});
if (exactMatches.length > 0) {
return exactMatches;
}
return partners.filter((partner) => {
const name = unaccent(partner.name || "", false).toLowerCase();
return name.includes(searchWord);
});
},
async getNewPartners() {
if (this.state.searchMode !== "name") {
return super.getNewPartners();
}
let domain = [];
const limit = 30;
if (this.state.query) {
domain = [["name", "ilike", this.state.query + "%"]];
}
const result = await this.pos.data.searchRead("res.partner", domain, [], {
limit: limit,
offset: this.state.currentOffset,
});
return result;
},
});

View File

@ -35,17 +35,7 @@ export class OwsPosCustomerSidebar extends Component {
}
getDate(order) {
// Odoo sendet date_order als UTC-String, der in lokale Zeit konvertiert werden muss
// Wenn der String kein 'Z' oder Timezone-Info hat, wird er als UTC behandelt
let dateStr = order.date_order;
// Falls der String im Format "YYYY-MM-DD HH:MM:SS" ohne Timezone ist,
// wird er als UTC interpretiert (Odoo-Standard)
if (dateStr && !dateStr.includes('T') && !dateStr.includes('Z')) {
dateStr = dateStr.replace(' ', 'T') + 'Z';
}
const date = new Date(dateStr);
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');

View File

@ -1,36 +0,0 @@
/** @odoo-module **/
import { ProductCard } from "@point_of_sale/app/generic_components/product_card/product_card";
import { patch } from "@web/core/utils/patch";
patch(ProductCard.prototype, {
/**
* Gibt den formatierten Preis des Produkts zurück, basierend auf der aktuellen Preisliste.
* Dieser Getter wird automatisch neu berechnet, wenn sich die Preisliste ändert.
*/
get productPrice() {
if (!this.props.product) {
return "";
}
// Hole die aktuelle Bestellung, um auf deren Preisliste zuzugreifen
const order = this.env.services.pos.get_order();
if (!order) {
return this.env.services.pos.getProductPriceFormatted(this.props.product);
}
// Zugriff auf die Preisliste der Bestellung, damit Owl Änderungen tracken kann
const pricelist = order.pricelist_id;
// Berechne den Preis mit der Preisliste
const price = this.props.product.get_price(pricelist, 1);
const formattedPrice = this.env.utils.formatCurrency(price);
// Füge Maßeinheit hinzu, wenn das Produkt nach Gewicht verkauft wird
if (this.props.product.to_weight) {
return `${formattedPrice}/${this.props.product.uom_id.name}`;
}
return formattedPrice;
}
});

View File

@ -1,40 +0,0 @@
// ows_product_screen_default_category.js
// @odoo-module
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
import { patch } from "@web/core/utils/patch";
import { onMounted } from "@odoo/owl";
// Konfiguration: Tragen Sie hier den Namen oder die ID der Standardkategorie ein
const DEFAULT_CATEGORY_NAME = "Nutzung"; // Name der Kategorie
// oder verwenden Sie eine ID:
// const DEFAULT_CATEGORY_ID = 1; // ID der Kategorie
patch(ProductScreen.prototype, {
setup() {
super.setup();
onMounted(() => {
// Setze die Standardkategorie beim Laden des Screens
this.setDefaultCategory();
});
},
setDefaultCategory() {
// Suche die Kategorie nach Name oder ID
let category = null;
if (typeof DEFAULT_CATEGORY_NAME !== 'undefined' && DEFAULT_CATEGORY_NAME) {
// Suche nach Name
const categories = this.pos.models["pos.category"].getAll();
category = categories.find(cat => cat.name === DEFAULT_CATEGORY_NAME);
} else if (typeof DEFAULT_CATEGORY_ID !== 'undefined' && DEFAULT_CATEGORY_ID) {
// Suche nach ID
category = this.pos.models["pos.category"].get(DEFAULT_CATEGORY_ID);
}
if (category) {
this.pos.setSelectedCategory(category.id);
}
},
});

View File

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

View File

@ -1,5 +0,0 @@
/** @odoo-module */
// This file is now deprecated - order patching moved to ows_order_patch.js
// Keeping for backwards compatibility

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.ControlButtons" t-inherit="point_of_sale.ControlButtons" t-inherit-mode="extension">
<!-- Hide the internal note button completely -->
<xpath expr="//t[@t-if='!props.showRemainingButtons || (ui.isSmall and props.showRemainingButtons)']" position="attributes">
<attribute name="t-if">false</attribute>
</xpath>
<!-- Add pricelist button directly visible (not just in Actions dialog) -->
<xpath expr="//SelectPartnerButton[@partner='partner']" position="after">
<button t-if="!props.showRemainingButtons and !ui.isSmall"
class="o_pricelist_button btn btn-light btn-lg lh-lg"
t-on-click="() => this.clickPricelist()">
<i class="fa fa-th-list me-1" role="img" aria-label="Price list" title="Price list" />
<t t-if="currentOrder?.pricelist_id" t-esc="currentOrder.pricelist_id.display_name" />
<t t-else="">Pricelist</t>
</button>
</xpath>
</t>
</templates>

View File

@ -59,7 +59,7 @@
<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.id">
<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 ? '✅' : '❌'" />

View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.PartnerList" t-inherit="point_of_sale.PartnerList" t-inherit-mode="extension">
<xpath expr="//t[@t-set-slot='header']" position="inside">
<div class="btn-group ms-2" role="group" aria-label="Search mode">
<button
class="btn btn-outline-secondary"
t-att-class="{ active: state.searchMode === 'name' }"
t-on-click="() => this.setSearchMode('name')">
Name
</button>
<button
class="btn btn-outline-secondary"
t-att-class="{ active: state.searchMode === 'all' }"
t-on-click="() => this.setSearchMode('all')">
Alle Felder
</button>
</div>
</xpath>
</t>
</templates>

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.PaymentScreenButtons" t-inherit="point_of_sale.PaymentScreenButtons" t-inherit-mode="extension">
<!-- Remove the Invoice button completely -->
<xpath expr="//button[contains(@class, 'js_invoice')]" position="replace">
<!-- Invoice button removed -->
</xpath>
</t>
</templates>

View File

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Erweitere ProductCard um den Preis anzuzeigen -->
<t t-name="point_of_sale.ProductCard" t-inherit="point_of_sale.ProductCard" t-inherit-mode="extension">
<!-- Ändere das Layout zu flex-column mit justify-content-end für Ausrichtung am unteren Rand -->
<xpath expr="//div[hasclass('product-content')]" position="attributes">
<attribute name="class">product-content d-flex flex-column px-2 justify-content-end rounded-bottom rounded-3 flex-shrink-1</attribute>
</xpath>
<!-- Entferne die Margins vom Produktnamen für kompakteres Layout -->
<xpath expr="//div[hasclass('product-name')]" position="attributes">
<attribute name="class">overflow-hidden lh-sm product-name mb-1</attribute>
</xpath>
<!-- Füge den Preis nach dem Produktnamen hinzu (preislistenabhängig) -->
<xpath expr="//div[hasclass('product-name')]" position="after">
<div t-if="props.product" class="product-price text-primary fw-bold fs-6 mb-2">
<t t-esc="productPrice"/>
</div>
</xpath>
<!-- Entferne die Warenkorb-Mengenanzeige für ruhigere Optik -->
<xpath expr="//h1[hasclass('product-cart-qty')]" position="replace"/>
</t>
</templates>

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Add partner name to tracking number in receipt header -->
<t t-name="point_of_sale.ReceiptHeader" t-inherit="point_of_sale.ReceiptHeader" t-inherit-mode="extension">
<!-- Add partner name above the big tracking number -->
<xpath expr="//h1[hasclass('tracking-number')][hasclass('text-center')]" position="before">
<div t-if="props.data.partnerName"
class="partner-name text-center fw-bold"
style="font-size: 48px; margin-bottom: 10px;">
<t t-esc="props.data.partnerName" />
</div>
</xpath>
</t>
</templates>

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