Compare commits
86 Commits
18.0-Multi
...
18.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 90e52b269a | |||
| 19bca86b3d | |||
| 61213b2c0c | |||
| f2b5710178 | |||
| f834d70859 | |||
| 9b9b8d025e | |||
| d0b7a5737d | |||
| 8a414ed3ac | |||
| 5a27dc6a65 | |||
| c55b0e59d2 | |||
| 989720ed21 | |||
| 6676433d46 | |||
| c1df940daf | |||
| 92f9548d34 | |||
| b46fed0f8e | |||
| 90e3422e8b | |||
| f1b0c50fbf | |||
| 7f827cf7eb | |||
| dfb77411d9 | |||
| 59539e0201 | |||
| b6a0f0462d | |||
| 5fcaef0336 | |||
| bb3e492d30 | |||
| aeb8e5660b | |||
| 4c03959437 | |||
| 75d91984d1 | |||
| 77cd3f4595 | |||
| 4684d33241 | |||
| 85a90f90fd | |||
| 0d27ca36b0 | |||
| b85ec97d9c | |||
| f9cbcffe34 | |||
| fe09d1439f | |||
| 23b9867eec | |||
| 00df958985 | |||
| 98c96018d6 | |||
| b2f7a5edbf | |||
| d6c2986ebf | |||
| 67184e7e01 | |||
| ee0e6c8b98 | |||
| 1c9829a7aa | |||
| 1abeb97afa | |||
| daddb51327 | |||
| 5d1faa7b5b | |||
| 21b0d6305f | |||
| 988421a0c5 | |||
| b26e70dbe9 | |||
| 38d0becfad | |||
| 943d48af58 | |||
| 09f28c8070 | |||
| 40a5fe8b0f | |||
| 31a40e95d7 | |||
| 91209dead4 | |||
| 9cb6dc8ac5 | |||
| 93385abb0f | |||
| e443f8709d | |||
| b151894c39 | |||
| 0c4b58e0a6 | |||
| e460acddf3 | |||
| a1c6903893 | |||
| 72a46e3657 | |||
| 641bfb3ade | |||
| a73c0cb299 | |||
| 4626379d6e | |||
| 9be8f320f7 | |||
| d6a98cfbd0 | |||
| 0135035a54 | |||
| f79e126c8c | |||
| 253d289633 | |||
| de317f46e6 | |||
| 8ae586cca6 | |||
| e18f2880a4 | |||
| 43cf5754fd | |||
| 4c048b5ca9 | |||
| 686b201792 | |||
| bd235e3ca0 | |||
| a5f8fd32c3 | |||
| 3fa050f153 | |||
| e53c4028c9 | |||
| 5057985024 | |||
| 00f33faebe | |||
| 2eff81ce54 | |||
| 92490aeb9c | |||
| 1b203eeb10 | |||
| ed339b0e2e | |||
| e1feeb6d75 |
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
|||
# Test Logs
|
||||
test_*.log
|
||||
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
|
@ -15,7 +19,7 @@ dist/
|
|||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
#lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
|
@ -159,3 +163,8 @@ cython_debug/
|
|||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# DokuWiki API Test Script (enthält Credentials)
|
||||
test_dokuwiki.py
|
||||
|
||||
# Python Virtual Environment
|
||||
venv/
|
||||
|
|
|
|||
46
Checkliste.md
Normal file
46
Checkliste.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# ✅ OpenWorkshop Test Checkliste
|
||||
|
||||
## 🔹 1. Migration
|
||||
- [x] `migrate_existing_partners()` erzeugt zu jedem Partner genau einen `ows.user`.
|
||||
- [x] `migrate_existing_partners()` übernimmt korrekt alte `vvow_*`-Felder.
|
||||
- [x] `migrate_machine_access_from_old_fields()` erstellt korrekte Einträge in `ows.machine.access`.
|
||||
- [x] `migrate_machine_access_from_old_fields()` übernimmt das Änderungsdatum aus `mail.tracking.value`.
|
||||
|
||||
## 🔹 2. Kontakte Backend
|
||||
- [x] Beim Anlegen eines neuen Partners wird automatisch ein `ows.user` angelegt.
|
||||
- [ ] Beim Speichern eines Partners wird überprüft, ob ein `ows.user` existiert, und ggf. angelegt.
|
||||
- [x] Änderungen an Geburtstag, RFID, Haftung in Partner-Formular schreiben korrekt in `ows.user`.
|
||||
- [x] Die Werte aus `ows.user` werden korrekt im Partnerformular angezeigt (via `compute`).
|
||||
- [x] Das HTML-Widget mit Maschinenfreigaben (`machine_access_html`) wird korrekt dargestellt.
|
||||
|
||||
## 🔹 3. POS-Integration
|
||||
- [x] Felder aus `ows.user` (Geburtstag, RFID etc.) erscheinen im POS-Kunden-Popup.
|
||||
- [x] Maschinenfreigaben erscheinen im POS-Layout korrekt gruppiert nach Bereichen.
|
||||
- [x] Farben der Maschinenbereiche werden korrekt aus `color_hex` übernommen.
|
||||
|
||||
## 🔹 4. Maschinenverwaltung
|
||||
- [x] Maschinen-Formular zeigt Nutzungs- und Einweisungsprodukte korrekt an.
|
||||
- [?] Drop-downs in den Produktlisten zeigen nur Produkte der richtigen Kategorie.
|
||||
- [?] Neue Zuordnungen können direkt in den Tree-Ansichten editiert werden.
|
||||
- [?] Filter greifen korrekt (Maschinennutzung / Einweisungen).
|
||||
- [ ] In der Ansicht Wartung - Ausrüstung - Tab Offene Werkstatt (hobbyhimmel) wird unter Nutzungsprodukte und Einweisungsprodukte nur die Auswahl des Produkts angezeigt, eine Zuordnung zu einer Maschine ist automatisch die aktelle ausgewählte Maschine des Formulars.
|
||||
|
||||
## 🔹 5. Menüstruktur
|
||||
- [ ] Menüeinträge "Nutzungsprodukte" und "Einweisungsprodukte" erscheinen unter Wartung > Konfiguration > Zuordnungen.
|
||||
|
||||
|
||||
## 🔹 6. CSV/XML Demo-/Initialdaten
|
||||
- [x] Maschinenbereiche (`ows.machine.area`) sind korrekt aus `data.xml` geladen.
|
||||
- [x] Maschinen und ihre Produkt-Zuordnungen sind vollständig.
|
||||
- [x] Kategorien und Produkte sind korrekt verknüpft (`product.category`, `product.product`).
|
||||
|
||||
## 🔹 7. Systemweite Konsistenz
|
||||
- [x] Es gibt keine doppelten `ows.user`-Einträge.
|
||||
- [bedingt] Kein Partner existiert ohne zugehörigen `ows.user`.
|
||||
- [bedingt] `res.partner.ows_user_id` ist immer gefüllt.
|
||||
|
||||
## 🔹 8. Technische Qualität
|
||||
- [ ] Kein `@api.depends('id')` mehr vorhanden.
|
||||
- [ ] Commit wird in Migrationsfunktionen korrekt gesetzt (`self.env.cr.commit()`).
|
||||
- [ ] Keine toten `vvow_*` Felder mehr im Modell (wenn auf ows.user umgestellt).
|
||||
- [ ] post-init und pre-load Skripte laufen fehlerfrei bei Neuinstallation.
|
||||
323
FEATURE_REQUEST/open_workshop_IoT.md
Normal file
323
FEATURE_REQUEST/open_workshop_IoT.md
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
# Projektplan: MQTT-basierte IoT-Events in Odoo 18 Community (Simulation-first)
|
||||
|
||||
Stand: 2026-01-10
|
||||
Ziel: **Odoo-18-Community (self-hosted)** soll **Geräte-Events** (Timer/Maschinenlaufzeit, Waage, Zustandswechsel) über **MQTT** aufnehmen und in Odoo als **saubere, nachvollziehbare Sessions/Events** verarbeiten.
|
||||
Wichtig: **Hardware wird zunächst vollständig simuliert** (Software-Simulatoren), damit die Odoo-Schnittstelle stabil steht, bevor echte Geräte angebunden werden.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ziele und Nicht-Ziele
|
||||
|
||||
### 1.1 Ziele (MVP)
|
||||
- Einheitliche **Device-Event-Schnittstelle** in Odoo (REST/Webhook) inkl. Authentifizierung
|
||||
- **Event-Log** in Odoo (persistente Rohereignisse + Normalisierung)
|
||||
- **Session-Logik** für Timer/Maschinenlaufzeit (Start/Stop/Heartbeat)
|
||||
- **Simulation** von:
|
||||
- Maschinenzustand (running/idle/fault)
|
||||
- Timer-Events (run_start/run_stop)
|
||||
- Waage (stable_weight/tare)
|
||||
- Reproduzierbare Tests (Unit/Integration) und eindeutige Fehlerdiagnostik (Logging)
|
||||
|
||||
### 1.2 Nicht-Ziele (für Phase 1)
|
||||
- Keine Enterprise-IoT-Box, keine Enterprise-Module
|
||||
- Keine echte Hardware-Treiberentwicklung in Phase 1
|
||||
- Kein POS-Frontend-Live-Widget (optional erst in späterer Phase)
|
||||
- Keine Abrechnungslogik/Preisregeln (kann vorbereitet, aber nicht umgesetzt werden)
|
||||
|
||||
---
|
||||
|
||||
## 2. Zielarchitektur (Simulation-first)
|
||||
|
||||
### 2.1 Komponenten
|
||||
1. **MQTT Broker** (z. B. Mosquitto in Docker)
|
||||
2. **Device Simulator(s)** (Python/Node)
|
||||
- veröffentlicht MQTT Topics wie echte Geräte
|
||||
3. **Gateway/Bridge (Software)**
|
||||
- abonniert MQTT Topics
|
||||
- validiert/normalisiert Payload
|
||||
- sendet Events via HTTPS an Odoo (Webhook)
|
||||
4. **Odoo Modul** `ows_iot_bridge`
|
||||
- REST Controller `/ows/iot/event`
|
||||
- Modelle für Devices, Events, Sessions
|
||||
- Business-Regeln für Session-Start/Stop/Hysterese (softwareseitig)
|
||||
5. Optional später: **Realtime-Feed** (WebSocket) für POS/Display
|
||||
|
||||
### 2.2 Datenfluss
|
||||
1. Simulator publiziert MQTT → `hobbyhimmel/machines/<machine_id>/state`
|
||||
2. Bridge konsumiert MQTT, mappt auf Event-Format
|
||||
3. Bridge POSTet JSON an Odoo Endpoint
|
||||
4. Odoo speichert Roh-Event + erzeugt ggf. Session-Updates
|
||||
|
||||
---
|
||||
|
||||
## 3. Schnittstelle zu Odoo (Kern des Projekts)
|
||||
|
||||
### 3.1 Authentifizierung
|
||||
- Pro Device oder pro Bridge ein **API-Token** (Bearer)
|
||||
- Header: `Authorization: Bearer <token>`
|
||||
- Token in Odoo in `ows.iot.device` oder `ir.config_parameter` hinterlegt
|
||||
- Optional: IP-Allowlist / Rate-Limit (in Reverse Proxy)
|
||||
|
||||
### 3.2 Endpoint (REST/Webhook)
|
||||
- `POST /ows/iot/event`
|
||||
- Content-Type: `application/json`
|
||||
- Antwort:
|
||||
- `200 OK` mit `{ "status": "ok", "event_id": "...", "session_id": "..." }`
|
||||
- Fehler: `400` (Schema), `401/403` (Auth), `409` (Duplikat), `500` (Server)
|
||||
|
||||
### 3.3 Idempotenz / Duplikaterkennung
|
||||
- Jedes Event enthält eine eindeutige `event_uid`
|
||||
- Odoo legt Unique-Constraint auf `event_uid` (pro device)
|
||||
- Wiederholte Events liefern `409 Conflict` oder `200 OK (duplicate=true)` (Designentscheidung)
|
||||
|
||||
---
|
||||
|
||||
## 4. Ereignis- und Topic-Standard (Versioniert)
|
||||
|
||||
### 4.1 MQTT Topics (v1)
|
||||
- Maschinenzustand:
|
||||
`hobbyhimmel/machines/<machine_id>/state`
|
||||
- Maschinenereignisse:
|
||||
`hobbyhimmel/machines/<machine_id>/event`
|
||||
- Waage:
|
||||
`hobbyhimmel/scales/<scale_id>/event`
|
||||
- Geräte-Status (optional):
|
||||
`hobbyhimmel/devices/<device_id>/status`
|
||||
|
||||
### 4.2 Gemeinsames JSON Event Schema (v1)
|
||||
Pflichtfelder:
|
||||
- `schema_version`: `"v1"`
|
||||
- `event_uid`: UUID/string
|
||||
- `ts`: ISO-8601 UTC (z. B. `"2026-01-10T12:34:56Z"`)
|
||||
- `source`: `"simulator" | "device" | "gateway"`
|
||||
- `device_id`: string
|
||||
- `entity_type`: `"machine" | "scale" | "sensor"`
|
||||
- `entity_id`: string (z. B. machine_id)
|
||||
- `event_type`: string (siehe unten)
|
||||
- `payload`: object
|
||||
- `confidence`: `"high" | "medium" | "low"` (für Sensorfusion)
|
||||
|
||||
### 4.3 Event-Typen (v1)
|
||||
**Maschine/Timer**
|
||||
- `run_start` (Start einer Laufphase)
|
||||
- `run_stop` (Ende einer Laufphase)
|
||||
- `heartbeat` (optional, laufend während running)
|
||||
- `state_changed` (idle/running/fault)
|
||||
- `fault` (Fehler mit Code/Severity)
|
||||
|
||||
**Waage**
|
||||
- `stable_weight` (stabiler Messwert)
|
||||
- `weight` (laufend)
|
||||
- `tare`
|
||||
- `zero`
|
||||
- `error`
|
||||
|
||||
---
|
||||
|
||||
## 5. Odoo Datenmodell (Vorschlag)
|
||||
|
||||
### 5.1 `ows.iot.device`
|
||||
- `name`
|
||||
- `device_id` (unique)
|
||||
- `token_hash` (oder Token in separater Tabelle)
|
||||
- `device_type` (machine/scale/...)
|
||||
- `active`
|
||||
- `last_seen`
|
||||
- `notes`
|
||||
|
||||
### 5.2 `ows.iot.event`
|
||||
- `event_uid` (unique)
|
||||
- `device_id` (m2o -> device)
|
||||
- `entity_type`, `entity_id`
|
||||
- `event_type`
|
||||
- `timestamp`
|
||||
- `payload_json` (Text/JSON)
|
||||
- `confidence`
|
||||
- `processing_state` (new/processed/error)
|
||||
- `session_id` (m2o optional)
|
||||
|
||||
### 5.3 `ows.machine.session` (Timer-Sessions)
|
||||
- `machine_id` (Char oder m2o auf bestehendes Maschinenmodell)
|
||||
- `start_ts`, `stop_ts`
|
||||
- `duration_s` (computed)
|
||||
- `state` (running/stopped/aborted)
|
||||
- `origin` (sensor/manual/sim)
|
||||
- `confidence_summary`
|
||||
- `event_ids` (o2m)
|
||||
|
||||
> Hinweis: Wenn du bereits `ows.machine` aus deinem open_workshop nutzt, referenziert `machine_id` direkt dieses Modell.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verarbeitungslogik (Phase 1: minimal, robust)
|
||||
|
||||
### 6.1 Session-Automat (State Machine)
|
||||
- Eingang: Events `run_start`, `run_stop`, optional `heartbeat`
|
||||
- Regeln:
|
||||
- `run_start` erstellt neue Session, wenn keine läuft
|
||||
- `run_start` während laufender Session → nur Log, keine neue Session
|
||||
- `run_stop` schließt laufende Session
|
||||
- Timeout-Regel: wenn `heartbeat` ausbleibt (z. B. 10 min) → Session `aborted`
|
||||
|
||||
### 6.2 Hysterese (Simulation als Stellvertreter für Sensorik)
|
||||
In Simulation definierst du:
|
||||
- Start, wenn „running“ mindestens **2s stabil**
|
||||
- Stop, wenn „idle“ mindestens **15s stabil**
|
||||
Diese Parameter als Odoo Systemparameter, z. B.:
|
||||
- `ows_iot.start_debounce_s`
|
||||
- `ows_iot.stop_debounce_s`
|
||||
|
||||
---
|
||||
|
||||
## 7. Simulation (Software statt Hardware)
|
||||
|
||||
### 7.1 Device Simulator: Maschine
|
||||
- Konfigurierbar:
|
||||
- Muster: random, fixed schedule, manuell per CLI
|
||||
- Zustände: idle/running/fault
|
||||
- Optional: „power_w“ und „vibration“ als Felder im Payload
|
||||
- Publiziert MQTT in realistischen Intervallen
|
||||
|
||||
### 7.2 Device Simulator: Waage
|
||||
- Modi:
|
||||
- stream weight (mehrfach pro Sekunde)
|
||||
- stable_weight nur auf „stabil“
|
||||
- tare/zero Events per CLI
|
||||
|
||||
### 7.3 Bridge Simulator (MQTT → Odoo)
|
||||
- Abonniert alle relevanten Topics
|
||||
- Validiert Schema v1
|
||||
- POSTet Events an Odoo
|
||||
- Retry-Queue (lokal) bei Odoo-Ausfall
|
||||
- Metriken/Logs:
|
||||
- gesendete Events, Fehlerquoten, Latenz
|
||||
|
||||
---
|
||||
|
||||
## 8. Milestones & Deliverables
|
||||
|
||||
### M0 – Repo/Grundgerüst (0.5–1 Tag)
|
||||
**Deliverables**
|
||||
- Git-Repo Struktur
|
||||
- Docker Compose: mosquitto + simulator + bridge
|
||||
- Odoo Modul Skeleton `ows_iot_bridge`
|
||||
|
||||
### M1 – Odoo Endpoint & Modelle (1–2 Tage)
|
||||
**Deliverables**
|
||||
- `/ows/iot/event` Controller inkl. Token-Auth
|
||||
- Modelle `ows.iot.device`, `ows.iot.event`
|
||||
- Admin UI: Geräte anlegen, Token setzen, letzte Events anzeigen
|
||||
|
||||
### M2 – Session-Engine (1–2 Tage)
|
||||
**Deliverables**
|
||||
- Modell `ows.machine.session`
|
||||
- Event → Session Zuordnung
|
||||
- Timeout/Abbruch-Logik
|
||||
- Parameter für Debounce/Timeout
|
||||
|
||||
### M3 – Simulatoren & End-to-End Test (1–2 Tage)
|
||||
**Deliverables**
|
||||
- Machine Simulator + Scale Simulator
|
||||
- Bridge mit Retry-Queue
|
||||
- End-to-End: Simulator → MQTT → Bridge → Odoo → Session entsteht
|
||||
|
||||
### M4 – Qualität & Betrieb (1 Tag)
|
||||
**Deliverables**
|
||||
- Logging-Konzept (Odoo + Bridge)
|
||||
- Tests (Unit: Controller/Auth, Integration: Session Engine)
|
||||
- Doku: Event-Schema v1, Topics, Beispielpayloads
|
||||
|
||||
### Optional M5 – POS/Anzeige (später)
|
||||
- Realtime Anzeige im POS oder auf Display
|
||||
- Live-Weight in POS
|
||||
|
||||
---
|
||||
|
||||
## 9. Testplan (Simulation-first)
|
||||
|
||||
### 9.1 Unit Tests (Odoo)
|
||||
- Auth: gültiger Token → 200
|
||||
- ungültig/fehlend → 401/403
|
||||
- Schema-Validation → 400
|
||||
- Idempotenz: duplicate `event_uid` → 409 oder duplicate-flag
|
||||
|
||||
### 9.2 Integration Tests
|
||||
- Sequenz: start → heartbeat → stop → Session duration plausibel
|
||||
- stop ohne start → kein Crash, Event loggt Fehlerzustand
|
||||
- Timeout: start → keine heartbeat → Session aborted
|
||||
|
||||
### 9.3 Last-/Stabilitätstest (Simulator)
|
||||
- 20 Maschinen, je 1 Event/s, 1h Lauf
|
||||
- Ziel: Odoo bleibt stabil, Event-Insert performant, Queue läuft nicht über
|
||||
|
||||
---
|
||||
|
||||
## 10. Betriebs- und Sicherheitskonzept
|
||||
|
||||
- Token-Rotation möglich (neues Token, altes deaktivieren)
|
||||
- Reverse Proxy:
|
||||
- HTTPS
|
||||
- Rate limiting auf `/ows/iot/event`
|
||||
- optional Basic WAF Regeln
|
||||
- Payload-Größenlimit (z. B. 16–64 KB)
|
||||
- Bridge persistiert Retry-Queue auf Disk
|
||||
|
||||
---
|
||||
|
||||
## 11. Offene Entscheidungen (später, nicht blocker für MVP)
|
||||
|
||||
- Event-Consumer in Odoo vs. Bridge-only Webhook (empfohlen: Webhook)
|
||||
- Genaues Mapping zu bestehenden open_workshop Modellen (`ows.machine`, Nutzer/RFID)
|
||||
- Abrechnung: Preisregeln, Rundungen, POS-Integration
|
||||
- Realtime: Odoo Bus vs. eigener WebSocket Service
|
||||
|
||||
---
|
||||
|
||||
## 12. Nächste Schritte (konkret)
|
||||
|
||||
1. `ows_iot_bridge` Modul: Endpoint + Device/Event Modelle implementieren
|
||||
2. Docker Compose: mosquitto + bridge + simulator bereitstellen
|
||||
3. Simulatoren produzieren v1-Events; Bridge sendet an Odoo
|
||||
4. Session-Engine anschließen und End-to-End testen
|
||||
|
||||
---
|
||||
|
||||
## Anhang A: Beispiel-Event (Maschine run_start)
|
||||
```json
|
||||
{
|
||||
"schema_version": "v1",
|
||||
"event_uid": "c2a7d6f1-7d8d-4a63-9a7f-4e6d7b0d9e2a",
|
||||
"ts": "2026-01-10T12:34:56Z",
|
||||
"source": "simulator",
|
||||
"device_id": "esp32-fraser-01",
|
||||
"entity_type": "machine",
|
||||
"entity_id": "formatkreissaege",
|
||||
"event_type": "run_start",
|
||||
"confidence": "high",
|
||||
"payload": {
|
||||
"power_w": 820,
|
||||
"vibration": 0.73,
|
||||
"reason": "power_threshold"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Anhang B: Beispiel-Event (Waage stable_weight)
|
||||
```json
|
||||
{
|
||||
"schema_version": "v1",
|
||||
"event_uid": "b6d0a2c5-9b1f-4a0b-8b19-7f2e1b8f3d11",
|
||||
"ts": "2026-01-10T12:40:12Z",
|
||||
"source": "simulator",
|
||||
"device_id": "scale-sim-01",
|
||||
"entity_type": "scale",
|
||||
"entity_id": "waage-01",
|
||||
"event_type": "stable_weight",
|
||||
"confidence": "high",
|
||||
"payload": {
|
||||
"weight_g": 1532.4,
|
||||
"unit": "g",
|
||||
"stable_ms": 1200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -68,7 +68,8 @@ open_workshop/
|
|||
├── open_workshop/ # ✅ Alt-Modul (installable=False, Kompatibilität)
|
||||
├── open_workshop_base/ # ✅ FERTIG – Backend, _inherits maintenance.equipment
|
||||
├── open_workshop_pos/ # ✅ FERTIG – POS-Integration (JS, XML, UI)
|
||||
└── open_workshop_api/ # ⏳ GEPLANT – REST API für WordPress
|
||||
├── open_workshop_dokuwiki/ # ⏳ IN ENTWICKLUNG – DokuWiki-Integration
|
||||
└── open_workshop_api/ # 💤 OPTIONAL – REST API (zurückgestellt)
|
||||
```
|
||||
|
||||
**Entfernt:** open_workshop_maintenance (verworfen - Funktionalität direkt in open_workshop_base integriert)
|
||||
|
|
@ -81,7 +82,7 @@ open_workshop/
|
|||
|
||||
Enthält:
|
||||
|
||||
- ✅ `ows.machine` mit _inherits zu `maintenance.equipment`
|
||||
- ✅ `ows.machine` mit _inherits zu `maintenance.equipment` (delegiert alle Felder von maintenance.equipment transparent an ows.machine)
|
||||
- ✅ `ows.machine.area` (Bereiche mit JSONB-Namen)
|
||||
- ✅ `ows.machine.product` (Nutzungsprodukte)
|
||||
- ✅ `ows.machine.training` (Einweisungsprodukte)
|
||||
|
|
@ -140,87 +141,282 @@ Vollständig separiertes POS-Modul mit:
|
|||
|
||||
---
|
||||
|
||||
# 5. Modul: open_workshop_api ⏳ GEPLANT
|
||||
# 5. Modul: open_workshop_dokuwiki ⏳ IN ENTWICKLUNG
|
||||
|
||||
**Status: NOCH NICHT IMPLEMENTIERT**
|
||||
**Status: IN ENTWICKLUNG (Branch: feature/dokuwiki-integration)**
|
||||
|
||||
Dieses Modul stellt die **REST API** bereit, über die WordPress öffentlich verfügbare Daten abholt.
|
||||
Dieses Modul integriert **DokuWiki** als Dokumentationssystem für Equipment.
|
||||
|
||||
## 5.1 Ziele
|
||||
|
||||
- Minimaler externer Zugriff (nur API-Endpunkte)
|
||||
- JSON-Ausgabe für WordPress
|
||||
- Keine Odoo-Website erforderlich
|
||||
- Odoo selbst bleibt **nicht öffentlich erreichbar**
|
||||
- Automatische Wiki-Seiten für jedes Equipment
|
||||
- **Invertierte Include-Architektur**:
|
||||
- Odoo generiert geschützte Hauptseite mit Equipment-Daten
|
||||
- Hauptseite inkludiert User-Dokumentation via Include Plugin
|
||||
- User editieren separate `:doku` Unterseite
|
||||
- **Klare Datentrennung**:
|
||||
- Odoo = Master für strukturierte Daten (Hauptseite, read-only für User)
|
||||
- User = Master für Freitext-Dokumentation (`:doku` Unterseite, editierbar)
|
||||
- **Generisches ACL-System**: Eine Regel für alle Equipment
|
||||
- Versionierung durch DokuWiki eingebaut
|
||||
|
||||
## 5.2 API-Endpunkte
|
||||
## 5.2 Architektur
|
||||
|
||||
Pflicht:
|
||||
```
|
||||
Odoo (maintenance.equipment)
|
||||
↓
|
||||
XML-RPC Client
|
||||
↓
|
||||
DokuWiki API (wiki.putPage)
|
||||
↓
|
||||
werkstatt:ausruestung:bereich:maschinenname
|
||||
```
|
||||
|
||||
## 5.3 DokuWiki-Seitenstruktur
|
||||
|
||||
**Zwei-Seiten-System pro Equipment:**
|
||||
|
||||
```
|
||||
werkstatt:ausruestung:holzwerkstatt:formatkreissaege ← Hauptseite (Odoo generiert)
|
||||
werkstatt:ausruestung:holzwerkstatt:formatkreissaege:doku ← Doku-Seite (User editieren)
|
||||
werkstatt:ausruestung:lasercutter:epilog_fusion_m2 ← Hauptseite
|
||||
werkstatt:ausruestung:lasercutter:epilog_fusion_m2:doku ← Doku-Seite
|
||||
```
|
||||
|
||||
**Namespace-Regeln:**
|
||||
- **Hauptnamespace**: `werkstatt:ausruestung:`
|
||||
- **Hauptseite**: `werkstatt:ausruestung:bereich:maschinenname`
|
||||
- Bereich: ows_area_id (normalisiert, lowercase)
|
||||
- Maschinenname: Equipment name (normalisiert, ohne Sonderzeichen)
|
||||
- **Komplett Odoo-generiert**, User können nicht editieren
|
||||
|
||||
- **Doku-Seite**: `werkstatt:ausruestung:bereich:maschinenname:doku`
|
||||
- **User editieren hier frei** (Anleitungen, Tipps, Bilder)
|
||||
- Wird via Include in Hauptseite eingebunden
|
||||
- User können weitere Unterseiten anlegen (`:doku:bilder`, `:doku:videos`)
|
||||
|
||||
## 5.4 Hauptseiten-Template (Odoo-generiert)
|
||||
|
||||
Odoo erstellt die komplette Hauptseite inklusive Include für User-Dokumentation:
|
||||
|
||||
```wiki
|
||||
====== Formatkreissäge ======
|
||||
|
||||
===== Grunddaten =====
|
||||
* **Seriennummer:** FKS-2024-001
|
||||
* **Bereich:** [[werkstatt:ausruestung:holzwerkstatt|Holzwerkstatt]]
|
||||
* **Kategorie:** 🔴 Kategorie 3 (Einweisung erforderlich)
|
||||
* **Standort:** Schrank A, Regal 2
|
||||
* **Hersteller:** Bosch
|
||||
|
||||
===== Sicherheit =====
|
||||
* **Einweisungspflichtig:** Ja
|
||||
* **Nutzungsprodukte:**
|
||||
* Holzwerkstatt Nutzung (5€/Tag)
|
||||
* **Einweisungsprodukte:**
|
||||
* Einweisung Formatkreissäge (einmalig 25€)
|
||||
|
||||
===== Wartung =====
|
||||
* **Letzte Wartung:** 01.12.2025
|
||||
* **Nächste Wartung:** 01.06.2026
|
||||
* **Wartungshistorie:** [[odoo:maintenance:request:42|Alle Wartungsaufträge]]
|
||||
|
||||
===== Dokumentation & Anleitungen =====
|
||||
{{page>.:formatkreissaege:doku&noheader}}
|
||||
|
||||
[[.:formatkreissaege:doku|✏️ Dokumentation bearbeiten]]
|
||||
```
|
||||
|
||||
**User-Doku-Seite** (`:doku`, User editieren hier):
|
||||
```wiki
|
||||
==== Bedienung ====
|
||||
1. Hauptschalter einschalten
|
||||
2. Sägeblatt-Drehzahl einstellen
|
||||
3. Anschlag positionieren
|
||||
...
|
||||
|
||||
==== Tipps & Tricks ====
|
||||
- Bei Hartholz: Vorschub langsamer
|
||||
- Sägeblatt nach 50h wechseln
|
||||
|
||||
==== Bilder ====
|
||||
{{.:doku:bild1.jpg?300}}
|
||||
```
|
||||
|
||||
## 5.5 Technische Umsetzung
|
||||
|
||||
**Python-Bibliothek:**
|
||||
- `dokuwiki` (Python XML-RPC Client für DokuWiki)
|
||||
- Installation: `pip install dokuwiki`
|
||||
|
||||
**API-Methoden:**
|
||||
```python
|
||||
# DokuWiki XML-RPC API
|
||||
wiki.putPage(page_id, content, summary, minor) # Seite erstellen/aktualisieren
|
||||
wiki.getPage(page_id) # Seite lesen
|
||||
wiki.getAllPages() # Alle Seiten auflisten
|
||||
```
|
||||
|
||||
**Odoo-Integration:**
|
||||
- Neues Model: `ows.dokuwiki.config` (DokuWiki-URL, Credentials)
|
||||
- Methode auf `maintenance.equipment`: `sync_to_dokuwiki()`
|
||||
- Automated Action: Bei Equipment-Änderung → Wiki aktualisieren
|
||||
- Smart Button: "Wiki öffnen" → Link zur DokuWiki-Seite
|
||||
|
||||
**Sync-Algorithmus (vereinfacht!):**
|
||||
```python
|
||||
def sync_to_dokuwiki(self):
|
||||
"""Generiert Hauptseite mit Include für User-Doku"""
|
||||
# 1. Generiere Hauptseite komplett (Odoo hat volle Kontrolle)
|
||||
content = self._generate_equipment_page()
|
||||
|
||||
# 2. Füge Include für User-Doku hinzu
|
||||
wiki_id = self._get_normalized_name()
|
||||
content += "\n\n===== Dokumentation & Anleitungen =====\n"
|
||||
content += f"{{{{page>.:{{wiki_id}}:doku&noheader}}}}\n\n"
|
||||
content += f"[[.:{{wiki_id}}:doku|✏️ Dokumentation bearbeiten]]\n"
|
||||
|
||||
# 3. Schreibe Hauptseite (überschreibt komplett)
|
||||
main_page = f"werkstatt:ausruestung:{self.ows_area_id.wiki_name}:{wiki_id}"
|
||||
dokuwiki_client.putPage(main_page, content, "Odoo Auto-Sync")
|
||||
|
||||
# 4. Erstelle leere Doku-Seite falls nicht vorhanden
|
||||
doku_page = f"{main_page}:doku"
|
||||
if not dokuwiki_client.getPage(doku_page):
|
||||
initial_doku = "==== Bedienung ====\n\n==== Tipps & Tricks ====\n"
|
||||
dokuwiki_client.putPage(doku_page, initial_doku, "Initial")
|
||||
|
||||
def _generate_equipment_page(self):
|
||||
"""Generiert komplette Hauptseite mit Equipment-Daten"""
|
||||
return f"""====== {self.name} ======
|
||||
|
||||
===== Grunddaten =====
|
||||
* **Seriennummer:** {self.serial_no or 'N/A'}
|
||||
* **Bereich:** [[werkstatt:ausruestung:{self.ows_area_id.wiki_name}|{self.ows_area_id.name}]]
|
||||
* **Kategorie:** {self._get_category_icon()} {self._get_category_label()}
|
||||
* **Standort:** {self.location or 'N/A'}
|
||||
* **Hersteller:** {self.partner_id.name or 'N/A'}
|
||||
|
||||
===== Sicherheit =====
|
||||
* **Einweisungspflichtig:** {self._is_training_required()}
|
||||
* **Nutzungsprodukte:**
|
||||
{self._format_product_list(self.ows_product_ids)}
|
||||
* **Einweisungsprodukte:**
|
||||
{self._format_product_list(self.ows_training_ids)}
|
||||
|
||||
===== Wartung =====
|
||||
* **Letzte Wartung:** {self.last_maintenance_date or 'N/A'}
|
||||
* **Nächste Wartung:** {self.next_action_date or 'N/A'}
|
||||
"""
|
||||
```
|
||||
|
||||
**Vorteile dieser Architektur:**
|
||||
- ✅ Keine Sync-Marker nötig (Hauptseite wird komplett überschrieben)
|
||||
- ✅ User können Odoo-Daten nicht versehentlich löschen
|
||||
- ✅ User-Content ist komplett geschützt (separate Seite)
|
||||
- ✅ Include funktioniert transparent (User sehen alles auf einer Seite)
|
||||
- ✅ Wartungsfreies ACL-System
|
||||
|
||||
## 5.6 ACL-System (Generisch & Wartungsfrei)
|
||||
|
||||
**Phase 1: Nur für Odoo sichtbar (Initial-Setup)**
|
||||
|
||||
```
|
||||
# Komplett gesperrt für alle außer Odoo
|
||||
werkstatt:ausruestung:* @odoo 8 # Voller Zugriff
|
||||
werkstatt:ausruestung:* @ALL 0 # Kein Zugriff
|
||||
```
|
||||
|
||||
**Phase 2: Werkstatt-Betreiber erhalten Zugriff (später)**
|
||||
|
||||
```
|
||||
# User-Doku-Seiten editierbar (spezifischere Regel zuerst!)
|
||||
werkstatt:ausruestung:*:doku @werkstatt 2 # Editieren erlaubt
|
||||
werkstatt:ausruestung:*:doku:* @werkstatt 2 # Auch Unterseiten (Bilder, Videos, etc.)
|
||||
|
||||
# Hauptseiten nur lesen
|
||||
werkstatt:ausruestung:* @werkstatt 1 # Nur Lesezugriff
|
||||
werkstatt:ausruestung:* @ALL 1 # Alle können lesen
|
||||
|
||||
# Odoo hat vollen Zugriff überall
|
||||
werkstatt:ausruestung:* @odoo 8 # Voller Zugriff
|
||||
werkstatt:ausruestung:*:doku @odoo 8 # Auch auf Doku-Seiten (für Initial-Erstellung)
|
||||
**Wie es funktioniert:**
|
||||
|
||||
| Seite | User @werkstatt | User @odoo | Ergebnis |
|
||||
|-------|-----------------|------------|----------|
|
||||
| `werkstatt:ausruestung:holzwerkstatt:formatkreissaege` | Matched: `werkstatt:ausruestung:*` → 1 | Matched: `werkstatt:ausruestung:*` → 8 | User: Lesen, Odoo: Schreiben |
|
||||
| `werkstatt:ausruestung:holzwerkstatt:formatkreissaege:doku` | Matched: `werkstatt:ausruestung:*:doku` → 2 | Matched: `werkstatt:ausruestung:*:doku` → 8 | User: Editieren, Odoo: Voll |
|
||||
- `16` = Admin (alle Rechte)
|
||||
**Vorteile:**
|
||||
- ✅ **Komplett generisch**: Gilt automatisch für alle Equipment
|
||||
- ✅ **Wartungsfrei**: Kein manuelles ACL-Setup pro Equipment
|
||||
- ✅ **Phasenweise Freischaltung**: Erst Odoo-Only, dann schrittweise für Werkstatt
|
||||
- ✅ **Selbsterklärend**: User sehen sofort wo sie editieren können
|
||||
- ✅ **Skalierbar**: Funktioniert auch für 1000+ Equipment-Seiten
|
||||
| `ausruestung:holzwerkstatt:formatkreissaege` | Matched: `ausruestung:*` → 1 | Matched: `ausruestung:*` → 8 | User: Lesen, Odoo: Schreiben |
|
||||
| `ausruestung:holzwerkstatt:formatkreissaege:doku` | Matched: `ausruestung:*:doku` → 2 | Matched: `ausruestung:*:doku` → 8 | User: Editieren, Odoo: Voll |
|
||||
|
||||
**Vorteile:**
|
||||
- ✅ **Komplett generisch**: Gilt automatisch für alle Equipment
|
||||
- ✅ **Wartungsfrei**: Kein manuelles ACL-Setup pro Equipment
|
||||
- ✅ **Selbsterklärend**: User sehen sofort wo sie editieren können
|
||||
- ✅ **Skalierbar**: Funktioniert auch für 1000+ Equipment-Seiten
|
||||
|
||||
**Odoo-Seite:**
|
||||
- Credentials in `ir.config_parameter`:
|
||||
- `dokuwiki.url` (z.B. `https://wiki.hobbyhimmel.de`)
|
||||
- `dokuwiki.user` (z.B. `odoo`)
|
||||
- `dokuwiki.password` (verschlüsselt gespeichert)
|
||||
- XML-RPC über HTTPS
|
||||
- Logging aller Wiki-Operationen in `mail.message`
|
||||
|
||||
## 5.7 Vorteile DokuWiki
|
||||
|
||||
✅ **Leichtgewichtig** – File-based, keine separate Datenbank
|
||||
✅ **Versionierung** – Eingebautes History-System
|
||||
✅ **Wiki-Syntax** – Perfekt für strukturierte Dokumentation
|
||||
✅ **ACL-System** – Granulare Berechtigungen
|
||||
✅ **Suchfunktion** – Volltext-Suche eingebaut
|
||||
✅ **Plugins** – Erweiterbar (Markdown, Galleries, etc.)
|
||||
✅ **Open Source** – Keine Lizenzkosten
|
||||
|
||||
---
|
||||
|
||||
# 6. Modul: open_workshop_api ⏳ GEPLANT
|
||||
|
||||
**Status: ZURÜCKGESTELLT (DokuWiki hat Priorität)**
|
||||
|
||||
REST API für externe Systeme (falls später WordPress/App-Integration gewünscht).
|
||||
|
||||
## 6.1 Potenzielle Endpunkte
|
||||
|
||||
```
|
||||
GET /api/v1/machines
|
||||
GET /api/v1/machine/<id>
|
||||
```
|
||||
|
||||
Empfohlen:
|
||||
|
||||
```
|
||||
GET /api/v1/areas
|
||||
GET /api/v1/files/<attachment_id>
|
||||
GET /api/v1/events (später für Kurse)
|
||||
```
|
||||
|
||||
## 5.3 JSON Beispiel
|
||||
**Hinweis:** Mit DokuWiki kann die öffentliche Dokumentation direkt über das Wiki erfolgen.
|
||||
Eine separate REST API ist optional und wird nur bei Bedarf implementiert.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 12,
|
||||
"name": "Formatkreissäge",
|
||||
"status": "available",
|
||||
"area": "Holzwerkstatt",
|
||||
"image_url": "https://odoo.example.org/api/v1/file/29",
|
||||
"manual_url": "https://odoo.example.org/api/v1/file/55",
|
||||
"training_required": true
|
||||
}
|
||||
```
|
||||
## 6.1 Warum DokuWiki statt WordPress?
|
||||
|
||||
## 5.4 Sicherheitsmechanismen
|
||||
- **Einfacher**: Keine separate Datenbank, file-based
|
||||
- **Wiki-Syntax**: Perfekt für technische Dokumentation
|
||||
- **Versionierung**: Eingebautes History-System
|
||||
- **ACL**: Feinere Berechtigungskontrolle
|
||||
- **Leichtgewichtig**: Weniger Overhead als WordPress
|
||||
- **Integration**: XML-RPC API für bidirektionale Sync
|
||||
|
||||
- CORS nur für WordPress-Domain erlauben
|
||||
- Optionaler Token im Header (`Authorization: Bearer <token>`)
|
||||
- Kein Zugriff auf `/web`, `/api/web`, `/pos`
|
||||
- Rate Limiting via Reverse Proxy
|
||||
## 6.2 Zukunft: WordPress optional
|
||||
|
||||
---
|
||||
Falls später gewünscht, kann WordPress **zusätzlich** die öffentliche Maschinenliste anzeigen:
|
||||
- DokuWiki für **Dokumentation** (intern/extern lesbar)
|
||||
- WordPress für **Präsentation** (extern mit Bildern, SEO)
|
||||
- Beide Systeme greifen auf Odoo-Daten zu
|
||||
|
||||
# 6. WordPress Integration ⏳ GEPLANT
|
||||
|
||||
**Status: VORBEREITET (API noch nicht implementiert)**
|
||||
|
||||
WordPress bleibt die öffentliche Website.
|
||||
|
||||
## 6.1 Warum?
|
||||
|
||||
- Sehr hohe Performance (CDN, Caching)
|
||||
- SEO-optimiert
|
||||
- Keine Belastung deiner Internetleitung
|
||||
- Keine Sicherheitsrisiken im internen Odoo
|
||||
- Keine Lizenzkosten
|
||||
|
||||
## 6.2 WordPress Plugin (bereitgestellt)
|
||||
|
||||
Das Plugin:
|
||||
|
||||
- Ruft Odoo-API ab
|
||||
- Rendert Maschinenliste via Shortcode
|
||||
- Konsumiert JSON-Daten
|
||||
- Unterstützt Token-Auth
|
||||
|
||||
Beispiel:
|
||||
|
||||
```
|
||||
[openworkshop_machines]
|
||||
```
|
||||
Aktuell hat DokuWiki-Integration **Priorität**.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -229,46 +425,58 @@ Beispiel:
|
|||
```
|
||||
Internet
|
||||
|
|
||||
WordPress (Frontend, CDN)
|
||||
DokuWiki (Dokumentation)
|
||||
WordPress (optional: Präsentation)
|
||||
|
|
||||
fetch JSON via REST API
|
||||
XML-RPC / REST API
|
||||
|
|
||||
----------------------------
|
||||
| API Gateway (open_workshop_api)
|
||||
| Exposed only: /api/v1/*
|
||||
| Odoo Backend (LAN/VPN)
|
||||
| open_workshop_dokuwiki
|
||||
| maintenance.equipment
|
||||
----------------------------
|
||||
|
|
||||
Odoo Backend (LAN/VPN)
|
||||
Maschinen, POS, Benutzer, Einweisungen
|
||||
POS, Benutzer, Einweisungen
|
||||
```
|
||||
|
||||
**Kein direkter Zugriff auf Odoo-Weboberfläche!**
|
||||
**Fokus:** Equipment-Dokumentation über DokuWiki, WordPress später optional.
|
||||
|
||||
---
|
||||
|
||||
# 8. Sicherheit
|
||||
|
||||
- Reverse Proxy (Traefik/Nginx) muss alle Odoo-Backoffice-URLS sperren
|
||||
- Nur `/api/v1/*` darf öffentlich sein
|
||||
**DokuWiki ACL:**
|
||||
- Namespace `ausruestung:*` lesbar für alle (@ALL = 1)
|
||||
- Namespace `ausruestung:*` schreibbar nur für Odoo (@odoo = 8)
|
||||
- Manuelle Edits durch Admins möglich
|
||||
|
||||
**Odoo:**
|
||||
- DokuWiki-Credentials in ir.config_parameter
|
||||
- XML-RPC über HTTPS
|
||||
- Logging aller Wiki-Operationen
|
||||
- Backup-System für Wiki-Dateien
|
||||
|
||||
**Optional (REST API):**
|
||||
- Reverse Proxy sperrt alle Odoo-URLs außer `/api/v1/*`
|
||||
- HTTPS erzwingen
|
||||
- Rate-Limit + Firewall-Regeln
|
||||
- Token-Authentifizierung optional zusätzlich einsetzbar
|
||||
- Rate-Limit + Firewall
|
||||
- Token-Auth
|
||||
|
||||
---
|
||||
|
||||
# 9. Vorteile dieser Architektur
|
||||
|
||||
✔ **WordPress bleibt ultraschnell** (1000 Besucher/Tag problemlos)
|
||||
✔ **DokuWiki für Dokumentation** – Wiki-Syntax perfekt für Anleitungen
|
||||
✔ **Versionierung eingebaut** – History für alle Änderungen
|
||||
✔ **Odoo bleibt sicher** hinter Firewall
|
||||
✔ **Keine Benutzerkosten** → alle Ehrenamtlichen können intern mitarbeiten
|
||||
✔ **Keine Benutzerkosten** – alle Ehrenamtlichen können intern mitarbeiten
|
||||
✔ **Modular, wartbar, zukunftssicher**
|
||||
✔ **API erlaubt feine Steuerung**, welche Daten öffentlich sind
|
||||
✔ **Keine Last auf DSL-Leitung** (WordPress hostet extern)
|
||||
✔ **Maintenance-Integration** ohne Extra-Modul direkt in Base
|
||||
✔ **Intelligente Sync** – Nur strukturierte Daten werden überschrieben, Freitext bleibt erhalten
|
||||
✔ **Öffentlich lesbar** – Anleitungen für alle verfügbar
|
||||
✔ **WordPress optional** – Kann später für Marketing/SEO ergänzt werden
|
||||
|
||||
---
|
||||
|
||||
# 10. Implementierungsstand (08.12.2025)
|
||||
# 10. Implementierungsstand (13.12.2025)
|
||||
|
||||
| Schritt | Status | Details |
|
||||
|---------|--------|---------|
|
||||
|
|
@ -276,45 +484,55 @@ Beispiel:
|
|||
| 2. open_workshop_pos | ✅ **FERTIG** | POS-Integration komplett |
|
||||
| 3. ~~open_workshop_maintenance~~ | ❌ **VERWORFEN** | Direkt in Base integriert |
|
||||
| 4. Maintenance Integration | ✅ **FERTIG** | _inherits Pattern implementiert |
|
||||
| 5. Migration Workflow | ✅ **FERTIG** | SQL + Python, CI/CD integriert |
|
||||
| 6. open_workshop_api | ⏳ **GEPLANT** | REST API für WordPress |
|
||||
| 7. WordPress Plugin | ⏳ **GEPLANT** | Frontend-Integration |
|
||||
| 5. Equipment-View Integration | ✅ **FERTIG** | Related fields, Menu-Migration |
|
||||
| 6. Migration Workflow | ✅ **FERTIG** | SQL + Python, CI/CD integriert |
|
||||
| 7. open_workshop_dokuwiki | ⏳ **IN ENTWICKLUNG** | Branch: feature/dokuwiki-integration |
|
||||
| 8. open_workshop_api | 💤 **OPTIONAL** | Zurückgestellt, bei Bedarf |
|
||||
|
||||
---
|
||||
|
||||
# 11. Nächste Schritte
|
||||
|
||||
1. **open_workshop_api** entwickeln
|
||||
- REST Controller implementieren
|
||||
- JSON-Serializer für machines/areas
|
||||
- CORS und Security konfigurieren
|
||||
- Token-Auth optional hinzufügen
|
||||
1. **open_workshop_dokuwiki** entwickeln
|
||||
- DokuWiki XML-RPC Client implementieren
|
||||
- Template-System für Equipment-Seiten
|
||||
- Sync-Mechanismus (Odoo → Wiki)
|
||||
- Smart Button "Wiki öffnen"
|
||||
- ACL-Konfiguration dokumentieren
|
||||
|
||||
2. **WordPress Plugin** anpassen
|
||||
- API-Endpunkte konfigurieren
|
||||
- Shortcode-Rendering
|
||||
- Caching implementieren
|
||||
2. **DokuWiki aufsetzen**
|
||||
- Installation auf Server
|
||||
- Namespace `ausruestung:` anlegen
|
||||
- ACL konfigurieren (@odoo Gruppe)
|
||||
- XML-RPC API aktivieren
|
||||
|
||||
3. **Testing & Deployment**
|
||||
- API-Tests schreiben
|
||||
- Reverse Proxy konfigurieren
|
||||
- Performance-Tests
|
||||
- Go-Live vorbereiten
|
||||
3. **Testing**
|
||||
- Equipment-Seiten automatisch generieren
|
||||
- Sync bei Änderungen testen
|
||||
- ACL-Rechte validieren
|
||||
- Performance testen
|
||||
|
||||
4. **Optional: WordPress Plugin**
|
||||
- Falls öffentliche Präsentation gewünscht
|
||||
- REST API dann implementieren
|
||||
- Caching-Layer hinzufügen
|
||||
|
||||
---
|
||||
|
||||
# 12. Endfazit
|
||||
|
||||
Diese Architektur hat sich bewährt:
|
||||
Diese Architektur vereint das Beste aus beiden Welten:
|
||||
|
||||
- ✅ **Technisch korrekt** – _inherits Pattern statt separatem Modul
|
||||
- ✅ **Performant** – Maintenance.equipment als Single Source of Truth
|
||||
- ✅ **Sicher** – API-Layer trennt intern/extern
|
||||
- ✅ **Kostenoptimiert** – Keine Odoo.sh Lizenzen nötig
|
||||
- ✅ **Dokumentiert** – DokuWiki für strukturierte Anleitungen
|
||||
- ✅ **Versioniert** – History-System eingebaut
|
||||
- ✅ **Öffentlich zugänglich** – Wiki lesbar für alle
|
||||
- ✅ **Kostenoptimiert** – Keine zusätzlichen Lizenzen
|
||||
- ✅ **Langfristig erweiterbar** – Modularer Aufbau
|
||||
- ✅ **Produktiv im Einsatz** – Migration erfolgreich abgeschlossen
|
||||
|
||||
Die API ist **zentrales Zukunftsmodul** für die WordPress-Integration.
|
||||
**DokuWiki-Integration ist der nächste logische Schritt** für bessere Equipment-Dokumentation.
|
||||
|
||||
**Letztes Update: 08.12.2025**
|
||||
**Letztes Update: 13.12.2025**
|
||||
|
||||
|
|
|
|||
415
README.md
Normal file
415
README.md
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
# Open Workshop
|
||||
|
||||
**POS-basiertes Maschinenfreigabe- und Werkstattverwaltungssystem für Odoo**
|
||||
|
||||
Open Workshop ist eine modulare Erweiterung des Odoo POS-Systems zur Verwaltung von einweisungspflichtigen Maschinen und Geräten in Werkstätten, Makerspaces und FabLabs. Das System ermöglicht die Verwaltung von Maschinenfreigaben, Einweisungen und Equipment-Dokumentation.
|
||||
|
||||
---
|
||||
|
||||
## Hauptfunktionen
|
||||
|
||||
- **Maschinenfreigaben**: Verwaltung von kundenspezifischen Freigaben für einweisungspflichtige Geräte
|
||||
- **Sicherheitskategorien**: Dreistufiges Kategoriesystem (grün/gelb/rot) für unterschiedliche Einweisungspflichten
|
||||
- **POS-Integration**: Verkauf von Einweisungen und Anzeige von Maschinenfreigaben direkt am Point of Sale
|
||||
- **Equipment-Verwaltung**: Vollständige Integration mit dem OCA Maintenance-Modul
|
||||
|
||||
- **Dokumentation**: Automatische Synchronisation von Equipment-Daten mit DokuWiki
|
||||
- **Mitarbeiter-Display**: Dynamische Anzeige von Mitarbeiterdaten im POS Customer Display
|
||||
|
||||
---
|
||||
|
||||
## Module
|
||||
|
||||
### open_workshop_base
|
||||
**Kernmodul** - Stellt die Basis-Funktionalität für das gesamte System bereit.
|
||||
|
||||
**Hauptfunktionen:**
|
||||
- Maschinenmodelle mit Sicherheitskategorien (grün/gelb/rot)
|
||||
- Bereiche (z.B. Holz, Metall, FabLab)
|
||||
- Freigabeverwaltung (wer darf welche Maschine nutzen)
|
||||
- Produktverknüpfungen für Einweisungen und Nutzung
|
||||
- Integration mit OCA maintenance.equipment als Single Source of Truth
|
||||
|
||||
**Datenmodelle:**
|
||||
- `ows.user` - Benutzerdaten (RFID, Geburtstag, Sicherheitsunterweisung)
|
||||
- `ows.machine.area` - Maschinenbereiche mit Farbkodierung
|
||||
- `ows.machine` - Maschinen (nutzt _inherits Pattern mit maintenance.equipment)
|
||||
- `ows.machine.access` - Freigaben (Partner ↔ Maschine)
|
||||
- `ows.machine.product` - Nutzungsprodukte für Maschinen
|
||||
- `ows.machine.training` - Einweisungsprodukte für Maschinen
|
||||
|
||||
**Abhängigkeiten:**
|
||||
- OCA `maintenance` (Basis-Equipment-Verwaltung)
|
||||
- OCA `maintenance_equipment_status` (Equipment-Statusverwaltung)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/OCA/maintenance.git
|
||||
cd maintenance
|
||||
git reset --hard 5510275e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### open_workshop_pos
|
||||
**POS-Integration** - Erweitert den Odoo Point of Sale um Maschinenfreigabe-Funktionalität.
|
||||
|
||||
**Hauptfunktionen:**
|
||||
- **Machine Access List**: Übersichtsanzeige aller verfügbaren Maschinen welche Einweisungspflichtig sind, mit Freigabestatus für den aktuellen Kunden (roter Punkt)
|
||||
- **Customer Sidebar**: Kundenspezifische Maschinenfreigaben am POS
|
||||
- **POS Sidebar**: Maschinenauswahl und Statusanzeige
|
||||
- **Automatische Freigabenvergabe**: Beim Verkauf eines Einweisungsprodukts wird dem Kunden automatisch die Freigabe für das zugehörige Gerät in der Datenbank erteilt
|
||||
|
||||
**Verwendungszweck:**
|
||||
Verkauf von Maschineneinweisungen und Anzeige bestehender Freigaben direkt am Kassensystem. Ermöglicht schnelle Prüfung, welcher Kunde auf welche einweisungspflichtigen Geräte zugreifen darf.
|
||||
|
||||
**Abhängigkeiten:**
|
||||
- `open_workshop_base`
|
||||
- `point_of_sale`
|
||||
|
||||
---
|
||||
|
||||
### open_workshop_pos_customer_display
|
||||
**Mitarbeiter-Display** - Zeigt dynamisches Mitarbeiter-Namensschild im POS Customer Display.
|
||||
|
||||
**Hauptfunktionen:**
|
||||
- Anzeige von Mitarbeiterfoto (generiert durch open_workshop_employee_imagegenerator)
|
||||
- Darstellung von Name und Schwerpunkten (job_focus)
|
||||
- Automatische Aktualisierung bei Kassiererwechsel
|
||||
- Responsive Design mit Fallback auf Company-Logo
|
||||
|
||||
**Verwendungszweck:**
|
||||
Darstellung der Mitarbeitenden am Kundendisplay, verbessert die Kundeninteraktion und Transparenz.
|
||||
|
||||
**Abhängigkeiten:**
|
||||
- `point_of_sale`
|
||||
- `hr`
|
||||
|
||||
---
|
||||
|
||||
### open_workshop_dokuwiki
|
||||
**DokuWiki-Integration** - Synchronisiert Equipment-Daten aus Odoo mit einem DokuWiki-System.
|
||||
|
||||
**Hauptfunktionen:**
|
||||
- Automatische Erstellung von Wiki-Seiten für Equipment
|
||||
- Übersichtstabelle aller Equipment (DataTables mit Sortierung/Filterung)
|
||||
- Status-Seiten für Include-Plugin (nur von Odoo generiert, read-only)
|
||||
- Smart Button "Wiki öffnen" im Equipment-Formular
|
||||
- Automatische Synchronisation bei Equipment-Änderungen
|
||||
|
||||
**Architektur-Prinzip:**
|
||||
- Odoo generiert **NUR** `odoo-status/` Seiten (maschinengeneriert, read-only)
|
||||
- Benutzer erstellen eigene Dokumentation in `{bereich}/` Namespaces
|
||||
- Einbindung der Odoo-Daten via DokuWiki Include-Plugin
|
||||
|
||||
**Verwendungszweck:**
|
||||
Zentrale Dokumentation aller Geräte mit automatisch aktualisiertem Status aus Odoo, kombiniert mit benutzergenerierten Anleitungen und Wartungshinweisen.
|
||||
|
||||
**Abhängigkeiten:**
|
||||
- `open_workshop_base`
|
||||
- `maintenance`
|
||||
- `maintenance_equipment_status`
|
||||
|
||||
---
|
||||
|
||||
### 🎨 open_workshop_employee_imagegenerator
|
||||
**Mitarbeiterfoto-Generator** - Upload und Zuschnitt von Mitarbeiterfotos direkt in Odoo.
|
||||
|
||||
**Hauptfunktionen:**
|
||||
- Upload und Zuschnitt von Fotos auf festes Format (369x492 Pixel)
|
||||
- Hinzufügen von Schwerpunktbereichen (job_focus)
|
||||
- Integrierter Cropper.js für professionelle Bildbearbeitung
|
||||
- Fester Crop-Frame (nur Bild bewegt sich, nicht der Rahmen)
|
||||
- Integration im Employee-Formular über Button "Namensschild erstellen"
|
||||
|
||||
**Verwendungszweck:**
|
||||
Erstellung von professionellen Mitarbeiterfotos für das POS Customer Display. Vereinfacht den Workflow durch direkten Upload in Odoo ohne externe Bildbearbeitung.
|
||||
|
||||
**Abhängigkeiten:**
|
||||
- `hr`
|
||||
- `web`
|
||||
|
||||
---
|
||||
|
||||
## Architektur & Datenfluss
|
||||
|
||||
### Datenmodell-Übersicht
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ OCA maintenance.equipment │
|
||||
│ (Single Source of Truth für name, serial_no, cost, etc.) │
|
||||
└──────────────────────┬──────────────────────────────────────┘
|
||||
│ _inherits (Delegation Pattern)
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ ows.machine │ ◄────────┐
|
||||
│ (OWS-Features) │ │
|
||||
└────┬───────────┘ │
|
||||
│ │
|
||||
┌─────────┼──────────────────────┤
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ ows.machine.area │ │
|
||||
│ │ (Bereiche) │ │
|
||||
│ └──────────────────┘ │
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────┐ ┌────────┴─────────┐
|
||||
│ ows.machine. │ │ ows.machine. │
|
||||
│ product │ │ training │
|
||||
│ (Nutzung) │ │ (Einweisung) │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ res.partner │ │ ows.user │
|
||||
│ (Kunde) │◄───────────│ (Benutzerdaten) │
|
||||
└────────┬─────────┘ 1:1 └──────────────────┘
|
||||
│
|
||||
│ M:N
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ ows.machine. │ │ ows.machine │
|
||||
│ access │───────────►│ │
|
||||
│ (Freigaben) │ │ │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
### _inherits Pattern Erklärung
|
||||
|
||||
Das Modul nutzt das Odoo **_inherits Pattern** (Delegation/Prototypische Vererbung) für `ows.machine`:
|
||||
|
||||
**Wie es funktioniert:**
|
||||
- `ows.machine` delegiert Basis-Felder an `maintenance.equipment`
|
||||
- Beim Erstellen einer `ows.machine` wird automatisch ein `maintenance.equipment` erstellt
|
||||
- Felder wie `name`, `serial_no`, `cost`, `location` werden direkt von equipment übernommen
|
||||
- **Keine Datenduplizierung!** Ein Equipment-Datensatz, zwei Modell-Sichten
|
||||
|
||||
**Vorteile:**
|
||||
- `maintenance.equipment` bleibt die **Single Source of Truth** für Stammdaten
|
||||
- Kompatibilität mit allen OCA Maintenance-Modulen
|
||||
- `ows.machine` fügt nur OWS-spezifische Felder hinzu (category, area_id, etc.)
|
||||
- Nahtlose Integration in bestehendes Maintenance-System
|
||||
|
||||
**Beispiel:**
|
||||
```python
|
||||
class OwsMachine(models.Model):
|
||||
_name = 'ows.machine'
|
||||
_inherits = {'maintenance.equipment': 'equipment_id'}
|
||||
|
||||
equipment_id = fields.Many2one('maintenance.equipment', required=True, ondelete='cascade')
|
||||
category = fields.Selection(...) # OWS-spezifisch
|
||||
area_id = fields.Many2one(...) # OWS-spezifisch
|
||||
```
|
||||
|
||||
Beim Zugriff auf `machine.name` wird automatisch `machine.equipment_id.name` zurückgegeben.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Modul-Zusammenhänge
|
||||
|
||||
### Installation & Abhängigkeiten
|
||||
|
||||
```
|
||||
OCA maintenance ─────┐
|
||||
OCA maintenance_ │
|
||||
equipment_status ──┤
|
||||
▼
|
||||
open_workshop_base ◄──────────┐
|
||||
│ │
|
||||
┌───────────┼─────────────┐ │
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ │
|
||||
open_workshop open_workshop open_workshop
|
||||
_pos _dokuwiki _employee_
|
||||
imagegenerator
|
||||
│ │
|
||||
▼ │
|
||||
open_workshop_pos_ │
|
||||
customer_display ◄───────────────┘
|
||||
```
|
||||
|
||||
**Installation:**
|
||||
|
||||
Die OCA Module `maintenance` und `maintenance_equipment_status` müssen im `addons_path` verfügbar sein. Sie werden automatisch mit installiert, wenn `open_workshop_base` installiert wird (siehe `depends` in der `__manifest__.py`).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/OCA/maintenance.git
|
||||
cd maintenance
|
||||
git reset --hard 5510275e
|
||||
```
|
||||
|
||||
**Installationsreihenfolge:**
|
||||
1. `open_workshop_base` (Kernmodul - installiert OCA-Abhängigkeiten automatisch)
|
||||
2. Optionale Module je nach Bedarf:
|
||||
- `open_workshop_pos` für POS-Integration
|
||||
- `open_workshop_dokuwiki` für Wiki-Dokumentation
|
||||
- `open_workshop_employee_imagegenerator` für Mitarbeiterfotos
|
||||
- `open_workshop_pos_customer_display` für Customer Display (benötigt imagegenerator)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Entwicklungshinweise
|
||||
|
||||
### Wo finde ich was?
|
||||
|
||||
#### open_workshop_base
|
||||
- **Models**: `/models/ows_models.py` - Alle Kern-Datenmodelle
|
||||
- **Views**: `/views/` - Backend-Ansichten (Form, Tree, Kanban)
|
||||
- Erweiterte Views:
|
||||
- `res.partner` (Kontakte): Formular, Listenansicht - Maschinenfreigaben-Tab
|
||||
- `maintenance.equipment`: Formular, Listenansicht, Suchansicht - OWS-Felder
|
||||
- **Security**: `/security/ir.model.access.csv` - Zugriffsrechte
|
||||
- **Data**: `/data/` - Stammdaten (Equipment-Status, Bereiche)
|
||||
|
||||
#### open_workshop_pos
|
||||
- **JavaScript**: `/static/src/js/` - POS Frontend-Logik
|
||||
- `ows_pos_sidebar.js` - POS Maschinensidebar
|
||||
- `ows_pos_customer_sidebar.js` - Kundensidebar
|
||||
- `ows_machine_access_list.js` - Zugriffsliste
|
||||
- **Templates**: `/static/src/xml/` - OWL Templates für POS UI
|
||||
- **Models**: `/models/pos_order.py` - POS Order Extensions
|
||||
|
||||
#### open_workshop_dokuwiki
|
||||
- **Models**: `/models/`
|
||||
- `dokuwiki_client.py` - DokuWiki XML-RPC Client
|
||||
- `maintenance_equipment.py` - Equipment Extensions
|
||||
- **Views**: `/views/`
|
||||
- Erweiterte Views:
|
||||
- `maintenance.equipment`: Smart Button "Wiki öffnen"
|
||||
- `maintenance.equipment.status`: DokuWiki-Synchronisation
|
||||
- **Wizard**: `/wizard/` - Synchronisations-Assistenten
|
||||
|
||||
#### open_workshop_employee_imagegenerator
|
||||
- **JavaScript**: `/static/src/js/employee_image_widget.js` - Cropper Widget
|
||||
- **Templates**: `/static/src/xml/employee_image_widget.xml` - Widget UI
|
||||
- **Views**: `/views/`
|
||||
- Erweiterte Views:
|
||||
- `hr.employee`: Button "Namensschild erstellen" im Formular
|
||||
- **CSS**: `/static/src/css/` - Badge-Styling
|
||||
- **Library**: `/static/lib/cropperjs/` - Cropper.js Integration
|
||||
|
||||
#### open_workshop_pos_customer_display
|
||||
- **JavaScript**: `/static/src/js/customer_display.js` - Display-Logik
|
||||
- **CSS**: `/static/src/css/employee_badge.css` - Badge-Styling
|
||||
|
||||
### Wichtige Methoden & RPC Calls
|
||||
|
||||
#### open_workshop_base
|
||||
- `ows.machine.get_access_list_grouped(partner_id)` - Gruppierte Zugriffsliste für POS
|
||||
- Liefert alle Bereiche mit Maschinen und Zugriffssstatus für einen Partner
|
||||
- Wird vom POS Frontend verwendet
|
||||
|
||||
#### res.partner Extensions
|
||||
- `machine_access_ids` - One2many zu allen Freigaben
|
||||
- `machine_access_html` - Computed HTML-Tabelle für Backend-Ansicht im Res.partner / Kontakt Formular
|
||||
- `ows_user_id` - One2many zu ows.user (Benutzerdaten)
|
||||
|
||||
### Erweiterung des Systems
|
||||
|
||||
**Neue Sicherheitskategorie hinzufügen:**
|
||||
1. In `ows_models.py` → `OwsMachine.category` Selection erweitern
|
||||
2. Icon-Mapping in `_compute_category_icon()` aktualisieren
|
||||
3. CSS in `/static/src/css/category_color.css` ergänzen
|
||||
|
||||
**Neuen Maschinenbereich erstellen:**
|
||||
- Im Backend: **Wartung → Konfiguration → Bereiche**
|
||||
- Oder via Data-File in `/data/` für Standardbereiche
|
||||
|
||||
**POS-UI anpassen:**
|
||||
- OWL Templates in `/static/src/xml/` bearbeiten
|
||||
- JavaScript-Komponenten in `/static/src/js/` erweitern
|
||||
- CSS in `/static/src/css/` anpassen
|
||||
|
||||
---
|
||||
|
||||
## 📝 Technische Details
|
||||
|
||||
### Backend-Konfiguration
|
||||
|
||||
**Sicherheitskategorien zuweisen:**
|
||||
- Menü: **Wartung → Ausrüstung** → [Equipment auswählen]
|
||||
- Im Formular: Feld "Sicherheitskategorie" (🟢/🟡/🔴)
|
||||
- Die Kategorie ist direkt am `ows.machine` / `maintenance.equipment` gespeichert
|
||||
|
||||
**Einweisungsprodukte zuweisen:**
|
||||
- Menü: **Wartung → Ausrüstung** → [Equipment auswählen]
|
||||
- Im Formular: Tab "Einweisungsprodukte"
|
||||
- Button "Hinzufügen" → Produkt aus `product.product` auswählen
|
||||
- Speichert Verknüpfung in `ows.machine.training` (Many2many über One2many)
|
||||
- Beim Verkauf dieses Produkts am POS wird automatisch die Freigabe erteilt
|
||||
|
||||
**Nutzungsprodukte zuweisen:**
|
||||
- Gleicher Weg wie Einweisungsprodukte
|
||||
- Tab "Nutzungsprodukte" im Equipment-Formular
|
||||
- Speichert Verknüpfung in `ows.machine.product`
|
||||
- Diese Produkte können für Nutzungsgebühren verwendet werden
|
||||
|
||||
**Maschinenfreigaben manuell vergeben:**
|
||||
- Menü: **Kontakte** → [Partner auswählen]
|
||||
- Tab "Maschinenfreigaben" (von open_workshop_base hinzugefügt)
|
||||
- Übersicht aller Bereiche und Maschinen mit aktuellem Freigabestatus
|
||||
- Freigaben werden jedoch hauptsächlich automatisch über POS-Verkäufe vergeben
|
||||
|
||||
**Konfiguration:**
|
||||
- **Bereiche**: Wartung → Konfiguration → Bereiche
|
||||
- **Produkt-Zuordnungen**: Wartung → Konfiguration → Zuordnungen
|
||||
- Nutzungsprodukte (Übersicht aller Zuordnungen)
|
||||
- Einweisungsprodukte (Übersicht aller Zuordnungen)
|
||||
|
||||
---
|
||||
|
||||
### Sicherheitskategorien
|
||||
|
||||
- **🟢 Grün (Kategorie 1)**: Keine Einweisungspflicht, freier Zugang
|
||||
- **🟡 Gelb (Kategorie 2)**: Empfohlene Einweisung, optionale Freigabe
|
||||
- **🔴 Rot (Kategorie 3)**: Einweisung zwingend erforderlich, POS zeigt nur rote Maschinen
|
||||
|
||||
### Freigabenverwaltung
|
||||
|
||||
Freigaben werden in `ows.machine.access` gespeichert mit:
|
||||
- Partner (Kunde)
|
||||
- Maschine
|
||||
- Datum der Freigabe
|
||||
- Optional: Ablaufdatum
|
||||
- Herkunft (granted_by_pos)
|
||||
|
||||
### DokuWiki Namespace-Struktur
|
||||
|
||||
```
|
||||
{equipment_namespace}:
|
||||
├── overview # Übersichtstabelle (von Odoo)
|
||||
└── odoo-status/ # Maschinengeneriert (read-only)
|
||||
├── maschine_1 # Status-Seite für Include
|
||||
├── maschine_2
|
||||
└── c_template # Template für Status-Seiten
|
||||
|
||||
{bereich}: # Benutzer-Namespaces
|
||||
├── holz/
|
||||
│ ├── maschine_1 # Benutzer-Dokumentation
|
||||
│ └── maschine_2 # (inkludiert odoo-status via {{page>}})
|
||||
└── metall/
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
AGPL-3 (open_workshop_base)
|
||||
LGPL-3 (weitere Module)
|
||||
|
||||
---
|
||||
|
||||
## 👤 Autor
|
||||
|
||||
Matthias Lotz / Hobbyhimmel
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Verwandte Projekte
|
||||
|
||||
- [OCA Maintenance](https://github.com/OCA/maintenance) - Basis Equipment-Verwaltung
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ from odoo import models, fields
|
|||
class MaintenanceEquipment(models.Model):
|
||||
_inherit = 'maintenance.equipment'
|
||||
|
||||
qr_code = fields.Binary("QR Code")
|
||||
qr_code = fields.Binary("QR Code", attachment=True)
|
||||
comp_serial_no = fields.Char("Inventory Serial No", tracking=True)
|
||||
serial_no = fields.Char('Mfg. Serial Number', copy=False)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<div class="o_label_data">
|
||||
<div class="text-center o_label_right_column">
|
||||
<t t-if="equipment.qr_code">
|
||||
<div t-field="equipment.qr_code" t-options="{'widget': 'image'}" style="width: 130px;margin-top: -40px;"/>
|
||||
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')" style="width: 130px;margin-top: -40px;"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="text-left" style="line-height:normal;word-wrap: break-word;">
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
</div>
|
||||
<div class= "text-center o_label_right_column">
|
||||
<t t-if="equipment.qr_code">
|
||||
<div t-field="equipment.qr_code" t-options="{'widget': 'image'}" style="width:95px;padding:0px;margin-top:-10px"/>
|
||||
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')" style="width:95px;padding:0px;margin-top:-10px"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="text-left o_label_left_column" style="line-height:normal;word-wrap: break-word;">
|
||||
|
|
@ -66,19 +66,19 @@
|
|||
<td style="width:25%;">Name</td>
|
||||
<td style="width:5%">:</td>
|
||||
<td colspan="2" style="width:70%;word-wrap: break-word;">
|
||||
<t t-esc="equipment.name"/>
|
||||
<span t-field="equipment.name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:25%">Model</td>
|
||||
<td style="width:5%">:</td>
|
||||
<td style="width:35%;word-wrap: break-word;">
|
||||
<t t-esc="equipment.model"/>
|
||||
<span t-field="equipment.model"/>
|
||||
</td>
|
||||
<td rowspan="5" style="width:35%">
|
||||
<t t-if="equipment.name">
|
||||
<div t-field="equipment.qr_code" t-options="{'widget': 'image'}"
|
||||
style="width:30mm;height:10mm;"/>
|
||||
<t t-if="equipment.qr_code">
|
||||
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')"
|
||||
style="width:30mm;height:30mm;"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -87,23 +87,22 @@
|
|||
<td style="width:25%">Mfg Serial</td>
|
||||
<td style="width:5%">:</td>
|
||||
<td style="width:35%;font-size:10px;word-wrap: break-word;">
|
||||
<t t-esc="equipment.serial_no"/>
|
||||
<span t-field="equipment.serial_no"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:25%">Serial</td>
|
||||
<td style="width:5%">:</td>
|
||||
<td style="width:35%;word-wrap: break-word;">
|
||||
<t t-esc="equipment.comp_serial_no"/>
|
||||
<span t-field="equipment.comp_serial_no"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:25%">Warranty Date</td>
|
||||
<td style="width:5%">:</td>
|
||||
<td style="width:35%;word-wrap: break-word;">
|
||||
<t t-esc="equipment.warranty_date"/>
|
||||
<span t-field="equipment.warranty_date"/>
|
||||
</td>
|
||||
<!-- <td style="width:35%;"></td> -->
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:25%"></td>
|
||||
|
|
|
|||
|
|
@ -1,84 +1,34 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
import math
|
||||
from io import BytesIO
|
||||
|
||||
import qrcode
|
||||
from odoo import models
|
||||
|
||||
|
||||
def generate_qr_code(value):
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=20,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(value)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image()
|
||||
temp = BytesIO()
|
||||
img.save(temp, format="PNG")
|
||||
qr_img = base64.b64encode(temp.getvalue())
|
||||
return qr_img
|
||||
|
||||
|
||||
def _prepare_data(env, data):
|
||||
equipment_label_layout_id = env['equipment.label.layout'].browse(data['equipment_label_layout_id'])
|
||||
equipment_dict = {}
|
||||
equipment_ids = equipment_label_layout_id.equipment_ids
|
||||
for equipment in equipment_ids:
|
||||
if not equipment.name:
|
||||
continue
|
||||
equipment_dict[equipment] = 1
|
||||
combine_equipment_detail = ""
|
||||
|
||||
# Generate Equipment Redirect LInk
|
||||
url = env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
menuId = env.ref('maintenance.menu_equipment_form').sudo().id
|
||||
actionId = env.ref('maintenance.hr_equipment_action').sudo().id
|
||||
|
||||
equipment_link = url + '/web#id=' + str(equipment.id) + '&menu_id=' + str(menuId) + '&action=' + str(
|
||||
actionId) + '&model=maintenance.equipment&view_type=form'
|
||||
|
||||
# Prepare main Equipment Detail
|
||||
main_equipment_detail = ""
|
||||
main_equipment_detail = main_equipment_detail.join(
|
||||
"Name: " + str(equipment.name) + "\n" +
|
||||
"Model: " + str(equipment.model) + "\n" +
|
||||
"Mfg serial no: " + str(equipment.serial_no) + "\n"
|
||||
"Warranty Exp. Date: " +str(equipment.warranty_date) + "\n"
|
||||
"Category: " +str(equipment.category_id.name)
|
||||
)
|
||||
# main_equipment_detail = equipment_link + '\n' + '\n' + main_equipment_detail
|
||||
|
||||
# Prepare Child Equipment Detail
|
||||
combine_equipment_detail = main_equipment_detail
|
||||
|
||||
combine_equipment_detail += '\n' + '\n' + equipment_link
|
||||
|
||||
# Generate Qr Code depends on Details
|
||||
qr_image = generate_qr_code(combine_equipment_detail)
|
||||
equipment.write({
|
||||
'qr_code': qr_image
|
||||
})
|
||||
env.cr.commit()
|
||||
page_numbers = (len(equipment_ids) - 1) // (equipment_label_layout_id.rows * equipment_label_layout_id.columns) + 1
|
||||
|
||||
dict_equipment = {
|
||||
'rows': equipment_label_layout_id.rows,
|
||||
'columns': equipment_label_layout_id.columns,
|
||||
'page_numbers': page_numbers,
|
||||
'equipment_data': equipment_dict
|
||||
}
|
||||
return dict_equipment
|
||||
|
||||
|
||||
class ReportProductTemplateLabel(models.AbstractModel):
|
||||
_name = 'report.aspl_equipment_qrcode_generator.maintenance_quip'
|
||||
_description = 'Equipment QR-code Report'
|
||||
|
||||
def _get_report_values(self, docids, data):
|
||||
return _prepare_data(self.env, data)
|
||||
def _get_report_values(self, docids, data=None):
|
||||
"""
|
||||
QR-Code-Generierung erfolgt im Wizard (equipment_label_layout.py).
|
||||
Dieser Report rendert nur die bereits generierten QR-Codes.
|
||||
"""
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
equipment_label_layout_id = self.env['equipment.label.layout'].browse(data.get('equipment_label_layout_id'))
|
||||
|
||||
equipment_dict = {}
|
||||
for equipment in equipment_label_layout_id.equipment_ids:
|
||||
equipment_dict[equipment] = 1
|
||||
|
||||
page_numbers = (len(equipment_label_layout_id.equipment_ids) - 1) // (
|
||||
equipment_label_layout_id.rows * equipment_label_layout_id.columns
|
||||
) + 1
|
||||
|
||||
return {
|
||||
'rows': equipment_label_layout_id.rows,
|
||||
'columns': equipment_label_layout_id.columns,
|
||||
'page_numbers': page_numbers,
|
||||
'equipment_data': equipment_dict
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ class EquipmentLabelLayout(models.TransientModel):
|
|||
rows = fields.Integer(compute='_compute_dimensions')
|
||||
columns = fields.Integer(compute='_compute_dimensions')
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Override to properly set equipment_ids from context"""
|
||||
res = super().default_get(fields_list)
|
||||
if self.env.context.get('active_ids'):
|
||||
res['equipment_ids'] = [(6, 0, self.env.context.get('active_ids', []))]
|
||||
elif self.env.context.get('default_equipment_ids'):
|
||||
res['equipment_ids'] = [(6, 0, self.env.context.get('default_equipment_ids', []))]
|
||||
return res
|
||||
|
||||
@api.depends('print_format')
|
||||
def _compute_dimensions(self):
|
||||
for wizard in self:
|
||||
|
|
@ -28,9 +38,62 @@ class EquipmentLabelLayout(models.TransientModel):
|
|||
|
||||
|
||||
def process_label(self):
|
||||
# Generiere QR-Codes für alle Equipment VOR dem Report
|
||||
self._generate_qr_codes()
|
||||
|
||||
xml_id = 'aspl_equipment_qrcode_generator.report_equipment_label'
|
||||
data = {
|
||||
'equipment_label_layout_id':self.id
|
||||
'equipment_label_layout_id': self.id
|
||||
}
|
||||
|
||||
return self.env.ref(xml_id).report_action(None, data=data)
|
||||
# report_action benötigt die Equipment IDs als docids
|
||||
return self.env.ref(xml_id).report_action(self.equipment_ids.ids, data=data)
|
||||
|
||||
def _generate_qr_codes(self):
|
||||
"""Generiert QR-Codes für alle ausgewählten Equipment"""
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import qrcode
|
||||
|
||||
# Hole die base_url für die Equipment-Links
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
menu_id = self.env.ref('maintenance.menu_equipment_form').sudo().id
|
||||
action_id = self.env.ref('maintenance.hr_equipment_action').sudo().id
|
||||
|
||||
for equipment in self.equipment_ids:
|
||||
# Extrahiere Namen aus JSONB falls nötig
|
||||
equipment_name = equipment.name
|
||||
if isinstance(equipment_name, dict):
|
||||
equipment_name = equipment_name.get('de_DE') or equipment_name.get('en_US') or str(equipment_name)
|
||||
|
||||
category_name = equipment.category_id.name if equipment.category_id else ""
|
||||
if isinstance(category_name, dict):
|
||||
category_name = category_name.get('de_DE') or category_name.get('en_US') or str(category_name)
|
||||
|
||||
# Erstelle Equipment-Link
|
||||
equipment_link = f"{base_url}/web#id={equipment.id}&menu_id={menu_id}&action={action_id}&model=maintenance.equipment&view_type=form"
|
||||
|
||||
# Erstelle Equipment-Details für QR-Code
|
||||
qr_data = f"Name: {equipment_name}\n"
|
||||
qr_data += f"Model: {equipment.model or ''}\n"
|
||||
qr_data += f"Mfg serial no: {equipment.serial_no or ''}\n"
|
||||
qr_data += f"Warranty Exp. Date: {equipment.warranty_date or ''}\n"
|
||||
qr_data += f"Category: {category_name}\n\n"
|
||||
qr_data += equipment_link
|
||||
|
||||
# Generiere QR-Code
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=20,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(qr_data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image()
|
||||
temp = BytesIO()
|
||||
img.save(temp, format="PNG")
|
||||
qr_image = base64.b64encode(temp.getvalue())
|
||||
|
||||
# Speichere QR-Code im Equipment
|
||||
equipment.write({'qr_code': qr_image})
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<group>
|
||||
<field name="equipment_ids" invisible="1"/>
|
||||
<group>
|
||||
<field name="print_format" widget="radio"/>
|
||||
</group>
|
||||
|
|
|
|||
|
|
@ -1,300 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin Name: OpenWorkshop Odoo API
|
||||
* Plugin URI: https://hobbyhimmel.de/
|
||||
* Description: Bindet Daten aus Odoo (OpenWorkshop) per REST-API in WordPress ein. Stellt u.a. den Shortcode [openworkshop_machines] bereit.
|
||||
* Version: 0.1.0
|
||||
* Author: HobbyHimmel / Matthias Lotz
|
||||
* License: GPLv3
|
||||
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
* Text Domain: openworkshop-odoo-api
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class OpenWorkshop_Odoo_API_Plugin {
|
||||
|
||||
const OPTION_GROUP = 'openworkshop_odoo_api_options';
|
||||
const OPTION_NAME = 'openworkshop_odoo_api_settings';
|
||||
|
||||
public function __construct() {
|
||||
add_action( 'admin_menu', array( $this, 'register_settings_page' ) );
|
||||
add_action( 'admin_init', array( $this, 'register_settings' ) );
|
||||
add_shortcode( 'openworkshop_machines', array( $this, 'shortcode_machines' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register settings page under Settings → OpenWorkshop Odoo API
|
||||
*/
|
||||
public function register_settings_page() {
|
||||
add_options_page(
|
||||
__( 'OpenWorkshop Odoo API', 'openworkshop-odoo-api' ),
|
||||
__( 'OpenWorkshop Odoo API', 'openworkshop-odoo-api' ),
|
||||
'manage_options',
|
||||
'openworkshop-odoo-api',
|
||||
array( $this, 'render_settings_page' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register settings (base URL + optional token)
|
||||
*/
|
||||
public function register_settings() {
|
||||
register_setting(
|
||||
self::OPTION_GROUP,
|
||||
self::OPTION_NAME,
|
||||
array(
|
||||
'type' => 'array',
|
||||
'sanitize_callback' => array( $this, 'sanitize_settings' ),
|
||||
'default' => array(
|
||||
'base_url' => '',
|
||||
'api_token' => '',
|
||||
'machines_endpoint' => '/api/v1/machines',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'openworkshop_odoo_api_main',
|
||||
__( 'Odoo API Einstellungen', 'openworkshop-odoo-api' ),
|
||||
'__return_false',
|
||||
'openworkshop-odoo-api'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'base_url',
|
||||
__( 'Odoo Basis-URL', 'openworkshop-odoo-api' ),
|
||||
array( $this, 'render_field_base_url' ),
|
||||
'openworkshop-odoo-api',
|
||||
'openworkshop_odoo_api_main'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'machines_endpoint',
|
||||
__( 'Endpoint für Maschinenliste', 'openworkshop-odoo-api' ),
|
||||
array( $this, 'render_field_machines_endpoint' ),
|
||||
'openworkshop-odoo-api',
|
||||
'openworkshop_odoo_api_main'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'api_token',
|
||||
__( 'API Token (optional)', 'openworkshop-odoo-api' ),
|
||||
array( $this, 'render_field_api_token' ),
|
||||
'openworkshop-odoo-api',
|
||||
'openworkshop_odoo_api_main'
|
||||
);
|
||||
}
|
||||
|
||||
public function sanitize_settings( $input ) {
|
||||
$output = array();
|
||||
|
||||
$output['base_url'] = isset( $input['base_url'] )
|
||||
? esc_url_raw( rtrim( $input['base_url'], '/' ) )
|
||||
: '';
|
||||
|
||||
$output['machines_endpoint'] = isset( $input['machines_endpoint'] )
|
||||
? sanitize_text_field( $input['machines_endpoint'] )
|
||||
: '/api/v1/machines';
|
||||
|
||||
$output['api_token'] = isset( $input['api_token'] )
|
||||
? sanitize_text_field( $input['api_token'] )
|
||||
: '';
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
public function get_settings() {
|
||||
$settings = get_option( self::OPTION_NAME, array() );
|
||||
$defaults = array(
|
||||
'base_url' => '',
|
||||
'api_token' => '',
|
||||
'machines_endpoint' => '/api/v1/machines',
|
||||
);
|
||||
|
||||
return wp_parse_args( $settings, $defaults );
|
||||
}
|
||||
|
||||
public function render_settings_page() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = $this->get_settings();
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'OpenWorkshop Odoo API Einstellungen', 'openworkshop-odoo-api' ); ?></h1>
|
||||
<form method="post" action="options.php">
|
||||
<?php
|
||||
settings_fields( self::OPTION_GROUP );
|
||||
do_settings_sections( 'openworkshop-odoo-api' );
|
||||
submit_button();
|
||||
?>
|
||||
</form>
|
||||
|
||||
<h2><?php esc_html_e( 'Shortcodes', 'openworkshop-odoo-api' ); ?></h2>
|
||||
<p><?php esc_html_e( 'Maschinenliste anzeigen:', 'openworkshop-odoo-api' ); ?></p>
|
||||
<code>[openworkshop_machines]</code>
|
||||
<p><?php esc_html_e( 'Optional können Attribute verwendet werden, um z. B. eine andere Anzahl von Maschinen anzuzeigen.', 'openworkshop-odoo-api' ); ?></p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function render_field_base_url() {
|
||||
$settings = $this->get_settings();
|
||||
?>
|
||||
<input type="text"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[base_url]"
|
||||
value="<?php echo esc_attr( $settings['base_url'] ); ?>"
|
||||
class="regular-text"
|
||||
placeholder="https://odoo.example.org" />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Basis-URL deiner Odoo-Instanz (ohne Slash am Ende). Die API-Route wird daran angehängt.', 'openworkshop-odoo-api' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function render_field_machines_endpoint() {
|
||||
$settings = $this->get_settings();
|
||||
?>
|
||||
<input type="text"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[machines_endpoint]"
|
||||
value="<?php echo esc_attr( $settings['machines_endpoint'] ); ?>"
|
||||
class="regular-text"
|
||||
placeholder="/api/v1/machines" />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Relativer Pfad zum Maschinen-Endpoint. Standard: /api/v1/machines', 'openworkshop-odoo-api' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function render_field_api_token() {
|
||||
$settings = $this->get_settings();
|
||||
?>
|
||||
<input type="text"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[api_token]"
|
||||
value="<?php echo esc_attr( $settings['api_token'] ); ?>"
|
||||
class="regular-text" />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Optionaler API-Token, der im Authorization-Header gesendet wird (Bearer <token>).', 'openworkshop-odoo-api' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcode: [openworkshop_machines]
|
||||
* Attributes:
|
||||
* limit – Anzahl der Einträge (optional)
|
||||
*/
|
||||
public function shortcode_machines( $atts ) {
|
||||
$atts = shortcode_atts(
|
||||
array(
|
||||
'limit' => 0,
|
||||
),
|
||||
$atts,
|
||||
'openworkshop_machines'
|
||||
);
|
||||
|
||||
$data = $this->fetch_machines();
|
||||
|
||||
if ( is_wp_error( $data ) ) {
|
||||
return '<p>' . esc_html__( 'Fehler beim Laden der Maschinendaten aus Odoo.', 'openworkshop-odoo-api' ) . '</p>';
|
||||
}
|
||||
|
||||
if ( ! is_array( $data ) || empty( $data ) ) {
|
||||
return '<p>' . esc_html__( 'Keine Maschinen gefunden.', 'openworkshop-odoo-api' ) . '</p>';
|
||||
}
|
||||
|
||||
$limit = intval( $atts['limit'] );
|
||||
if ( $limit > 0 ) {
|
||||
$data = array_slice( $data, 0, $limit );
|
||||
}
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="openworkshop-machines">
|
||||
<table class="openworkshop-machines-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Name', 'openworkshop-odoo-api' ); ?></th>
|
||||
<th><?php esc_html_e( 'Bereich', 'openworkshop-odoo-api' ); ?></th>
|
||||
<th><?php esc_html_e( 'Status', 'openworkshop-odoo-api' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $data as $machine ) : ?>
|
||||
<tr>
|
||||
<td>
|
||||
<?php echo isset( $machine['name'] ) ? esc_html( $machine['name'] ) : ''; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo isset( $machine['area'] ) ? esc_html( $machine['area'] ) : ''; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo isset( $machine['status'] ) ? esc_html( $machine['status'] ) : ''; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Odoo API and returns decoded JSON or WP_Error.
|
||||
*/
|
||||
protected function fetch_machines() {
|
||||
$settings = $this->get_settings();
|
||||
|
||||
if ( empty( $settings['base_url'] ) ) {
|
||||
return new WP_Error( 'openworkshop_no_base_url', __( 'Keine Odoo Basis-URL konfiguriert.', 'openworkshop-odoo-api' ) );
|
||||
}
|
||||
|
||||
$url = $settings['base_url'] . $settings['machines_endpoint'];
|
||||
|
||||
$args = array(
|
||||
'timeout' => 10,
|
||||
'headers' => array(
|
||||
'Accept' => 'application/json',
|
||||
),
|
||||
);
|
||||
|
||||
if ( ! empty( $settings['api_token'] ) ) {
|
||||
$args['headers']['Authorization'] = 'Bearer ' . $settings['api_token'];
|
||||
}
|
||||
|
||||
$response = wp_remote_get( $url, $args );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
|
||||
if ( $code < 200 || $code >= 300 ) {
|
||||
return new WP_Error( 'openworkshop_bad_status', sprintf( 'HTTP %d', $code ) );
|
||||
}
|
||||
|
||||
$data = json_decode( $body, true );
|
||||
if ( json_last_error() !== JSON_ERROR_NONE ) {
|
||||
return new WP_Error( 'openworkshop_json_error', json_last_error_msg() );
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
function openworkshop_odoo_api_bootstrap() {
|
||||
static $instance = null;
|
||||
if ( $instance === null ) {
|
||||
$instance = new OpenWorkshop_Odoo_API_Plugin();
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
|
||||
openworkshop_odoo_api_bootstrap();
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
=== OpenWorkshop Odoo API ===
|
||||
Contributors: hobbyhimmel
|
||||
Tags: odoo, api, openworkshop, integration
|
||||
Requires at least: 5.8
|
||||
Tested up to: 6.6
|
||||
Stable tag: 0.1.0
|
||||
License: GPLv3
|
||||
License URI: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
Dieses Plugin bindet Maschinendaten aus einer Odoo/OpenWorkshop-Installation via REST-API in WordPress ein.
|
||||
|
||||
== Beschreibung ==
|
||||
|
||||
Das Plugin stellt u.a. den Shortcode [openworkshop_machines] bereit, der eine einfache Maschinenliste
|
||||
auf Basis eines JSON-Endpunkts in Odoo rendert.
|
||||
|
||||
In den Einstellungen kann die Basis-URL der Odoo-Instanz, der Endpoint (z.B. /api/v1/machines) sowie ein
|
||||
optionaler API-Token hinterlegt werden.
|
||||
|
||||
== Installation ==
|
||||
|
||||
1. ZIP in WordPress unter Plugins → Installieren → Plugin hochladen hochladen.
|
||||
2. Aktivieren.
|
||||
3. Unter Einstellungen → OpenWorkshop Odoo API die Basis-URL und den Endpoint konfigurieren.
|
||||
4. Den Shortcode [openworkshop_machines] in einer Seite oder einem Beitrag einfügen.
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 0.1.0 =
|
||||
* Erste Version.
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../../../usr/lib/python3/dist-packages/odoo"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from . import models
|
||||
from . import controllers
|
||||
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
'name': 'POS Open Workshop',
|
||||
'license': 'AGPL-3',
|
||||
'version': '18.0.1.0.1',
|
||||
'summary': 'Erstellt Maschinenfreigaben basierend auf POS-Einweisungsprodukten',
|
||||
'depends': ['base', 'account', 'hr','product','sale','contacts','point_of_sale'],
|
||||
'author': 'matthias.lotz',
|
||||
'category': 'Point of Sale',
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/machine_product_training_views.xml',
|
||||
'views/menu_views.xml',
|
||||
'views/machine_area_views.xml',
|
||||
'views/machine_views.xml',
|
||||
'views/res_partner_view.xml',
|
||||
'data/data.xml',
|
||||
],
|
||||
'installable': False, # Wird durch open_workshop_base ersetzt
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'open_workshop/static/src/css/category_color.css',
|
||||
],
|
||||
'point_of_sale._assets_pos': [
|
||||
'open_workshop/static/src/css/pos.css',
|
||||
'open_workshop/static/src/js/ows_machine_access_list.js',
|
||||
'open_workshop/static/src/js/ows_pos_customer_sidebar.js',
|
||||
'open_workshop/static/src/js/ows_pos_sidebar.js',
|
||||
'open_workshop/static/src/js/ows_product_screen_template_patch.js',
|
||||
'open_workshop/static/src/xml/ows_machine_access_list.xml',
|
||||
'open_workshop/static/src/xml/ows_pos_customer_sidebar.xml',
|
||||
'open_workshop/static/src/xml/ows_pos_sidebar.xml',
|
||||
'open_workshop/static/src/xml/ows_product_screen_template_patch.xml',
|
||||
],
|
||||
},
|
||||
'description': """
|
||||
Diese App erstellt Maschinenfreigaben basierend auf POS-Einweisungsprodukten.
|
||||
Die App ist für den Einsatz in der Odoo-Version 18.0 konzipiert.
|
||||
""",
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# Datei: open_workshop/controllers/__init__.py
|
||||
|
||||
from . import pos_access
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
class OpenWorkshopPOSController(http.Controller):
|
||||
|
||||
@http.route('/open_workshop/partner_access', type='json', auth='user')
|
||||
def get_partner_machine_access(self, **kwargs):
|
||||
partner_id = kwargs.get('params', {}).get('partner_id')
|
||||
if not partner_id:
|
||||
return {"error": "Missing partner_id"}
|
||||
|
||||
Machine = request.env['ows.machine'].sudo()
|
||||
return Machine.get_access_list_grouped(partner_id)
|
||||
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
<odoo>
|
||||
<!-- Bereiche -->
|
||||
<record id="area_fablab" model="ows.machine.area">
|
||||
<field name="name">Fablab</field>
|
||||
<field name="color_hex">#008000</field>
|
||||
</record>
|
||||
|
||||
<record id="area_holz" model="ows.machine.area">
|
||||
<field name="name">Holzbereich</field>
|
||||
<field name="color_hex">#ff0000</field>
|
||||
</record>
|
||||
|
||||
<record id="area_metall" model="ows.machine.area">
|
||||
<field name="name">Metallbereich</field>
|
||||
<field name="color_hex">#0000ff</field>
|
||||
</record>
|
||||
|
||||
<record id="area_elektronik" model="ows.machine.area">
|
||||
<field name="name">Elektronikbereich</field>
|
||||
<field name="color_hex">#ffff00</field>
|
||||
</record>
|
||||
|
||||
<!-- Maschinen im Fablab -->
|
||||
<record id="machine_sabako_laser" model="ows.machine">
|
||||
<field name="name">Sabako Laser</field>
|
||||
<field name="code">sabako_laser</field>
|
||||
<field name="area_id" ref="area_fablab"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_prusa" model="ows.machine">
|
||||
<field name="name">Prusa</field>
|
||||
<field name="code">prusa</field>
|
||||
<field name="area_id" ref="area_fablab"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_prusa_mmu" model="ows.machine">
|
||||
<field name="name">Prusa MMU</field>
|
||||
<field name="code">prusa_mmu</field>
|
||||
<field name="area_id" ref="area_fablab"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_3d_delta" model="ows.machine">
|
||||
<field name="name">3D Delta</field>
|
||||
<field name="code">3d_delta</field>
|
||||
<field name="area_id" ref="area_fablab"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_cnc_beamicon" model="ows.machine">
|
||||
<field name="name">CNC Beamicon</field>
|
||||
<field name="code">cnc_beamicon</field>
|
||||
<field name="area_id" ref="area_fablab"/>
|
||||
</record>
|
||||
|
||||
<!-- Maschinen im Holzbereich -->
|
||||
<record id="machine_formatkreissaege" model="ows.machine">
|
||||
<field name="name">Formatkreissäge</field>
|
||||
<field name="code">formatkreissaege</field>
|
||||
<field name="area_id" ref="area_holz"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_bandsaege_holz" model="ows.machine">
|
||||
<field name="name">Bandsäge</field>
|
||||
<field name="code">bandsaege_holz</field>
|
||||
<field name="area_id" ref="area_holz"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_abrichte" model="ows.machine">
|
||||
<field name="name">Abricht Dickenhobel</field>
|
||||
<field name="code">dickenhobel</field>
|
||||
<field name="area_id" ref="area_holz"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_drechselbank" model="ows.machine">
|
||||
<field name="name">Drechselbank</field>
|
||||
<field name="code">drechselbank</field>
|
||||
<field name="area_id" ref="area_holz"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_festool_domino" model="ows.machine">
|
||||
<field name="name">Festool Domino Fräse</field>
|
||||
<field name="code">festool_domino</field>
|
||||
<field name="area_id" ref="area_holz"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_maffel_duo" model="ows.machine">
|
||||
<field name="name">Maffel Duo Dübler</field>
|
||||
<field name="code">maffel_duo</field>
|
||||
<field name="area_id" ref="area_holz"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_lamello" model="ows.machine">
|
||||
<field name="name">Lamello Zeta P2</field>
|
||||
<field name="code">lamello_zeta_p2</field>
|
||||
<field name="area_id" ref="area_holz"/>
|
||||
</record>
|
||||
|
||||
<!-- Maschinen im Metallbereich -->
|
||||
<record id="machine_kreissaege_metall" model="ows.machine">
|
||||
<field name="name">Kreissäge</field>
|
||||
<field name="code">kreissaege_metall</field>
|
||||
<field name="area_id" ref="area_metall"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_bandsaege_metall" model="ows.machine">
|
||||
<field name="name">Bandsäge</field>
|
||||
<field name="code">bandsaege_metall</field>
|
||||
<field name="area_id" ref="area_metall"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_mig_mag" model="ows.machine">
|
||||
<field name="name">MIG/MAG Schweißgeräte</field>
|
||||
<field name="code">mig_mag</field>
|
||||
<field name="area_id" ref="area_metall"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_wig" model="ows.machine">
|
||||
<field name="name">WIG Schweißgerät</field>
|
||||
<field name="code">wig</field>
|
||||
<field name="area_id" ref="area_metall"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_schweissen" model="ows.machine">
|
||||
<field name="name">Schweißen allgemein</field>
|
||||
<field name="code">schweissen_allgemein</field>
|
||||
<field name="area_id" ref="area_metall"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_drehbank" model="ows.machine">
|
||||
<field name="name">Drehbank</field>
|
||||
<field name="code">drehbank</field>
|
||||
<field name="area_id" ref="area_metall"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_fraese" model="ows.machine">
|
||||
<field name="name">Fräse</field>
|
||||
<field name="code">fraese</field>
|
||||
<field name="area_id" ref="area_metall"/>
|
||||
</record>
|
||||
|
||||
<record id="machine_abkantbank" model="ows.machine">
|
||||
<field name="name">Abkantbank</field>
|
||||
<field name="code">abkantbank</field>
|
||||
<field name="area_id" ref="area_metall"/>
|
||||
</record>
|
||||
|
||||
<!-- Maschine im Elektronikbereich -->
|
||||
<record id="machine_loetkolben" model="ows.machine">
|
||||
<field name="name">Lötkolben</field>
|
||||
<field name="code">loetkolben</field>
|
||||
<field name="area_id" ref="area_elektronik"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from . import ows_models
|
||||
from . import pos_order
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,589 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# ows_models.py
|
||||
# Part of Odoo Open Workshop
|
||||
|
||||
from odoo import models, fields, api
|
||||
from markupsafe import escape as html_escape
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.info("✅ ows_models.py geladen")
|
||||
|
||||
class HREmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
@api.model
|
||||
def anonymize_for_testsystem(self):
|
||||
"""Benennt Admin-Angestellten um und archiviert alle anderen für das Testsystem."""
|
||||
admin_user = self.env['res.users'].search([('name', '=', 'Administrator')], limit=1)
|
||||
if not admin_user:
|
||||
_logger.error("[OWS] Administrator-Benutzer nicht gefunden!")
|
||||
return
|
||||
|
||||
_logger.info(f"[OWS] Administrator-Benutzer gefunden: {admin_user.name} (ID: {admin_user.id})")
|
||||
|
||||
# Suche auch archivierte Employees
|
||||
admin_employee = self.with_context(active_test=False).search([('user_id', '=', admin_user.id)], limit=1)
|
||||
|
||||
if admin_employee:
|
||||
# Administrator-Employee reaktivieren und umbenennen
|
||||
admin_employee.write({
|
||||
'name': 'TESTSYSTEM',
|
||||
'job_title': 'Testumgebung',
|
||||
'work_email': 'office@hobbyhimmel.de',
|
||||
'work_phone': False,
|
||||
'active': True, # Reaktivieren falls archiviert
|
||||
})
|
||||
_logger.info(f"[OWS] Admin-Angestellter reaktiviert und umbenannt: {admin_employee.name} (ID: {admin_employee.id})")
|
||||
else:
|
||||
_logger.warning("[OWS] Kein Angestellter für Administrator gefunden.")
|
||||
return
|
||||
|
||||
# Alle anderen Angestellten archivieren (auch bereits archivierte berücksichtigen)
|
||||
other_employees = self.with_context(active_test=False).search([('id', '!=', admin_employee.id)])
|
||||
other_employees.write({'active': False})
|
||||
_logger.info("[OWS] %d Angestellte archiviert.", len(other_employees))
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
_logger.info("✅ ows ResPartner geladen")
|
||||
|
||||
ows_user_id = fields.One2many('ows.user', 'partner_id', string="OWS Benutzerdaten")
|
||||
|
||||
# Alte Felder (weiterhin sichtbar für externe Programme)
|
||||
birthday = fields.Date(
|
||||
string="Geburtstag",
|
||||
compute='_compute_ows_user_fields',
|
||||
inverse='_inverse_birthday',
|
||||
store=False
|
||||
)
|
||||
rfid_card = fields.Text(
|
||||
string="RFID Card ID",
|
||||
compute='_compute_ows_user_fields',
|
||||
inverse='_inverse_rfid_card',
|
||||
store=False
|
||||
)
|
||||
security_briefing = fields.Boolean(
|
||||
string="Haftungsausschluss",
|
||||
compute='_compute_ows_user_fields',
|
||||
inverse='_inverse_security_briefing',
|
||||
store=False
|
||||
)
|
||||
security_id = fields.Text(
|
||||
string="Haftungsausschluss ID",
|
||||
compute='_compute_ows_user_fields',
|
||||
inverse='_inverse_security_id',
|
||||
store=False
|
||||
)
|
||||
# Neue direkte vvow_* Felder
|
||||
vvow_birthday = fields.Date(string="Geburtstag (vvow)")
|
||||
vvow_rfid_card = fields.Text(string="RFID Card ID (vvow)")
|
||||
vvow_security_briefing = fields.Boolean(string="Haftungsausschluss (vvow)")
|
||||
vvow_security_id = fields.Text(string="Haftungsausschluss ID (vvow)")
|
||||
|
||||
@api.depends('ows_user_id')
|
||||
def _compute_ows_user_fields(self):
|
||||
for partner in self:
|
||||
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||
partner.birthday = user.birthday if user else False
|
||||
partner.rfid_card = user.rfid_card if user else False
|
||||
partner.security_briefing = user.security_briefing if user else False
|
||||
partner.security_id = user.security_id if user else False
|
||||
|
||||
def _inverse_birthday(self):
|
||||
for partner in self:
|
||||
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||
if user:
|
||||
user.birthday = partner.birthday
|
||||
|
||||
def _inverse_rfid_card(self):
|
||||
for partner in self:
|
||||
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||
if user:
|
||||
user.rfid_card = partner.rfid_card
|
||||
|
||||
def _inverse_security_briefing(self):
|
||||
for partner in self:
|
||||
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||
if user:
|
||||
user.security_briefing = partner.security_briefing
|
||||
|
||||
def _inverse_security_id(self):
|
||||
for partner in self:
|
||||
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||
if user:
|
||||
user.security_id = partner.security_id
|
||||
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
partners = super().create(vals_list)
|
||||
for vals, partner in zip(vals_list, partners):
|
||||
self.env['ows.user'].create({
|
||||
'partner_id': partner.id,
|
||||
'birthday': vals.get('birthday') or vals.get('vvow_birthday'),
|
||||
'rfid_card': vals.get('rfid_card') or vals.get('vvow_rfid_card'),
|
||||
'security_briefing': vals.get('security_briefing') or vals.get('vvow_security_briefing'),
|
||||
'security_id': vals.get('security_id') or vals.get('vvow_security_id'),
|
||||
})
|
||||
return partners
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
for partner in self:
|
||||
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||
if not user:
|
||||
continue
|
||||
|
||||
# Synchronisation alt -> user
|
||||
if 'birthday' in vals:
|
||||
user.birthday = vals['birthday']
|
||||
if 'rfid_card' in vals:
|
||||
user.rfid_card = vals['rfid_card']
|
||||
if 'security_briefing' in vals:
|
||||
user.security_briefing = vals['security_briefing']
|
||||
if 'security_id' in vals:
|
||||
user.security_id = vals['security_id']
|
||||
|
||||
# Synchronisation vvow_* -> user + alt
|
||||
if 'vvow_birthday' in vals:
|
||||
user.birthday = vals['vvow_birthday']
|
||||
partner.birthday = vals['vvow_birthday']
|
||||
if 'vvow_rfid_card' in vals:
|
||||
user.rfid_card = vals['vvow_rfid_card']
|
||||
partner.rfid_card = vals['vvow_rfid_card']
|
||||
if 'vvow_security_briefing' in vals:
|
||||
user.security_briefing = vals['vvow_security_briefing']
|
||||
partner.security_briefing = vals['vvow_security_briefing']
|
||||
if 'vvow_security_id' in vals:
|
||||
user.security_id = vals['vvow_security_id']
|
||||
partner.security_id = vals['vvow_security_id']
|
||||
return res
|
||||
|
||||
machine_access_ids = fields.One2many(
|
||||
'ows.machine.access',
|
||||
'partner_id',
|
||||
string='Maschinenfreigaben'
|
||||
)
|
||||
|
||||
machine_access_html = fields.Html(
|
||||
string="Maschinenfreigabe",
|
||||
compute="_compute_machine_access_html",
|
||||
sanitize=False
|
||||
)
|
||||
|
||||
|
||||
@api.depends('machine_access_ids')
|
||||
def _compute_machine_access_html(self):
|
||||
areas = self.env['ows.machine.area'].search([], order="name")
|
||||
for partner in self:
|
||||
html = ""
|
||||
for area in areas:
|
||||
html += f"""
|
||||
<div class="o_form_sheet">
|
||||
<h3 class="o_form_label">{area.name}</h3>
|
||||
<table class="table table-sm table-bordered o_form_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Maschine</th>
|
||||
<th>Status</th>
|
||||
<th>Datum</th>
|
||||
<th>Gültig bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
"""
|
||||
|
||||
machines = self.env['ows.machine'].search([('area_id', '=', area.id)], order="name")
|
||||
|
||||
for machine in machines:
|
||||
access = self.env['ows.machine.access'].search([
|
||||
('partner_id', '=', partner.id),
|
||||
('machine_id', '=', machine.id),
|
||||
], limit=1)
|
||||
icon = '<span class="text-success fa fa-check"/>' if access else '<span class="text-danger fa fa-times"/>'
|
||||
date_granted = access.date_granted.strftime('%Y-%m-%d') if access and access.date_granted else "-"
|
||||
date_expiry = access.date_expiry.strftime('%Y-%m-%d') if access and access.date_expiry else "-"
|
||||
|
||||
html += f"""
|
||||
<tr>
|
||||
<td>{machine.name}</td>
|
||||
<td>{icon}</td>
|
||||
<td>{date_granted}</td>
|
||||
<td>{date_expiry}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
html += "</tbody></table></div>"
|
||||
|
||||
partner.machine_access_html = html
|
||||
|
||||
|
||||
|
||||
|
||||
@api.model
|
||||
def migrate_existing_partners(self):
|
||||
"""
|
||||
Erstellt für alle vorhandenen res.partner einen ows.user,
|
||||
wenn noch keiner existiert, und übernimmt alte vvow_* Felder.
|
||||
Führt am Ende automatisch einen commit durch.
|
||||
|
||||
Verwendung in der odoo shell:
|
||||
env['res.partner'].migrate_existing_partners()
|
||||
env['ows.user'].search_count([])
|
||||
env['ows.user'].search([]).mapped('partner_id.name')
|
||||
"""
|
||||
migrated = 0
|
||||
skipped = 0
|
||||
|
||||
partners = self.env['res.partner'].search([])
|
||||
for partner in partners:
|
||||
if partner.ows_user_id:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Werte lesen (werden evtl. durch _inherit hinzugefügt)
|
||||
vals = {
|
||||
'partner_id': partner.id,
|
||||
'birthday': getattr(partner, 'vvow_birthday', False),
|
||||
'rfid_card': getattr(partner, 'vvow_rfid_card', False),
|
||||
'security_briefing': getattr(partner, 'vvow_security_briefing', False),
|
||||
'security_id': getattr(partner, 'vvow_security_id', False),
|
||||
}
|
||||
self.env['ows.user'].create(vals)
|
||||
migrated += 1
|
||||
_logger.info(f"Erzeuge ows.user für Partner {partner.id} ({partner.name}) mit Werten: {vals}")
|
||||
|
||||
_logger.info(f"[OWS Migration] ✅ Migriert: {migrated}, ❌ Übersprungen: {skipped}")
|
||||
|
||||
# 🔐 Commit am Ende
|
||||
self.env.cr.commit()
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': "Migration abgeschlossen",
|
||||
'message': f"{migrated} Partner migriert, {skipped} übersprungen.",
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
@api.model
|
||||
def migrate_machine_access_from_old_fields(self):
|
||||
"""
|
||||
Migriert alte vvow_* Boolean-Felder in strukturierte ows.machine.access Einträge.
|
||||
Das Freigabe-Datum wird aus dem Änderungsverlauf (mail.message.tracking_value_ids) extrahiert.
|
||||
"""
|
||||
mapping = [
|
||||
('vvow_holz_sander', 'bandschleifer'),
|
||||
('vvow_holz_felder_bandsaw', 'bandsaege_holz'),
|
||||
('vvow_holz_felder_jointer', 'dickenhobel'),
|
||||
('vvow_holz_felder_mill', 'felder_fraese'),
|
||||
('vvow_holz_felder_tablesaw', 'formatkreissaege'),
|
||||
('vvow_holz_mill', 'tischfraese'),
|
||||
('vvow_holz_lathe', 'drechselbank'),
|
||||
('vvow_holz_domino', 'festool_domino'),
|
||||
('vvow_holz_duoduebler', 'maffel_duo'),
|
||||
('vvow_holz_lamello', 'lamello_zeta_p2'),
|
||||
('vvow_fablab_laser_sabko', 'sabako_laser'),
|
||||
('vvow_fablab_3dprint_delta', '3d_delta'),
|
||||
('vvow_fablab_3dprint_prusa', 'prusa'),
|
||||
('vvow_fablab_3dprint_prusa_mmu', 'prusa_mmu'),
|
||||
('vvow_fablab_cnc_beamicon', 'cnc_beamicon'),
|
||||
('vvow_metall_welding_mig', 'mig_mag'),
|
||||
('vvow_metall_welding_wig', 'wig'),
|
||||
('vvow_metall_welding_misc', 'schweissen_allgemein'),
|
||||
('vvow_metall_lathe', 'drehbank'),
|
||||
('vvow_metall_bandsaw', 'bandsaege_metall'),
|
||||
('vvow_metall_mill_fp2', 'fraese'),
|
||||
('vvow_metall_bende', 'abkantbank'),
|
||||
('vvow_metall_chop_saw', 'kreissaege_metall'),
|
||||
]
|
||||
|
||||
MachineAccess = self.env['ows.machine.access']
|
||||
MailMessage = self.env['mail.message']
|
||||
TrackingValue = self.env['mail.tracking.value']
|
||||
|
||||
count_created = 0
|
||||
|
||||
for partner in self.search([]):
|
||||
for field_name, machine_code in mapping:
|
||||
if not getattr(partner, field_name, False):
|
||||
continue
|
||||
|
||||
machine = self.env['ows.machine'].search([('code', '=', machine_code)], limit=1)
|
||||
if not machine:
|
||||
continue
|
||||
|
||||
# Änderungsverlauf durchsuchen: Wann wurde das Feld auf True gesetzt?
|
||||
tracking = TrackingValue.search([
|
||||
('field', '=', field_name),
|
||||
('field_type', '=', 'boolean'),
|
||||
('new_value_integer', '=', 1),
|
||||
('mail_message_id.model', '=', 'res.partner'),
|
||||
('mail_message_id.res_id', '=', partner.id),
|
||||
], order='id ASC', limit=1)
|
||||
|
||||
date_granted = tracking.mail_message_id.date if tracking else fields.Datetime.now()
|
||||
|
||||
if not MachineAccess.search([('partner_id', '=', partner.id), ('machine_id', '=', machine.id)]):
|
||||
MachineAccess.create({
|
||||
'partner_id': partner.id,
|
||||
'machine_id': machine.id,
|
||||
'date_granted': date_granted,
|
||||
})
|
||||
count_created += 1
|
||||
|
||||
_logger.info(f"[OWS Migration] ✅ Maschinenfreigaben erstellt: {count_created}")
|
||||
self.env.cr.commit()
|
||||
|
||||
@api.model
|
||||
def archive_partners_without_users(self):
|
||||
"""
|
||||
Archiviert alle Partner (res.partner), die keine Benutzer (res.users) sind.
|
||||
"""
|
||||
Partner = self.env['res.partner']
|
||||
User = self.env['res.users']
|
||||
|
||||
# IDs aller Partner, die ein Benutzerkonto haben
|
||||
user_partner_ids = User.search([]).mapped('partner_id').ids
|
||||
|
||||
# Alle Partner ohne Benutzerkonto
|
||||
partners_to_archive = Partner.search([
|
||||
('id', 'not in', user_partner_ids),
|
||||
('active', '=', True),
|
||||
])
|
||||
|
||||
count = len(partners_to_archive)
|
||||
partners_to_archive.write({'active': False})
|
||||
for p in partners_to_archive:
|
||||
_logger.debug(f"[OWS] Archiviert Partner: {p.name} (ID {p.id})")
|
||||
_logger.info(f"[OWS] Archiviert {count} Partner ohne Benutzerkonto.")
|
||||
self.env.cr.commit()
|
||||
|
||||
|
||||
class OwsUser(models.Model):
|
||||
_name = 'ows.user'
|
||||
_description = 'OWS: Benutzerdaten'
|
||||
_table = 'ows_user'
|
||||
_logger.info("✅ ows_user geladen")
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
string='Kontakt'
|
||||
)
|
||||
|
||||
birthday = fields.Date('Geburtstag')
|
||||
rfid_card = fields.Text('RFID Card ID')
|
||||
security_briefing = fields.Boolean('Haftungsausschluss', default=False)
|
||||
security_id = fields.Text('Haftungsausschluss ID')
|
||||
|
||||
_sql_constraints = [
|
||||
('partner_unique', 'unique(partner_id)', 'Jeder Partner darf nur einen OWS-Datensatz haben.')
|
||||
]
|
||||
|
||||
|
||||
AVAILABLE_COLORS = [
|
||||
('#000000', 'schwarz'),
|
||||
('#ff0000', 'Rot'),
|
||||
('#E91E63', 'Pink'),
|
||||
('#9C27B0', 'Lila'),
|
||||
('#3F51B5', 'Indigo'),
|
||||
('#0000ff', 'Blau'),
|
||||
('#008000', 'Grün'),
|
||||
('#ffff00', 'Gelb'),
|
||||
('#FF9800', 'Orange'),
|
||||
('#795548', 'Braun'),
|
||||
('#ffffff', 'Weiss'),
|
||||
]
|
||||
|
||||
class OwsMachineArea(models.Model):
|
||||
_name = 'ows.machine.area'
|
||||
_table = 'ows_machine_area'
|
||||
_description = 'OWS: Maschinenbereich'
|
||||
_order = 'name'
|
||||
|
||||
name = fields.Char(string="Name", required=True, translate=True)
|
||||
|
||||
color_hex = fields.Selection(
|
||||
selection=AVAILABLE_COLORS,
|
||||
string="Farbe (Hex)",
|
||||
required=True,
|
||||
)
|
||||
|
||||
color_hex_value = fields.Char(
|
||||
string="Farbcode",
|
||||
compute='_compute_color_hex_value',
|
||||
store=False
|
||||
)
|
||||
|
||||
color_name = fields.Char(
|
||||
string="Farbname",
|
||||
compute='_compute_color_name',
|
||||
store=False
|
||||
)
|
||||
|
||||
@api.depends('color_hex')
|
||||
def _compute_color_hex_value(self):
|
||||
for rec in self:
|
||||
rec.color_hex_value = rec.color_hex or ''
|
||||
|
||||
@api.depends('color_hex')
|
||||
def _compute_color_name(self):
|
||||
label_dict = dict(AVAILABLE_COLORS)
|
||||
for rec in self:
|
||||
rec.color_name = label_dict.get(rec.color_hex, 'Unbekannt')
|
||||
|
||||
|
||||
|
||||
class OwsMachine(models.Model):
|
||||
_name = 'ows.machine'
|
||||
_table = 'ows_machine'
|
||||
_description = 'OWS: Maschine'
|
||||
|
||||
name = fields.Char(required=True, translate=True)
|
||||
code = fields.Char(required=True, help="Eindeutiger Kurzcode, z.B. 'lasercutter'")
|
||||
category = fields.Selection([
|
||||
('green', 'Kategorie 1: grün'),
|
||||
('yellow', 'Kategorie 2: gelb'),
|
||||
('red', 'Kategorie 3: rot'),
|
||||
], string="Sicherheitskategorie", required=True, default='red', help="Sicherheitsrelevante Maschinenkategorie:\n"
|
||||
"- grün: keine Einweisungspflicht\n"
|
||||
"- gelb: empfohlene Einweisung\n"
|
||||
"- rot: Einweisung zwingend erforderlich")
|
||||
|
||||
category_icon = fields.Char(string="Kategorie-Symbol", compute="_compute_category_icon", store=False)
|
||||
|
||||
@api.depends('category')
|
||||
def _compute_category_icon(self):
|
||||
for rec in self:
|
||||
icon_map = {
|
||||
'green': '🟢',
|
||||
'yellow': '🟡',
|
||||
'red': '🔴',
|
||||
}
|
||||
rec.category_icon = icon_map.get(rec.category, '⚪')
|
||||
|
||||
description = fields.Text()
|
||||
active = fields.Boolean(default=True)
|
||||
area_id = fields.Many2one('ows.machine.area', string='Bereich', help="Bereich, in dem die Maschine oder das Gerät steht.")
|
||||
product_ids = fields.One2many('ows.machine.product', 'machine_id', string="Nutzungsprodukte")
|
||||
product_names = fields.Char(string="Liste der Nutzungsprodukte", compute="_compute_product_using_names", store=False,)
|
||||
training_ids = fields.One2many('ows.machine.training', 'machine_id', string="Einweisungsprodukte")
|
||||
training_names = fields.Char(string="Liste der Einweisungsprodukte", compute="_compute_product_training_names", store=False,)
|
||||
storage_location = fields.Char(string="Lagerort", help="Lagerort der Maschine oder des Geräts.")
|
||||
purchase_price = fields.Float(string="Kaufpreis", help="Kaufpreis der Maschine oder des Geräts.")
|
||||
purchase_date = fields.Date(string="Kaufdatum", help="Kaufdatum der Maschine oder des Geräts.")
|
||||
|
||||
@api.depends('product_ids.product_id.name')
|
||||
def _compute_product_using_names(self):
|
||||
for machine in self:
|
||||
names = machine.product_ids.mapped('product_id.name')
|
||||
machine.product_names = ", ".join(names)
|
||||
|
||||
@api.depends('training_ids.training_id.name')
|
||||
def _compute_product_training_names(self):
|
||||
for machine in self:
|
||||
names = machine.training_ids.mapped('training_id.name')
|
||||
machine.training_names = ", ".join(names)
|
||||
|
||||
_sql_constraints = [
|
||||
('code_unique', 'unique(code)', 'Maschinencode muss eindeutig sein.')
|
||||
]
|
||||
|
||||
def name_get(self):
|
||||
return [(rec.id, f"{rec.name} ({rec.code})") for rec in self]
|
||||
|
||||
@api.model
|
||||
def get_access_list_grouped(self, partner_id):
|
||||
"""
|
||||
Gibt eine gruppierte Liste von Maschinenzugängen für einen bestimmten Partner zurück. Diese Funktion wird in
|
||||
Odoo POS Frontend verwendet um die Ansicht zu erzeugen auf Welche Maschinen der Partner Zugriff hat.
|
||||
|
||||
Für einen gegebenen Partner (über die partner_id) werden alle Maschinenbereiche (areas) abgefragt.
|
||||
Für jeden Bereich wird geprüft, auf welche Maschinen der Partner Zugriff hat. Das Ergebnis wird
|
||||
als Liste von Bereichen mit jeweils zugehörigen Maschinen und Zugriffsstatus zurückgegeben.
|
||||
|
||||
Zusätzlich werden sicherheitsrelevante Informationen des Partners (wie Sicherheitsunterweisung,
|
||||
Sicherheits-ID, RFID-Karte und Geburtstag) aus dem zugehörigen ows_user ermittelt und mitgeliefert.
|
||||
|
||||
Args:
|
||||
partner_id (int): Die ID des Partners, für den die Zugriffsübersicht erstellt werden soll.
|
||||
|
||||
Returns:
|
||||
dict: Ein Dictionary mit folgenden Schlüsseln:
|
||||
- 'access_by_area': Liste von Bereichen mit Maschinen und Zugriffsstatus.
|
||||
- 'security_briefing': Sicherheitsunterweisung des Nutzers (bool oder False).
|
||||
- 'security_id': Sicherheits-ID des Nutzers (str oder '').
|
||||
- 'rfid_card': RFID-Kartennummer des Nutzers (str oder '').
|
||||
- 'birthday': Geburtstag des Nutzers (str oder '').
|
||||
"""
|
||||
partner = self.env['res.partner'].browse(partner_id)
|
||||
areas = self.env['ows.machine.area'].search([], order="name")
|
||||
_logger.info("Access RPC called with partner_id=%s", partner_id)
|
||||
access_by_area = []
|
||||
for area in areas:
|
||||
machines = self.search([('area_id', '=', area.id), ('category', '=', 'red')], order="name")
|
||||
machine_list = []
|
||||
for machine in machines:
|
||||
has_access = bool(self.env['ows.machine.access'].search([
|
||||
('partner_id', '=', partner_id),
|
||||
('machine_id', '=', machine.id),
|
||||
], limit=1))
|
||||
machine_list.append({
|
||||
'name': machine.name,
|
||||
'has_access': has_access,
|
||||
})
|
||||
if machine_list:
|
||||
access_by_area.append({
|
||||
'area': area.name,
|
||||
'color_hex': area.color_hex or '#000000',
|
||||
'machines': machine_list
|
||||
})
|
||||
|
||||
user = partner.ows_user_id[:1]
|
||||
return {
|
||||
'access_by_area': access_by_area,
|
||||
'security_briefing': user.security_briefing if user else False,
|
||||
'security_id': user.security_id if user else '',
|
||||
'rfid_card': user.rfid_card if user else '',
|
||||
'birthday': user.birthday if user else '',
|
||||
}
|
||||
|
||||
|
||||
|
||||
class OwsMachineAccess(models.Model):
|
||||
_name = 'ows.machine.access'
|
||||
_table = 'ows_machine_access'
|
||||
_description = 'OWS: Maschinenfreigabe'
|
||||
_order = 'partner_id, machine_id'
|
||||
|
||||
partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade')
|
||||
machine_id = fields.Many2one('ows.machine', required=True)
|
||||
date_granted = fields.Date(default=fields.Date.today)
|
||||
date_expiry = fields.Date(string="Ablaufdatum")
|
||||
granted_by_pos = fields.Boolean(default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('partner_machine_unique', 'unique(partner_id, machine_id)', 'Der Kunde hat diese Freigabe bereits.')
|
||||
]
|
||||
|
||||
|
||||
class OwsMachineProduct(models.Model):
|
||||
_name = 'ows.machine.product'
|
||||
_table = 'ows_machine_product'
|
||||
_description = 'OWS: Zuordnung Produkt der Nutzung zu der Maschine'
|
||||
|
||||
product_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
|
||||
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
|
||||
|
||||
class OwsMachineTraining(models.Model):
|
||||
_name = 'ows.machine.training'
|
||||
_table = 'ows_machine_training'
|
||||
_description = 'OWS: Zuordnung Produkt der Einweisung zu der Maschine'
|
||||
|
||||
training_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
|
||||
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_ows_machine_access_user,ows.machine.access,model_ows_machine_access,base.group_user,1,1,1,1
|
||||
access_ows_machine_user,ows.machine,model_ows_machine,base.group_user,1,1,1,1
|
||||
access_ows_machine_product_user,ows.machine.product,model_ows_machine_product,base.group_user,1,1,1,1
|
||||
access_ows_machine_training_user,access_ows_machine_training_user,model_ows_machine_training,base.group_user,1,1,1,1
|
||||
access_ows_machine_area,ows.machine.area,model_ows_machine_area,base.group_user,1,1,1,1
|
||||
access_ows_user,ows.user,model_ows_user,base.group_user,1,1,1,1
|
||||
access_ows_machine_training,ows.machine.training,model_ows_machine_training,base.group_user,1,1,1,1
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
.category-color-circle {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
.custompane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ows-sidebar {
|
||||
flex: 1 1 auto;
|
||||
width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.order-entry:hover { cursor: pointer; }
|
||||
.order-entry.selected { background-color: #007bff; color: white; }
|
||||
|
||||
.ows-customer-list {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
min-height: 0; /* notwendig für Scrollbar */
|
||||
}
|
||||
|
||||
.client-details-grid {
|
||||
flex-shrink: 0;
|
||||
max-height: 60%;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pos *::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height:8px;
|
||||
}
|
||||
|
||||
.sidebar-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0.2em 0;
|
||||
}
|
||||
|
||||
.sidebar-date {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-name {
|
||||
flex-shrink: 1;
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
// @odoo-module ows_machine_access_list.js
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { useBus } from "@web/core/utils/hooks";
|
||||
import { usePos } from "@point_of_sale/app/store/pos_hook";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class OwsMachineAccessList extends Component {
|
||||
static template = 'open_workshop.OwsMachineAccessList';
|
||||
|
||||
setup() {
|
||||
this.pos = usePos();
|
||||
|
||||
this.state = useState({
|
||||
client: null,
|
||||
grouped_accesses: [],
|
||||
security_briefing: false,
|
||||
security_id: '',
|
||||
rfid_card: '',
|
||||
birthday: '',
|
||||
});
|
||||
|
||||
// 🔁 Reagiere auf Partnerwechsel über den Odoo-Bus
|
||||
useBus(this.env.bus, 'partner-changed', () => {
|
||||
this.updateAccessList();
|
||||
});
|
||||
|
||||
// 🔃 Beim Mounten initiale Daten laden
|
||||
this.updateAccessList();
|
||||
}
|
||||
|
||||
async updateAccessList() {
|
||||
const order = this.pos.get_order();
|
||||
const partner = order?.get_partner?.();
|
||||
this.state.client = partner || null;
|
||||
if (!partner) {
|
||||
this.state.grouped_accesses = [];
|
||||
this.state.security_briefing = false;
|
||||
this.state.security_id = '';
|
||||
this.state.rfid_card = '';
|
||||
this.state.birthday = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await rpc("/open_workshop/partner_access", {
|
||||
params: { partner_id: partner.id },
|
||||
});
|
||||
|
||||
this.state.grouped_accesses = data.access_by_area || [];
|
||||
this.state.security_briefing = data.security_briefing;
|
||||
this.state.security_id = data.security_id;
|
||||
this.state.rfid_card = data.rfid_card;
|
||||
this.state.birthday = data.birthday;
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Laden der Einweisungen:", error);
|
||||
this.state.grouped_accesses = [];
|
||||
this.state.security_briefing = false;
|
||||
this.state.security_id = '';
|
||||
this.state.rfid_card = '';
|
||||
this.state.birthday = '';
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
registry.category("templates").add("open_workshop.OwsMachineAccessList", OwsMachineAccessList);
|
||||
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
// @odoo-module ows_pos_customer_sidebar.js
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { usePos } from "@point_of_sale/app/store/pos_hook";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { ask } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
|
||||
export class OwsPosCustomerSidebar extends Component {
|
||||
static template = "open_workshop.OwsPosCustomerSidebar";
|
||||
|
||||
setup() {
|
||||
this.pos = usePos(); // ✅ Holt dir Zugriff auf den zentralen POS-Store
|
||||
this.dialog = useService("dialog");
|
||||
}
|
||||
|
||||
addOrder() {
|
||||
this.pos.add_new_order(); // ✅ Neue Order wird aktive Order
|
||||
this.pos.showScreen("ProductScreen");
|
||||
this.pos.selectPartner();
|
||||
this.env.bus.trigger('partner-changed'); // ✅ Event manuell auslösen
|
||||
}
|
||||
|
||||
async removeCurrentOrder() {
|
||||
this.pos.onDeleteOrder(this.pos.get_order())
|
||||
}
|
||||
|
||||
openTicketScreen() {
|
||||
this.pos.showScreen("TicketScreen");
|
||||
}
|
||||
|
||||
// 🔧 FIXED: Zugriff auf Order-Liste korrigiert
|
||||
getFilteredOrderList() {
|
||||
return this.pos.get_open_orders();
|
||||
}
|
||||
|
||||
getDate(order) {
|
||||
const date = new Date(order.date_order);
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mi = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${dd}.${mm}. ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
getPartner(order) {
|
||||
return order.get_partner()?.name || "Kein Kunde";
|
||||
}
|
||||
|
||||
selectOrder(order) {
|
||||
this.pos.set_order(order);
|
||||
this.env.bus.trigger('partner-changed');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
// ows_pos_sidebar.js
|
||||
// @odoo-module
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { OwsPosCustomerSidebar } from "./ows_pos_customer_sidebar";
|
||||
import { OwsMachineAccessList } from "./ows_machine_access_list";
|
||||
|
||||
export class OwsPosSidebar extends Component {
|
||||
static template = "open_workshop.OwsPosSidebar";
|
||||
static components = { OwsPosCustomerSidebar, OwsMachineAccessList };
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
// product_screen_template_patch.js
|
||||
// @odoo-module
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
|
||||
import { OwsPosSidebar } from "./ows_pos_sidebar";
|
||||
|
||||
class OwsProductScreen extends ProductScreen {
|
||||
static components = Object.assign({}, ProductScreen.components, {
|
||||
OwsPosSidebar,
|
||||
});
|
||||
}
|
||||
|
||||
registry.category("pos_screens").remove("ProductScreen");
|
||||
registry.category("pos_screens").add("ProductScreen", OwsProductScreen);
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<t t-name="open_workshop.OwsMachineAccessList">
|
||||
<div class="client-details-grid p-2 small">
|
||||
|
||||
<!-- ✅ Sicherheitsbereich -->
|
||||
<t t-if="state.client">
|
||||
<div class="client-details-header">
|
||||
<ul>
|
||||
<li><span class="client-details-label">Einweisungen</span></li>
|
||||
</ul>
|
||||
|
||||
<div class="client-details-area border" t-att-style="'border: solid 3px #ffffff; margin: 5px;'">
|
||||
<ul>
|
||||
<li class="client-detail">
|
||||
<span class="detail client-details-vvow_briefing">✅</span>
|
||||
<span class="briefinglabel">Werkstatt</span>
|
||||
</li>
|
||||
|
||||
<li class="client-detail">
|
||||
<t t-if="!state.security_briefing">
|
||||
<span class="detail client-details-vvow_briefing_error">❌</span>
|
||||
</t>
|
||||
<t t-if="state.security_briefing">
|
||||
<span class="detail client-details-vvow_briefing">✅</span>
|
||||
</t>
|
||||
<span class="briefinglabel">Haftungsausschluss</span>
|
||||
</li>
|
||||
|
||||
<t t-if="!state.security_briefing">
|
||||
<li class="client-detail">
|
||||
<ul class="subpoints">
|
||||
<span class="detail client-details-vvow_sec_briefing_error">‼️Bitte Prüfen‼️</span>
|
||||
</ul>
|
||||
</li>
|
||||
</t>
|
||||
|
||||
<t t-if="state.security_briefing">
|
||||
<ul class="subpoints">
|
||||
<li class="client-detail">
|
||||
<span class="label">Id:</span>
|
||||
<span class="detail client-details-vvow_security_id">
|
||||
<t t-esc="state.security_id || 'N/A'" />
|
||||
</span>
|
||||
</li>
|
||||
<li class="client-detail">
|
||||
<span class="label">Geburtstag:</span>
|
||||
<span class="detail client-details-vvow_security_id">
|
||||
<t t-esc="state.birthday || 'N/A'" />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ✅ Maschinenliste: immer sichtbar, gefiltert -->
|
||||
<t t-foreach="state.grouped_accesses" t-as="area" t-key="area.area">
|
||||
<t t-if="area.machines.length > 0">
|
||||
<div class="client-details-area" t-att-style="'border: solid 3px ' + area.color_hex + '; margin: 5px;'">
|
||||
<ul>
|
||||
<t t-foreach="area.machines" t-as="machine" t-key="machine.name">
|
||||
<li class="client-detail">
|
||||
<span t-attf-class="detail {{ machine.has_access ? 'client-details-vvow_briefing' : 'client-details-vvow_briefing_error' }}">
|
||||
<t t-esc="machine.has_access ? '✅' : '❌'" />
|
||||
</span>
|
||||
<span class="briefinglabel"><t t-esc="machine.name"/></span>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="open_workshop.OwsPosCustomerSidebar" owl="1">
|
||||
<div class="ows-sidebar p-2 bg-light border-end h-100">
|
||||
<div class="ows-sidebar-header mb-2 d-flex justify-content-between align-items-center">
|
||||
<button class="btn btn-secondary" t-on-click="openTicketScreen">Orders</button>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-success me-1" t-on-click="addOrder">+</button>
|
||||
<button class="btn btn-sm btn-danger" t-on-click="removeCurrentOrder">–</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ows-customer-list overflow-auto">
|
||||
<t t-foreach="getFilteredOrderList()" t-as="order" t-key="order.uid">
|
||||
<!--div class="order-entry p-1 rounded mb-1 border"
|
||||
t-att-class="order === pos.get_order() ? 'bg-primary text-white' : 'bg-white'"
|
||||
t-on-click="() => selectOrder(order)"-->
|
||||
<div t-att-class="'order-entry' + (order === pos.get_order() ? ' selected' : '')" t-on-click="() => this.selectOrder(order)">
|
||||
<div class="sidebar-line">
|
||||
<span class="sidebar-date"><t t-esc="getDate(order)"/></span>
|
||||
<span class="sidebar-name" t-att-title="getPartner(order)"><t t-esc="getPartner(order)"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="open_workshop.OwsPosSidebar" owl="1">
|
||||
<div class="custompane d-flex flex-column border-end bg-200">
|
||||
<OwsPosCustomerSidebar />
|
||||
<OwsMachineAccessList />
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="point_of_sale.ProductScreen" t-inherit="point_of_sale.ProductScreen" t-inherit-mode="extension">
|
||||
<xpath expr="//div[contains(@class, 'leftpane')]" position="before">
|
||||
<OwsPosSidebar />
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<odoo>
|
||||
<template id="assets_open_workshop" inherit_id="point_of_sale._assets_pos">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/open_workshop/static/src/js/machine_access_sidebar.js"/>
|
||||
<link rel="stylesheet" type="text/css" href="/open_workshop/static/src/css/pos.css"/>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<!-- machine_area_views.xml -->
|
||||
<odoo>
|
||||
<!-- Action zum Anzeigen der Bereiche -->
|
||||
<record id="action_machine_area_list" model="ir.actions.act_window">
|
||||
<field name="name">Maschinenbereiche</field>
|
||||
<field name="res_model">ows.machine.area</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Menüpunkt unter Maschinen > Konfiguration -->
|
||||
<menuitem id="menu_machine_area" name="Bereiche" parent="menu_machine_config" action="open_workshop.action_machine_area_list" sequence="30"/>
|
||||
|
||||
<!-- Listenansicht -->
|
||||
<record id="view_machine_area_tree" model="ir.ui.view">
|
||||
<field name="name">ows.machine.area.tree</field>
|
||||
<field name="model">ows.machine.area</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="color_hex_value" string="Farbe (Hex)"/>
|
||||
<field name="color_name" string="Farbname"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Formularansicht -->
|
||||
<record id="view_machine_area_form" model="ir.ui.view">
|
||||
<field name="name">ows.machine.area.form</field>
|
||||
<field name="model">ows.machine.area</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Maschinenbereich">
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="color_hex"/>
|
||||
<field name="color_name" readonly="1"/>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<odoo>
|
||||
<!-- Tree View: Nutzungsprodukte -->
|
||||
<record id="view_machine_product_tree" model="ir.ui.view">
|
||||
<field name="name">ows.machine.product.tree</field>
|
||||
<field name="model">ows.machine.product</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="machine_id"/>
|
||||
<field name="product_id" domain="[('categ_id.name', '=', 'Maschinennutzung')]"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View: Einweisungsprodukte -->
|
||||
<record id="view_machine_training_tree" model="ir.ui.view">
|
||||
<field name="name">ows.machine.training.tree</field>
|
||||
<field name="model">ows.machine.training</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="machine_id"/>
|
||||
<field name="training_id" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action: Nutzungsprodukte -->
|
||||
<record id="action_machine_product" model="ir.actions.act_window">
|
||||
<field name="name">Maschinen-Nutzungsprodukte</field>
|
||||
<field name="res_model">ows.machine.product</field>
|
||||
<field name="view_mode">list</field>
|
||||
<field name="view_id" ref="view_machine_product_tree"/>
|
||||
<field name="help" type="html">
|
||||
<p>Verwalte die Zuordnung von Maschinen zu Nutzungsprodukten.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action: Einweisungsprodukte -->
|
||||
<record id="action_machine_training" model="ir.actions.act_window">
|
||||
<field name="name">Maschinen-Einweisungsprodukte</field>
|
||||
<field name="res_model">ows.machine.training</field>
|
||||
<field name="view_mode">list</field>
|
||||
<field name="view_id" ref="view_machine_training_tree"/>
|
||||
<field name="help" type="html">
|
||||
<p>Verwalte die Zuordnung von Maschinen zu Einweisungsprodukten.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<!-- machine_views.xml -->
|
||||
<odoo>
|
||||
<!-- Maschinen Listenansicht -->
|
||||
<record id="view_machine_tree" model="ir.ui.view">
|
||||
<field name="name">ows.machine.tree</field>
|
||||
<field name="model">ows.machine</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="category_icon" string="⚙" readonly="1"/>
|
||||
<field name="name"/>
|
||||
<field name="category"/>
|
||||
<field name="code"/>
|
||||
<field name="area_id" widget="many2one_color"/>
|
||||
<field name="product_names"/>
|
||||
<field name="training_names"/>
|
||||
<field name="storage_location"/>
|
||||
<field name="purchase_price"/>
|
||||
<field name="purchase_date"/>
|
||||
<field name="active"/>
|
||||
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Maschinen Formularansicht -->
|
||||
<record id="view_machine_form" model="ir.ui.view">
|
||||
<field name="name">ows.machine.form</field>
|
||||
<field name="model">ows.machine</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Maschine">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="category_icon" string="⚙" readonly="1"/>
|
||||
<field name="name"/>
|
||||
<field name="category"/>
|
||||
<field name="code"/>
|
||||
<field name="area_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description"/>
|
||||
<field name="storage_location"/>
|
||||
<field name="purchase_price"/>
|
||||
<field name="purchase_date"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
|
||||
<!-- Notebook für Produkte und Einweisungen -->
|
||||
<notebook>
|
||||
<page string="Nutzungsprodukte">
|
||||
<field name="product_ids" context="{'default_machine_id': id}">
|
||||
<list editable="bottom">
|
||||
<field name="product_id" domain="[('categ_id.name', '=', 'Maschinennutzung')]" />
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Einweisungsprodukte">
|
||||
<field name="training_ids" context="{'default_machine_id': id}">
|
||||
<list editable="bottom">
|
||||
<field name="training_id" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]" />
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
<!-- menu_views.xml -->
|
||||
<odoo>
|
||||
<!-- Maschinenliste -->
|
||||
<record id="action_machine_list" model="ir.actions.act_window">
|
||||
<field name="name">Maschinen</field>
|
||||
<field name="res_model">ows.machine</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Trainingsprodukt-Liste -->
|
||||
<record id="action_training_product_list" model="ir.actions.act_window">
|
||||
<field name="name">Einweisungs-Produkte</field>
|
||||
<field name="res_model">ows.machine.product</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Menüstruktur -->
|
||||
<!-- Oberstes Menü -->
|
||||
<menuitem id="menu_machine_root"
|
||||
name="Maschinen"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Konfigurationsebene -->
|
||||
<menuitem id="menu_machine_config"
|
||||
name="Konfiguration"
|
||||
parent="menu_machine_root"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Menüpunkt: Maschinenliste (klickbar) -->
|
||||
<menuitem id="menu_machine_list_action"
|
||||
name="Alle Maschinen"
|
||||
parent="menu_machine_config"
|
||||
action="open_workshop.action_machine_list"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Menücontainer: Zuordnungen -->
|
||||
<menuitem id="menu_machine_list"
|
||||
name="Zuordnungen"
|
||||
parent="menu_machine_config"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- Untermenü: Nutzungsprodukte -->
|
||||
<menuitem id="menu_machine_product"
|
||||
name="Nutzungsprodukte"
|
||||
parent="menu_machine_list"
|
||||
action="action_machine_product"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Untermenü: Einweisungsprodukte -->
|
||||
<menuitem id="menu_machine_training"
|
||||
name="Einweisungsprodukte"
|
||||
parent="menu_machine_list"
|
||||
action="action_machine_training"
|
||||
sequence="20"/>
|
||||
|
||||
|
||||
<!-- List & Form Views für training.product -->
|
||||
<record id="view_training_product_tree" model="ir.ui.view">
|
||||
<field name="name">ows.machine.product.tree</field>
|
||||
<field name="model">ows.machine.product</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="product_id"/>
|
||||
<field name="machine_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_training_product_form" model="ir.ui.view">
|
||||
<field name="name">ows.machine.product.form</field>
|
||||
<field name="model">ows.machine.product</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Einweisungs-Produkt">
|
||||
<group>
|
||||
<field name="product_id"/>
|
||||
<field name="machine_id"/>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
<odoo>
|
||||
<!-- Entfernt die Partner-Warnung (Duplicate Bank Accounts) in res.partner
|
||||
<record id="patch_res_partner_duplicate_warning" model="ir.ui.view">
|
||||
<field name="name">res.partner.remove.duplicate.bank.warning</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="account.view_partner_property_form"/>
|
||||
<field name="priority" eval="99"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='warning_tax' and @class='alert alert-warning oe_edit_only']" position="replace"/>
|
||||
</field>
|
||||
</record>-->
|
||||
|
||||
<!-- Entfernt die Bankkonto-Warnung in res.partner.bank
|
||||
<record id="patch_res_partner_bank_duplicate_warning" model="ir.ui.view">
|
||||
<field name="name">res.partner.bank.remove.duplicate.warning</field>
|
||||
<field name="model">res.partner.bank</field>
|
||||
<field name="inherit_id" ref="account.view_partner_bank_form_inherit_account"/>
|
||||
<field name="priority" eval="99"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@class='alert alert-warning']" position="replace"/>
|
||||
</field>
|
||||
</record> -->
|
||||
|
||||
<!-- Zentrale View für alle drei Tabs in garantierter Reihenfolge -->
|
||||
|
||||
<record id="view_partner_form_inherit_open_workshop_tabs" model="ir.ui.view">
|
||||
<field name="name">res.partner.form.ows.tabs</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="priority" eval="20"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page name="ows_machine_access" string="Offene Werkstatt (Hobbyhimmel)">
|
||||
<!-- EINWEISUNG: Zwei Felder nebeneinander -->
|
||||
<group name="container_row_2" string="Sicherheitseinweisung" col="2">
|
||||
<field name="security_briefing"/>
|
||||
<field name="security_id"/>
|
||||
</group>
|
||||
|
||||
<!-- MASCHINENFREIGABEN: Volle Breite -->
|
||||
<group string="Maschinenfreigaben" col="2">
|
||||
<field name="machine_access_ids" colspan="2" context="{'default_partner_id': id}" nolabel="1">
|
||||
<list>
|
||||
<field name="machine_id"/>
|
||||
<field name="date_granted"/>
|
||||
<field name="date_expiry"/>
|
||||
<field name="granted_by_pos"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
||||
<!-- ÜBERSICHT: Volle Breite -->
|
||||
<group string="Maschinenfreigaben Übersicht" >
|
||||
<field name="machine_access_html" colspan="2" readonly="1" widget="html" nolabel="1"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Geburtstag direkt nach der USt-ID -->
|
||||
<record id="view_partner_form_inherit_ows_birthday" model="ir.ui.view">
|
||||
<field name="name">res.partner.form.ows.birthday</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="priority" eval="15"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='vat']" position="after">
|
||||
<field name="birthday"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- List View Anpassung -->
|
||||
<record id="ows_userList_inherit" model="ir.ui.view">
|
||||
<field name="name">res.partner.ows.tree</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='vat']" position="after">
|
||||
<field name="create_date" optional="show"/>
|
||||
<field name="security_briefing" optional="show"/>
|
||||
<field name="security_id" optional="show"/>
|
||||
<field name="rfid_card" optional="show"/>
|
||||
<field name="category_id" widget="many2many_tags"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='vat']" position="replace">
|
||||
<field name="vat" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='email']" position="replace">
|
||||
<field name="email" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='phone']" position="replace">
|
||||
<field name="phone" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='state_id']" position="replace">
|
||||
<field name="state_id" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='country_id']" position="replace">
|
||||
<field name="country_id" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Standardwerte setzen (company_type = person) -->
|
||||
<record id="view_partner_form_inherit" model="ir.ui.view">
|
||||
<field name="name">res.partner.form.inherit.default_person</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="company_type" position="attributes">
|
||||
<attribute name="default">person</attribute>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Optional: Kontakte-Action, falls gebraucht -->
|
||||
<record id="contacts.action_contacts" model="ir.actions.act_window">
|
||||
<field name="view_mode">list,kanban,form,activity</field>
|
||||
</record>
|
||||
<record id="contacts.action_contacts_view_kanban" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="1"/>
|
||||
</record>
|
||||
<record id="contacts.action_contacts_view_tree" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="0"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# ✅ OpenWorkshop Test Checkliste
|
||||
|
||||
## 🔹 1. Migration
|
||||
- [ ] `migrate_existing_partners()` erzeugt zu jedem Partner genau einen `ows.user`.
|
||||
- [ ] `migrate_existing_partners()` übernimmt korrekt alte `vvow_*`-Felder.
|
||||
- [ ] `migrate_machine_access_from_old_fields()` erstellt korrekte Einträge in `ows.machine.access`.
|
||||
- [ ] `migrate_machine_access_from_old_fields()` übernimmt das Änderungsdatum aus `mail.tracking.value`.
|
||||
|
||||
## 🔹 2. Kontakte Backend
|
||||
- [ ] Beim Anlegen eines neuen Partners wird automatisch ein `ows.user` angelegt.
|
||||
- [ ] Änderungen an Geburtstag, RFID, Haftung in Partner-Formular schreiben korrekt in `ows.user`.
|
||||
- [ ] Die Werte aus `ows.user` werden korrekt im Partnerformular angezeigt (via `compute`).
|
||||
- [ ] Das HTML-Widget mit Maschinenfreigaben (`machine_access_html`) wird korrekt dargestellt.
|
||||
|
||||
## 🔹 3. POS-Integration
|
||||
- [ ] Felder aus `ows.user` (Geburtstag, RFID etc.) erscheinen im POS-Kunden-Popup.
|
||||
- [ ] Maschinenfreigaben erscheinen im POS-Layout korrekt gruppiert nach Bereichen.
|
||||
- [ ] Farben der Maschinenbereiche werden korrekt aus `color_hex` übernommen.
|
||||
|
||||
## 🔹 4. Maschinenverwaltung
|
||||
- [ ] Maschinen-Formular zeigt Nutzungs- und Einweisungsprodukte korrekt an.
|
||||
- [ ] Drop-downs in den Produktlisten zeigen nur Produkte der richtigen Kategorie.
|
||||
- [ ] Neue Zuordnungen können direkt in den Tree-Ansichten editiert werden.
|
||||
- [ ] Filter greifen korrekt (Maschinennutzung / Einweisungen).
|
||||
|
||||
## 🔹 5. Menüstruktur
|
||||
- [ ] Menüeinträge "Nutzungsprodukte" und "Einweisungsprodukte" erscheinen unter Konfiguration > Maschinen.
|
||||
- [ ] Klick auf "Alle Maschinen" öffnet die erwartete Listenansicht.
|
||||
|
||||
## 🔹 6. CSV/XML Demo-/Initialdaten
|
||||
- [ ] Maschinenbereiche (`ows.machine.area`) sind korrekt aus `data.xml` geladen.
|
||||
- [ ] Maschinen und ihre Produkt-Zuordnungen sind vollständig.
|
||||
- [ ] Kategorien und Produkte sind korrekt verknüpft (`product.category`, `product.product`).
|
||||
|
||||
## 🔹 7. Systemweite Konsistenz
|
||||
- [ ] Es gibt keine doppelten `ows.user`-Einträge.
|
||||
- [ ] Kein Partner existiert ohne zugehörigen `ows.user`.
|
||||
- [ ] `res.partner.ows_user_id` ist immer gefüllt.
|
||||
|
||||
## 🔹 8. Technische Qualität
|
||||
- [ ] Kein `@api.depends('id')` mehr vorhanden.
|
||||
- [ ] Commit wird in Migrationsfunktionen korrekt gesetzt (`self.env.cr.commit()`).
|
||||
- [ ] Keine toten `vvow_*` Felder mehr im Modell (wenn auf ows.user umgestellt).
|
||||
- [ ] post-init und pre-load Skripte laufen fehlerfrei bei Neuinstallation.
|
||||
|
|
@ -1,20 +1,21 @@
|
|||
{
|
||||
'name': 'Open Workshop Base',
|
||||
'license': 'AGPL-3',
|
||||
'version': '18.0.1.0.4', # Migration läuft bei 18.0.1.0.4
|
||||
'version': '18.0.1.0.5', # Migration läuft bei 18.0.1.0.4
|
||||
'summary': 'Kern-Modul für Maschinenfreigaben - vereinfachte Equipment-Integration',
|
||||
'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance'],
|
||||
'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance', 'maintenance_equipment_status'],
|
||||
'author': 'matthias.lotz',
|
||||
'category': 'Manufacturing',
|
||||
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/maintenance_equipment_status_data.xml',
|
||||
'views/machine_product_training_views.xml',
|
||||
'views/menu_views.xml',
|
||||
'views/machine_area_views.xml',
|
||||
'views/machine_views.xml',
|
||||
'views/maintenance_equipment_views.xml',
|
||||
'views/res_partner_view.xml',
|
||||
'views/menu_views.xml',
|
||||
'data/data.xml',
|
||||
],
|
||||
'installable': True,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- Equipment Status für Open Workshop -->
|
||||
|
||||
<record id="equipment_status_inbetrieb" model="maintenance.equipment.status">
|
||||
<field name="name">In Betrieb</field>
|
||||
<field name="sequence" eval="10"/>
|
||||
</record>
|
||||
|
||||
<record id="equipment_status_defekt" model="maintenance.equipment.status">
|
||||
<field name="name">Defekt</field>
|
||||
<field name="sequence" eval="20"/>
|
||||
</record>
|
||||
|
||||
<record id="equipment_status_wartung" model="maintenance.equipment.status">
|
||||
<field name="name">Wartung</field>
|
||||
<field name="sequence" eval="30"/>
|
||||
</record>
|
||||
|
||||
<record id="equipment_status_ausgemustert" model="maintenance.equipment.status">
|
||||
<field name="name">Ausgemustert</field>
|
||||
<field name="sequence" eval="40"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -99,7 +99,55 @@ def migrate(cr, version):
|
|||
except Exception as e:
|
||||
_logger.error(f"Failed to migrate machine {m_id}: {e}")
|
||||
|
||||
# 3. Validierung
|
||||
# 3. Synchronisiere related fields von ows.machine → maintenance.equipment
|
||||
# Nötig weil related fields mit store=True nicht automatisch bei SQL-INSERT synchronisiert werden
|
||||
_logger.info("Synchronizing related fields (area_id, category) to maintenance.equipment...")
|
||||
|
||||
cr.execute("""
|
||||
UPDATE maintenance_equipment me
|
||||
SET ows_area_id = om.area_id
|
||||
FROM ows_machine om
|
||||
WHERE om.equipment_id = me.id
|
||||
AND om.area_id IS NOT NULL
|
||||
""")
|
||||
synced_areas = cr.rowcount
|
||||
_logger.info(f"✅ Synchronized {synced_areas} area_id values")
|
||||
|
||||
cr.execute("""
|
||||
UPDATE maintenance_equipment me
|
||||
SET ows_category = om.category
|
||||
FROM ows_machine om
|
||||
WHERE om.equipment_id = me.id
|
||||
AND om.category IS NOT NULL
|
||||
""")
|
||||
synced_categories = cr.rowcount
|
||||
_logger.info(f"✅ Synchronized {synced_categories} category values")
|
||||
|
||||
# 4. Setze Default-Status "In Betrieb" für migrierte Equipment ohne Status
|
||||
# (Status-Records werden automatisch über XML-Datei erstellt)
|
||||
_logger.info("Setting default status for equipment without status...")
|
||||
|
||||
# Prüfe ob Status "In Betrieb" existiert
|
||||
cr.execute("SELECT id FROM maintenance_equipment_status WHERE name = 'In Betrieb' LIMIT 1")
|
||||
status_result = cr.fetchone()
|
||||
|
||||
if status_result:
|
||||
status_id = status_result[0]
|
||||
cr.execute("""
|
||||
UPDATE maintenance_equipment me
|
||||
SET status_id = %s
|
||||
WHERE me.status_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ows_machine om
|
||||
WHERE om.equipment_id = me.id
|
||||
)
|
||||
""", (status_id,))
|
||||
status_set = cr.rowcount
|
||||
_logger.info(f"✅ Set default status for {status_set} equipment records")
|
||||
else:
|
||||
_logger.warning("⚠️ Status 'In Betrieb' not found - skipping status assignment")
|
||||
|
||||
# 5. Validierung
|
||||
cr.execute("SELECT COUNT(*) FROM ows_machine WHERE equipment_id IS NULL")
|
||||
remaining = cr.fetchone()[0]
|
||||
|
||||
|
|
@ -109,3 +157,4 @@ def migrate(cr, version):
|
|||
_logger.info(f"✅ Successfully migrated {migrated_count} machines to equipment")
|
||||
|
||||
_logger.info("Post-migration completed")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from . import ows_models
|
||||
from . import pos_order
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ class ResPartner(models.Model):
|
|||
<tbody>
|
||||
"""
|
||||
|
||||
machines = self.env['ows.machine'].search([('area_id', '=', area.id)], order="name")
|
||||
machines = self.env['ows.machine'].search([('area_id', '=', area.id), ('category', '=', 'red')], order="name")
|
||||
|
||||
for machine in machines:
|
||||
access = self.env['ows.machine.access'].search([
|
||||
|
|
@ -400,6 +400,128 @@ AVAILABLE_COLORS = [
|
|||
('#ffffff', 'Weiss'),
|
||||
]
|
||||
|
||||
class MaintenanceEquipment(models.Model):
|
||||
"""Erweitere maintenance.equipment mit OWS-Feldern für direkte Bearbeitung"""
|
||||
_inherit = 'maintenance.equipment'
|
||||
|
||||
# Inverse Relation zu ows.machine
|
||||
ows_machine_id = fields.One2many('ows.machine', 'equipment_id', string='OWS Maschine', readonly=True)
|
||||
|
||||
# OWS-Felder direkt auf Equipment (via related für einfache Bearbeitung)
|
||||
# store=True für Performance beim Filtern/Suchen
|
||||
ows_category = fields.Selection(
|
||||
related='ows_machine_id.category',
|
||||
string='Sicherheitskategorie',
|
||||
readonly=False,
|
||||
store=True
|
||||
)
|
||||
ows_category_icon = fields.Char(
|
||||
related='ows_machine_id.category_icon',
|
||||
string='⚙'
|
||||
)
|
||||
ows_area_id = fields.Many2one(
|
||||
'ows.machine.area',
|
||||
related='ows_machine_id.area_id',
|
||||
string='Bereich',
|
||||
readonly=False,
|
||||
store=True
|
||||
)
|
||||
ows_product_ids = fields.One2many(
|
||||
'ows.machine.product',
|
||||
related='ows_machine_id.product_ids',
|
||||
string='Nutzungsprodukte',
|
||||
readonly=False
|
||||
)
|
||||
ows_training_ids = fields.One2many(
|
||||
'ows.machine.training',
|
||||
related='ows_machine_id.training_ids',
|
||||
string='Einweisungsprodukte',
|
||||
readonly=False
|
||||
)
|
||||
ows_usage_product_ids = fields.Many2many(
|
||||
'product.product',
|
||||
compute='_compute_usage_products',
|
||||
inverse='_inverse_usage_products',
|
||||
string='Nutzungsprodukte',
|
||||
store=False
|
||||
)
|
||||
ows_training_product_ids = fields.Many2many(
|
||||
'product.product',
|
||||
compute='_compute_training_products',
|
||||
inverse='_inverse_training_products',
|
||||
string='Einweisungsprodukte',
|
||||
store=False
|
||||
)
|
||||
|
||||
@api.depends('ows_product_ids.product_id')
|
||||
def _compute_usage_products(self):
|
||||
for record in self:
|
||||
record.ows_usage_product_ids = record.ows_product_ids.mapped('product_id')
|
||||
|
||||
def _inverse_usage_products(self):
|
||||
for record in self:
|
||||
if record.ows_machine_id:
|
||||
# Entferne alle bisherigen
|
||||
record.ows_machine_id.product_ids.unlink()
|
||||
# Füge neue hinzu
|
||||
for product in record.ows_usage_product_ids:
|
||||
self.env['ows.machine.product'].create({
|
||||
'machine_id': record.ows_machine_id.id,
|
||||
'product_id': product.id
|
||||
})
|
||||
|
||||
@api.depends('ows_training_ids.training_id')
|
||||
def _compute_training_products(self):
|
||||
for record in self:
|
||||
record.ows_training_product_ids = record.ows_training_ids.mapped('training_id')
|
||||
|
||||
def _inverse_training_products(self):
|
||||
for record in self:
|
||||
if record.ows_machine_id:
|
||||
# Entferne alle bisherigen
|
||||
record.ows_machine_id.training_ids.unlink()
|
||||
# Füge neue hinzu
|
||||
for product in record.ows_training_product_ids:
|
||||
self.env['ows.machine.training'].create({
|
||||
'machine_id': record.ows_machine_id.id,
|
||||
'training_id': product.id
|
||||
})
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Erstelle automatisch ows.machine für jedes neue Equipment (außer wenn vom ows.machine.create aufgerufen)"""
|
||||
records = super(MaintenanceEquipment, self).create(vals_list)
|
||||
records._create_missing_ows_machines()
|
||||
return records
|
||||
|
||||
def _create_missing_ows_machines(self):
|
||||
"""Erstelle fehlende ows.machine Datensätze für Equipment ohne ows.machine"""
|
||||
if self.env.context.get('skip_ows_machine_creation'):
|
||||
return
|
||||
|
||||
for record in self:
|
||||
if not record.ows_machine_id:
|
||||
self.env['ows.machine'].with_context(skip_ows_machine_creation=True).create({
|
||||
'equipment_id': record.id,
|
||||
'category': record.ows_category or 'red',
|
||||
'area_id': record.ows_area_id.id if record.ows_area_id else False,
|
||||
})
|
||||
|
||||
@api.model
|
||||
def load(self, fields, data):
|
||||
"""Override load() um sicherzustellen, dass ows.machine nach dem Import erstellt wird"""
|
||||
result = super(MaintenanceEquipment, self).load(fields, data)
|
||||
|
||||
# Nach erfolgreichem Import: Erstelle fehlende ows.machine Einträge
|
||||
if result.get('ids'):
|
||||
equipment_records = self.browse(result['ids'])
|
||||
equipment_records._create_missing_ows_machines()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
|
||||
class OwsMachineArea(models.Model):
|
||||
_name = 'ows.machine.area'
|
||||
_table = 'ows_machine_area'
|
||||
|
|
@ -513,43 +635,33 @@ class OwsMachine(models.Model):
|
|||
|
||||
# Keine eigenen SQL Constraints - Equipment hat bereits unique constraint für serial_no
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""
|
||||
Beim Erstellen einer ows.machine:
|
||||
1. Equipment IMMER automatisch erstellen
|
||||
1. Equipment nur erstellen wenn equipment_id NICHT angegeben wurde
|
||||
2. Area → Location synchronisieren
|
||||
3. serial_no und name vom User übernehmen
|
||||
"""
|
||||
# Equipment IMMER automatisch erstellen
|
||||
equipment_vals = {
|
||||
'name': vals.get('name', 'Neue Maschine'),
|
||||
'serial_no': vals.get('serial_no', False),
|
||||
}
|
||||
# Equipment nur erstellen wenn noch nicht vorhanden
|
||||
for vals in vals_list:
|
||||
# Wenn equipment_id bereits gesetzt ist (z.B. bei Import), KEIN neues Equipment erstellen!
|
||||
if 'equipment_id' not in vals:
|
||||
equipment_vals = {
|
||||
'name': vals.get('name', 'Neue Maschine'),
|
||||
'serial_no': vals.get('serial_no', False),
|
||||
}
|
||||
|
||||
equipment = self.env['maintenance.equipment'].with_context(skip_ows_machine_creation=True).create(equipment_vals)
|
||||
vals['equipment_id'] = equipment.id
|
||||
|
||||
# Area → Location Mapping für Equipment
|
||||
if 'area_id' in vals and vals['area_id']:
|
||||
area = self.env['ows.machine.area'].browse(vals['area_id'])
|
||||
if area and area.name:
|
||||
equipment_vals['location'] = area.name
|
||||
|
||||
equipment = self.env['maintenance.equipment'].create(equipment_vals)
|
||||
vals['equipment_id'] = equipment.id
|
||||
|
||||
return super(OwsMachine, self).create(vals)
|
||||
return super(OwsMachine, self).create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
"""
|
||||
Bei Updates:
|
||||
1. Area → Location synchronisieren
|
||||
2. Name/Serial_no → Equipment synchronisieren
|
||||
1. Name/Serial_no → Equipment synchronisieren
|
||||
"""
|
||||
# Area → Location Mapping
|
||||
if 'area_id' in vals and vals['area_id']:
|
||||
area = self.env['ows.machine.area'].browse(vals['area_id'])
|
||||
if area and area.name:
|
||||
vals['location'] = area.name
|
||||
|
||||
# Name/Serial_no an Equipment weiterleiten
|
||||
equipment_vals = {}
|
||||
if 'name' in vals:
|
||||
|
|
@ -612,7 +724,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')], order="name")
|
||||
machines = self.search([('area_id', '=', area.id), ('category', '=', 'red'), ('equipment_id.status_id.name', '!=', 'Ausgemustert')], order="name")
|
||||
machine_list = []
|
||||
for machine in machines:
|
||||
has_access = bool(self.env['ows.machine.access'].search([
|
||||
|
|
@ -620,6 +732,7 @@ class OwsMachine(models.Model):
|
|||
('machine_id', '=', machine.id),
|
||||
], limit=1))
|
||||
machine_list.append({
|
||||
'id': machine.id,
|
||||
'name': machine.name,
|
||||
'has_access': has_access,
|
||||
})
|
||||
|
|
@ -666,6 +779,14 @@ class OwsMachineProduct(models.Model):
|
|||
product_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
|
||||
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
|
||||
|
||||
def name_get(self):
|
||||
"""Zeigt den Produktnamen statt ows.machine.product,ID"""
|
||||
result = []
|
||||
for record in self:
|
||||
name = record.product_id.name if record.product_id else 'Unbenannt'
|
||||
result.append((record.id, name))
|
||||
return result
|
||||
|
||||
class OwsMachineTraining(models.Model):
|
||||
_name = 'ows.machine.training'
|
||||
_table = 'ows_machine_training'
|
||||
|
|
@ -673,3 +794,11 @@ class OwsMachineTraining(models.Model):
|
|||
|
||||
training_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
|
||||
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
|
||||
|
||||
def name_get(self):
|
||||
"""Zeigt den Produktnamen statt ows.machine.training,ID"""
|
||||
result = []
|
||||
for record in self:
|
||||
name = record.training_id.name if record.training_id else 'Unbenannt'
|
||||
result.append((record.id, name))
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
from odoo import models, fields, api
|
||||
from collections import defaultdict
|
||||
|
||||
#import debugpy
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.info("✅ pos_order.py geladen Test 2")
|
||||
|
||||
#debugpy.listen(("0.0.0.0", 5678))
|
||||
print("✅ debugpy wartet auf Verbindung (Port 5678) ...")
|
||||
# Optional: Starte erst, wenn VS Code verbunden ist
|
||||
#debugpy.wait_for_client()
|
||||
|
||||
class PosOrder(models.Model):
|
||||
_inherit = 'pos.order'
|
||||
|
||||
def _process_order(self, order, existing_order):
|
||||
_logger.info("🚨 DEBUG: _process_order wurde aufgerufen mit order: %s", order.get('name', 'unbekannt'))
|
||||
pos_order_id = super(PosOrder, self)._process_order(order, existing_order)
|
||||
pos_order = self.browse(pos_order_id)
|
||||
|
||||
training_products = self.env['ows.machine.training'].search([])
|
||||
product_map = defaultdict(list)
|
||||
for tp in training_products:
|
||||
product_map[tp.training_id.product_tmpl_id.id].append(tp.machine_id.id)
|
||||
|
||||
partner = pos_order.partner_id
|
||||
if not partner:
|
||||
_logger.info("🟡 POS-Bestellung ohne Partner – keine Freigabe möglich")
|
||||
return pos_order_id
|
||||
|
||||
for line in pos_order.lines:
|
||||
product_tmpl_id = line.product_id.product_tmpl_id.id
|
||||
machine_ids = product_map.get(product_tmpl_id, [])
|
||||
_logger.info("🔍 Prüfe Produkt %s → Maschinen IDs: %s", line.product_id.display_name, machine_ids)
|
||||
for machine_id in machine_ids:
|
||||
already_exists = self.env['ows.machine.access'].search([
|
||||
('partner_id', '=', partner.id),
|
||||
('machine_id', '=', machine_id)
|
||||
], limit=1)
|
||||
if not already_exists:
|
||||
self.env['ows.machine.access'].create({
|
||||
'partner_id': partner.id,
|
||||
'machine_id': machine_id,
|
||||
'granted_by_pos': True
|
||||
})
|
||||
_logger.info("✅ Maschinenfreigabe erstellt: %s für %s", machine_id, partner.name)
|
||||
|
||||
return pos_order_id
|
||||
|
|
@ -7,9 +7,6 @@
|
|||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Menüpunkt unter Maschinen > Konfiguration -->
|
||||
<menuitem id="menu_machine_area" name="Bereiche" parent="menu_machine_config" action="open_workshop_base.action_machine_area_list" sequence="30"/>
|
||||
|
||||
<!-- Listenansicht -->
|
||||
<record id="view_machine_area_tree" model="ir.ui.view">
|
||||
<field name="name">ows.machine.area.tree</field>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<odoo>
|
||||
<!-- Tree View: Nutzungsprodukte -->
|
||||
<!-- Tree View: Nutzungsprodukte (normale Ansicht) -->
|
||||
<record id="view_machine_product_tree" model="ir.ui.view">
|
||||
<field name="name">ows.machine.product.tree</field>
|
||||
<field name="model">ows.machine.product</field>
|
||||
|
|
@ -10,8 +10,20 @@
|
|||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View: Nutzungsprodukte (vereinfacht für Equipment) -->
|
||||
<record id="view_ows_machine_product_tree_simple" model="ir.ui.view">
|
||||
<field name="name">ows.machine.product.tree.simple</field>
|
||||
<field name="model">ows.machine.product</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="machine_id" column_invisible="1"/>
|
||||
<field name="product_id" domain="[('categ_id.name', '=', 'Maschinennutzung')]"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View: Einweisungsprodukte -->
|
||||
<!-- Tree View: Einweisungsprodukte (normale Ansicht) -->
|
||||
<record id="view_machine_training_tree" model="ir.ui.view">
|
||||
<field name="name">ows.machine.training.tree</field>
|
||||
<field name="model">ows.machine.training</field>
|
||||
|
|
@ -22,6 +34,18 @@
|
|||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View: Einweisungsprodukte (vereinfacht für Equipment) -->
|
||||
<record id="view_ows_machine_training_tree_simple" model="ir.ui.view">
|
||||
<field name="name">ows.machine.training.tree.simple</field>
|
||||
<field name="model">ows.machine.training</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="machine_id" column_invisible="1"/>
|
||||
<field name="training_id" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action: Nutzungsprodukte -->
|
||||
<record id="action_machine_product" model="ir.actions.act_window">
|
||||
|
|
|
|||
|
|
@ -6,20 +6,27 @@
|
|||
<field name="model">ows.machine</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Ausrüstung suchen">
|
||||
<!-- OWS Felder -->
|
||||
<field name="name" string="Name"/>
|
||||
<field name="serial_no" string="Code"/>
|
||||
<field name="area_id" string="Bereich"/>
|
||||
<!-- maintenance.equipment Felder (via _inherits) -->
|
||||
<field name="model" string="Modell"/>
|
||||
<field name="partner_id" string="Hersteller"/>
|
||||
<field name="location" string="Standort"/>
|
||||
<field name="company_id" string="Unternehmen"/>
|
||||
<separator/>
|
||||
<filter string="Kategorie 1 (grün)" name="filter_green" domain="[('category', '=', 'green')]"/>
|
||||
<filter string="Kategorie 2 (gelb)" name="filter_yellow" domain="[('category', '=', 'yellow')]"/>
|
||||
<filter string="Kategorie 3 (rot)" name="filter_red" domain="[('category', '=', 'red')]"/>
|
||||
<filter string="Kategorie grün" name="filter_green" domain="[('category', '=', 'green')]"/>
|
||||
<filter string="Kategorie gelb" name="filter_yellow" domain="[('category', '=', 'yellow')]"/>
|
||||
<filter string="Kategorie rot" name="filter_red" domain="[('category', '=', 'red')]"/>
|
||||
<separator/>
|
||||
<filter string="Aktiv" name="filter_active" domain="[('active', '=', True)]"/>
|
||||
<filter string="Archiviert" name="filter_inactive" domain="[('active', '=', False)]"/>
|
||||
<separator/>
|
||||
<group expand="0" string="Gruppieren nach">
|
||||
<group expand="1" string="Gruppieren nach">
|
||||
<filter string="Bereich" name="group_area" context="{'group_by': 'area_id'}"/>
|
||||
<filter string="Kategorie" name="group_category" context="{'group_by': 'category'}"/>
|
||||
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
|
|
@ -33,7 +40,7 @@
|
|||
<list sample="1" multi_edit="1">
|
||||
<field name="category_icon" string="⚙" readonly="1"/>
|
||||
<field name="name" optional="show"/>
|
||||
<field name="serial_no" string="Code" optional="show"/>
|
||||
<!--field name="serial_no" string="Code" optional="show"/-->
|
||||
<field name="category" optional="show"/>
|
||||
<field name="area_id" widget="many2one" optional="show"/>
|
||||
<field name="location" optional="hide"/>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,80 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Inherit maintenance.equipment form view to make location readonly -->
|
||||
<record id="view_equipment_form_location_readonly" model="ir.ui.view">
|
||||
<field name="name">maintenance.equipment.form.location.readonly</field>
|
||||
<!-- Erweitere Equipment Form mit OWS-Feldern -->
|
||||
<record id="view_equipment_form_ows_extension" model="ir.ui.view">
|
||||
<field name="name">maintenance.equipment.form.ows</field>
|
||||
<field name="model">maintenance.equipment</field>
|
||||
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='location']" position="attributes">
|
||||
<attribute name="readonly">1</attribute>
|
||||
<attribute name="help">Wird automatisch aus dem Bereich (ows.machine) synchronisiert. Änderungen nur über Bereich möglich.</attribute>
|
||||
<!-- Füge OWS-Felder in die Details-Gruppe ein -->
|
||||
<xpath expr="//field[@name='location']" position="after">
|
||||
<field name="ows_area_id" string="Bereich"/>
|
||||
<field name="ows_category" string="Sicherheitskategorie"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Füge Open Workshop Notebook-Page hinzu -->
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Offene Werkstatt (Hobbyhimmel)" name="open_workshop" invisible="not ows_machine_id">
|
||||
<group>
|
||||
<group string="Sicherheit">
|
||||
<field name="ows_machine_id" invisible="1"/>
|
||||
<field name="ows_category_icon" readonly="1"/>
|
||||
<field name="ows_category"/>
|
||||
<field name="ows_area_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Nutzungsprodukte">
|
||||
<field name="ows_usage_product_ids" nolabel="1" widget="many2many_tags" options="{'no_create': True}" domain="[('categ_id.name', '=', 'Maschinennutzung')]"/>
|
||||
</group>
|
||||
<group string="Einweisungsprodukte">
|
||||
<field name="ows_training_product_ids" nolabel="1" widget="many2many_tags" options="{'no_create': True}" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Erweitere Equipment List-View mit OWS-Spalten -->
|
||||
<record id="view_equipment_tree_ows_extension" model="ir.ui.view">
|
||||
<field name="name">maintenance.equipment.tree.ows</field>
|
||||
<field name="model">maintenance.equipment</field>
|
||||
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="ows_category_icon" string="⚙" optional="show"/>
|
||||
<field name="ows_area_id" optional="show"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Erweitere Equipment Search-View mit OWS-Filtern -->
|
||||
<record id="view_equipment_search_ows_extension" model="ir.ui.view">
|
||||
<field name="name">maintenance.equipment.search.ows</field>
|
||||
<field name="model">maintenance.equipment</field>
|
||||
<field name="inherit_id" ref="maintenance.hr_equipment_view_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Füge OWS-Felder zur Suche hinzu -->
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="ows_area_id" string="Bereich"/>
|
||||
<field name="ows_category" string="Kategorie"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Füge OWS-Filter hinzu -->
|
||||
<xpath expr="//filter[@name='inactive']" position="after">
|
||||
<separator/>
|
||||
<filter string="Kategorie 1 (grün)" name="filter_green" domain="[('ows_category', '=', 'green')]"/>
|
||||
<filter string="Kategorie 2 (gelb)" name="filter_yellow" domain="[('ows_category', '=', 'yellow')]"/>
|
||||
<filter string="Kategorie 3 (rot)" name="filter_red" domain="[('ows_category', '=', 'red')]"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Füge OWS-Gruppierungen hinzu (erstelle group wenn nicht vorhanden) -->
|
||||
<xpath expr="//search" position="inside">
|
||||
<group expand="0" string="Gruppieren nach">
|
||||
<filter string="Bereich" name="group_by_area" context="{'group_by': 'ows_area_id'}"/>
|
||||
<filter string="Kategorie" name="group_by_category" context="{'group_by': 'ows_category'}"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
<!-- menu_views.xml -->
|
||||
<odoo>
|
||||
<!-- Maschinenliste -->
|
||||
<record id="action_machine_list" model="ir.actions.act_window">
|
||||
<!-- Equipment-Liste mit OWS-Filter (ersetzt Standard Equipment Action) -->
|
||||
<record id="action_equipment_ows" model="ir.actions.act_window">
|
||||
<field name="name">Ausrüstung</field>
|
||||
<field name="res_model">ows.machine</field>
|
||||
<field name="res_model">maintenance.equipment</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('ows_machine_id', '!=', False)]</field>
|
||||
<field name="context">{
|
||||
'default_ows_category': 'red',
|
||||
'search_default_group_by_area': 1
|
||||
}</field>
|
||||
</record>
|
||||
|
||||
<!-- Trainingsprodukt-Liste -->
|
||||
|
|
@ -14,42 +19,32 @@
|
|||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Menüstruktur -->
|
||||
<!-- Oberstes Menü -->
|
||||
<menuitem id="menu_machine_root"
|
||||
name="Ausrüstung"
|
||||
sequence="10"/>
|
||||
<!-- Integration in Maintenance Menü -->
|
||||
|
||||
<!-- Bereiche unter Maintenance → Configuration -->
|
||||
<menuitem id="menu_maintenance_ows_areas"
|
||||
name="Bereiche"
|
||||
parent="maintenance.menu_maintenance_config"
|
||||
action="action_machine_area_list"
|
||||
sequence="50"/>
|
||||
|
||||
<!-- Konfigurationsebene -->
|
||||
<menuitem id="menu_machine_config"
|
||||
name="Konfiguration"
|
||||
parent="menu_machine_root"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Menüpunkt: Maschinenliste (klickbar) -->
|
||||
<menuitem id="menu_machine_list_action"
|
||||
name="Alle Ausrüstung"
|
||||
parent="menu_machine_config"
|
||||
action="open_workshop_base.action_machine_list"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Menücontainer: Zuordnungen -->
|
||||
<menuitem id="menu_machine_list"
|
||||
<!-- Zuordnungen Container unter Maintenance → Configuration -->
|
||||
<menuitem id="menu_maintenance_ows_assignments"
|
||||
name="Zuordnungen"
|
||||
parent="menu_machine_config"
|
||||
sequence="20"/>
|
||||
parent="maintenance.menu_maintenance_config"
|
||||
sequence="51"/>
|
||||
|
||||
<!-- Untermenü: Nutzungsprodukte -->
|
||||
<menuitem id="menu_machine_product"
|
||||
<!-- Nutzungsprodukte unter Zuordnungen -->
|
||||
<menuitem id="menu_maintenance_machine_product"
|
||||
name="Nutzungsprodukte"
|
||||
parent="menu_machine_list"
|
||||
parent="menu_maintenance_ows_assignments"
|
||||
action="action_machine_product"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Untermenü: Einweisungsprodukte -->
|
||||
<menuitem id="menu_machine_training"
|
||||
<!-- Einweisungsprodukte unter Zuordnungen -->
|
||||
<menuitem id="menu_maintenance_machine_training"
|
||||
name="Einweisungsprodukte"
|
||||
parent="menu_machine_list"
|
||||
parent="menu_maintenance_ows_assignments"
|
||||
action="action_machine_training"
|
||||
sequence="20"/>
|
||||
|
||||
|
|
|
|||
456
open_workshop_dokuwiki/README.md
Normal file
456
open_workshop_dokuwiki/README.md
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
# Equipment Übersichtstabelle - Setup
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Das **datatables Plugin** muss in DokuWiki installiert sein:
|
||||
```bash
|
||||
# Als DokuWiki Admin: Administration → Erweiterungsverwaltung
|
||||
# Suche: "datatables"
|
||||
# Installiere: "DataTables Plugin" von Matthias Schulte
|
||||
```
|
||||
|
||||
Das **include Plugin** muss in DokuWiki installiert sein (für Einbindung von Odoo-Status-Seiten):
|
||||
```bash
|
||||
# Als DokuWiki Admin: Administration → Erweiterungsverwaltung
|
||||
# Suche: "include"
|
||||
# Installiere: "Include Plugin" - sollte standardmäßig vorhanden sein
|
||||
```
|
||||
|
||||
## Setup-Schritte
|
||||
|
||||
### 1. Odoo Konfiguration
|
||||
|
||||
Die Übersichtstabelle wird vollständig aus Odoo heraus konfiguriert - **kein DokuWiki Template nötig!**
|
||||
|
||||
Gehe zu: **Einstellungen → Technisch → Parameter → Systemparameter**
|
||||
|
||||
Standardwerte (automatisch gesetzt beim Modul-Install):
|
||||
|
||||
```python
|
||||
# Basis-Namespace für Equipment-Dokumentation (konfigurierbar)
|
||||
dokuwiki.equipment_namespace = werkstatt:ausstattung
|
||||
|
||||
# Seiten-ID wo die Übersicht erstellt wird (konfigurierbar)
|
||||
dokuwiki.overview_page_id = werkstatt:ausstattung:uebersicht
|
||||
|
||||
# Titel der Übersichtsseite
|
||||
dokuwiki.overview_title = Geräte & Maschinen - Übersicht
|
||||
|
||||
# Spaltenüberschriften (Pipe-separiert)
|
||||
dokuwiki.overview_columns = Status|Sicherheits-Kategorie|Hersteller|Bemerkung|Typ|Bild|Standort|Dokumentation
|
||||
|
||||
# Spaltendaten mit Platzhaltern (Pipe-separiert)
|
||||
dokuwiki.overview_column_data = {status_smiley}|{ows_machine_id.category_icon}|{partner_id}|{note}|{model}|{image}|{ows_machine_id.location}|{wiki_doku_link}
|
||||
```
|
||||
|
||||
### 2. Erste Synchronisation
|
||||
|
||||
1. In Odoo: **Wartung → Konfiguration → Wiki-Synchronisation**
|
||||
2. Wähle: **"Übersichtstabelle aktualisieren"**
|
||||
3. Klicke: **"Synchronisieren"**
|
||||
|
||||
Die Übersichtsseite wird erstellt unter: `werkstatt:ausstattung:uebersicht` (konfigurierbar über Systemparameter)
|
||||
|
||||
## Features
|
||||
|
||||
### DataTables Funktionen
|
||||
- ✅ Sortieren nach allen Spalten (Klick auf Header)
|
||||
- ✅ Filter/Suche über alle Felder
|
||||
- ✅ Pagination bei vielen Einträgen
|
||||
- ✅ Responsive Design
|
||||
|
||||
### Flexible Spalten
|
||||
- ✅ Spalten in Odoo konfigurierbar (kein DokuWiki Template)
|
||||
- ✅ Alle Platzhalter verfügbar (siehe unten)
|
||||
- ✅ Spalten hinzufügen/entfernen ohne Code-Änderung
|
||||
- ✅ Änderungen sofort beim nächsten Sync wirksam
|
||||
|
||||
### Standard-Spalten
|
||||
| Spalte | Platzhalter | Ausgabe |
|
||||
|--------|-------------|---------|
|
||||
| Status | `{status_smiley}` | 😊 / 😐 / ☹️ |
|
||||
| Sicherheits-Kategorie | `{ows_machine_id.category_icon}` | 🟢 / 🟡 / 🔴 |
|
||||
| Hersteller | `{partner_id}` | BOSCH, Festool, ... |
|
||||
| Bemerkung | `{note}` | Notizen |
|
||||
| Typ | `{model}` | Modellbezeichnung |
|
||||
| Bild | `{image}` | Thumbnail 100px |
|
||||
| Standort | `{ows_machine_id.location}` | Regal A3, ... |
|
||||
| Dokumentation | `{wiki_doku_link}` | Link zur Detail-Seite |
|
||||
|
||||
## Spalten anpassen
|
||||
|
||||
### Beispiel 1: Seriennummer hinzufügen
|
||||
|
||||
```python
|
||||
# Spaltenüberschriften erweitern
|
||||
dokuwiki.overview_columns = Status|Typ|Seriennummer|Hersteller|Standort|Dokumentation
|
||||
|
||||
# Spaltendaten erweitern
|
||||
dokuwiki.overview_column_data = {status_smiley}|{model}|{serial_no}|{partner_id}|{ows_machine_id.location}|{wiki_doku_link}
|
||||
```
|
||||
|
||||
### Beispiel 2: Minimale Ansicht
|
||||
|
||||
```python
|
||||
dokuwiki.overview_columns = Name|Status|Kategorie|Dokumentation
|
||||
dokuwiki.overview_column_data = {name}|{status_smiley}|{ows_machine_id.category_icon}|{wiki_doku_link}
|
||||
```
|
||||
|
||||
### Beispiel 3: Detaillierte Ansicht mit Kosten
|
||||
|
||||
```python
|
||||
dokuwiki.overview_columns = Name|Hersteller|Modell|Seriennummer|Kosten|Garantie|Standort|Kategorie
|
||||
dokuwiki.overview_column_data = {name}|{partner_id}|{model}|{serial_no}|{cost}|{warranty_date}|{location}|{ows_machine_id.category_icon}
|
||||
```
|
||||
|
||||
### Beispiel 4: Mit Tags
|
||||
|
||||
```python
|
||||
dokuwiki.overview_columns = Status|Name|Typ|Tags|Standort|Dokumentation
|
||||
dokuwiki.overview_column_data = {status_smiley}|{name}|{model}|{tags_list}|{location}|{wiki_doku_link}
|
||||
```
|
||||
|
||||
**Wichtig:** Anzahl der Pipes muss übereinstimmen!
|
||||
- 3 Pipes = 4 Spalten
|
||||
- Spalten und Daten müssen gleiche Anzahl haben
|
||||
|
||||
## Performance
|
||||
|
||||
- ⚡ Schneller Sync: Keine DB-Iteration über alle Einträge
|
||||
- ⚡ Generiert nur eine Seite (statt 156 einzelne)
|
||||
- ⚡ DokuWiki cached die Seite automatisch
|
||||
- ⚡ Spalten-Änderung ohne Code-Deploy
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### DataTables funktioniert nicht
|
||||
→ Installiere DataTables Plugin in DokuWiki
|
||||
|
||||
### Bilder werden nicht angezeigt
|
||||
→ Prüfe ob Equipment `image_1920` Feld gesetzt hat
|
||||
→ Bilder müssen zuerst via "Alle Equipment" Sync hochgeladen werden
|
||||
|
||||
### Equipment fehlen in Tabelle
|
||||
→ Nur Equipment mit gesetztem `ows_area_id` werden angezeigt
|
||||
→ Prüfe Filter in `action_sync_overview_table()`
|
||||
|
||||
### Spalten werden nicht korrekt dargestellt
|
||||
→ Prüfe ob Anzahl Pipes in `overview_columns` und `overview_column_data` übereinstimmt
|
||||
→ Beispiel: 3 Pipes = 4 Spalten
|
||||
|
||||
### Platzhalter wird nicht ersetzt (z.B. `{status}` bleibt stehen)
|
||||
→ Prüfe Schreibweise (case-sensitive!)
|
||||
→ Siehe Liste der verfügbaren Platzhalter unten
|
||||
→ Bei `ows.machine` Feldern: `{ows_machine_id.feldname}` verwenden
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Odoo Systempar. │
|
||||
│ - Spalten │
|
||||
│ - Platzhalter │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ Odoo Equipment │
|
||||
│ (156 Einträge) │
|
||||
└────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ _generate_overview_ │
|
||||
│ table_row() │ ← Pro Equipment eine Zeile
|
||||
│ (Platzhalter ersetzen) │ mit Platzhalter-Engine
|
||||
└────────┬────────────────┘
|
||||
│ 156 Zeilen
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ Seite zusammenbauen │
|
||||
│ - Titel │
|
||||
│ - DataTables Header │
|
||||
│ - Zeilen │
|
||||
│ - Footer │
|
||||
└────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ Seite speichern │
|
||||
│ werkstatt:ausstattung: │
|
||||
│ uebersicht │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
## Automatische Updates
|
||||
|
||||
Die Übersichtstabelle kann optional automatisch bei Equipment-Änderungen aktualisiert werden.
|
||||
|
||||
### Manueller Sync (empfohlen)
|
||||
|
||||
**Manueller Sync empfohlen für:**
|
||||
- Nach Massen-Importen
|
||||
- Einmal täglich/wöchentlich
|
||||
- Bei strukturellen Änderungen (neue Bereiche, etc.)
|
||||
|
||||
**Vorteil:** Performanter, keine zusätzliche Last bei jeder Equipment-Änderung
|
||||
|
||||
### Automatischer Sync bei Equipment-Änderungen
|
||||
|
||||
**Was passiert beim Speichern eines Equipment?**
|
||||
|
||||
1. **Equipment-Detailseite wird immer aktualisiert** wenn:
|
||||
- Eines der überwachten Felder geändert wurde (siehe unten)
|
||||
- `wiki_auto_sync` für das Equipment aktiviert ist (Standard: `True`)
|
||||
- Equipment bereits synchronisiert wurde (`wiki_synced = True`)
|
||||
|
||||
2. **Übersichtstabelle wird zusätzlich aktualisiert** wenn:
|
||||
- Alle Bedingungen von (1) erfüllt sind UND
|
||||
- Systemparameter `dokuwiki.auto_update_overview_table = True` gesetzt ist
|
||||
|
||||
**Überwachte Felder (lösen Sync aus):**
|
||||
- `name`, `serial_no`, `ows_area_id`, `category_id`, `status_id`
|
||||
- `model`, `partner_id`, `location`, `note`, `image_1920`, `tag_ids`
|
||||
|
||||
**Aktivierung Übersichtstabellen-Sync:**
|
||||
```python
|
||||
# In Odoo: Einstellungen → Technisch → Parameter → Systemparameter
|
||||
dokuwiki.auto_update_overview_table = True
|
||||
```
|
||||
|
||||
**Achtung:**
|
||||
- Bei vielen gleichzeitigen Equipment-Änderungen kann dies Performance-Probleme verursachen!
|
||||
- Die komplette Übersichtstabelle wird bei jeder Änderung neu generiert (ca. 156 Zeilen)
|
||||
|
||||
### Cronjob (Alternative)
|
||||
```python
|
||||
# In Odoo: Einstellungen → Technisch → Automatisierung → Geplante Aktionen
|
||||
|
||||
Name: Wiki Übersichtstabelle Update
|
||||
Modell: maintenance.equipment
|
||||
Aktion: action_sync_overview_table()
|
||||
Intervall: Täglich um 02:00 Uhr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Verfügbare Platzhalter
|
||||
|
||||
Alle Platzhalter aus dem Detail-Template sind verfügbar!
|
||||
|
||||
## Basis-Felder (maintenance.equipment)
|
||||
|
||||
- `{name}` - Equipment-Name
|
||||
- `{serial_no}` - Seriennummer
|
||||
- `{model}` - Modell
|
||||
- `{category}` - Kategoriename
|
||||
- `{status}` - Status (aus status_id)
|
||||
- `{status_smiley}` - Status als Smiley (aus status_id.smiley Feld, z.B. 😊 oder ☹️)
|
||||
- `{location}` - Standort
|
||||
- `{ows_area}` - Bereichsname
|
||||
- `{assign_date}` - Zuweisungsdatum (formatiert)
|
||||
- `{cost}` - Kosten
|
||||
- `{warranty_date}` - Garantiedatum (formatiert)
|
||||
- `{note}` - Notizen
|
||||
- `{partner_id}` - Lieferant (Name)
|
||||
- `{partner_ref}` - Lieferanten-Referenz
|
||||
|
||||
## Tags (falls maintenance_equipment_tags installiert)
|
||||
|
||||
- `{tags}` - Komma-separierte Liste aller Tags (z.B. "Holz, CNC, Einweisung erforderlich")
|
||||
- `{tags_list}` - DokuWiki Bullet-Liste mit Zeilenumbrüchen (ideal für Tabellenzellen)
|
||||
- `{tags_count}` - Anzahl der zugewiesenen Tags
|
||||
|
||||
## Spezial-Felder
|
||||
|
||||
- `{view_type}` - "Bereich" oder "Einsatzzweck"
|
||||
- `{view_name}` - Name des Bereichs/Einsatzzwecks
|
||||
- `{wiki_doku_page}` - ID der zentralen Doku-Seite
|
||||
- `{wiki_doku_link}` - Fertiger Link zur zentralen Doku-Seite
|
||||
- `{odoo_link}` - Fertiger Link zur Odoo Equipment-Seite
|
||||
- `{odoo_url}` - URL zur Odoo Equipment-Seite (ohne Link-Markup)
|
||||
- `{sync_datetime}` - Aktuelles Datum/Zeit
|
||||
- `{image}` - Equipment-Bild (100px Breite) - Format: `{{:media_id?100}}`
|
||||
- `{image_large}` - Equipment-Bild (Originalgröße) - Format: `{{:media_id}}`
|
||||
- `{image_id}` - Media-ID des Bildes (z.B. werkstatt:ausruestung:media:sabako-laser.jpg)
|
||||
|
||||
## ows.machine Felder (falls verknüpft)
|
||||
|
||||
- `{ows_machine_id.name}` - Name
|
||||
- `{ows_machine_id.model}` - Modell
|
||||
- `{ows_machine_id.serial_no}` - Seriennummer
|
||||
- `{ows_machine_id.location}` - Standort
|
||||
- `{ows_machine_id.note}` - Notizen
|
||||
- `{ows_machine_id.category}` - Sicherheitskategorie (red/yellow/green)
|
||||
- `{ows_machine_id.category_icon}` - Kategorie-Icon als Emoji (🔴/🟡/🟢)
|
||||
|
||||
## Tipps für Platzhalter
|
||||
|
||||
- **Case-sensitive**: `{Name}` funktioniert nicht, nur `{name}`
|
||||
- **Pipe als Trenner**: Zwischen Spalten `|` verwenden
|
||||
- **Leere Werte**: Werden automatisch durch `-` ersetzt
|
||||
- **Links**: `{wiki_doku_link}` und `{odoo_link}` enthalten bereits DokuWiki Link-Syntax
|
||||
- **Bilder**: `{image}` ist bereits formatiert mit `{{:...?100}}`
|
||||
- **ows.machine**: Immer mit Präfix `ows_machine_id.` (z.B. `{ows_machine_id.location}`)
|
||||
|
||||
## Beispiel-Konfigurationen
|
||||
|
||||
### Minimalistische Ansicht
|
||||
```
|
||||
Spalten: Name|Status|Kategorie
|
||||
Daten: {name}|{status_smiley}|{ows_machine_id.category_icon}
|
||||
```
|
||||
|
||||
### Standard-Ansicht (Default)
|
||||
```
|
||||
Spalten: Status|Sicherheits-Kategorie|Hersteller|Bemerkung|Typ|Bild|Standort|Dokumentation
|
||||
Daten: {status_smiley}|{ows_machine_id.category_icon}|{partner_id}|{note}|{model}|{image}|{ows_machine_id.location}|{wiki_doku_link}
|
||||
```
|
||||
|
||||
### Vollständige Ansicht
|
||||
```
|
||||
Spalten: Name|Status|Kategorie|Hersteller|Modell|S/N|Kosten|Garantie|Standort|Bereich|Bild|Doku
|
||||
Daten: {name}|{status_smiley}|{ows_machine_id.category_icon}|{partner_id}|{model}|{serial_no}|{cost}|{warranty_date}|{location}|{ows_area}|{image}|{wiki_doku_link}
|
||||
```
|
||||
|
||||
### Mit Tags
|
||||
```
|
||||
Spalten: Name|Status|Tags|Hersteller|Standort|Doku
|
||||
Daten: {name}|{status_smiley}|{tags_list}|{partner_id}|{location}|{wiki_doku_link}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Best Practices: DokuWiki Seitenstruktur
|
||||
|
||||
## Erweiterbare Dokumentation ohne Umbenennung
|
||||
|
||||
### Problem
|
||||
Benutzer möchten zu Equipment-Seiten eigene Unterseiten hinzufügen, ohne die von Odoo generierten Seiten umbenennen zu müssen.
|
||||
|
||||
### Lösung: Parallele Seite + Namespace
|
||||
|
||||
DokuWiki erlaubt **gleichzeitig** eine Seite und einen gleichnamigen Namespace:
|
||||
- Datei: `equipment.txt` (von Odoo generiert)
|
||||
- Verzeichnis: `equipment/` (für Benutzer-Unterseiten)
|
||||
|
||||
**Die Seite `equipment.txt` hat Vorrang und bleibt die Haupt-Equipment-Seite!**
|
||||
|
||||
### Empfohlene Struktur
|
||||
|
||||
```
|
||||
werkstatt/
|
||||
└── ausstattung/
|
||||
├── uebersicht # VON ODOO GENERIERT: Übersichtstabelle
|
||||
├── odoo-status/ # VON ODOO GENERIERT: Nur-Lese Seiten (nur odoo.odoo schreibt)
|
||||
│ ├── c_template.txt # Template für Equipment-Status-Seiten
|
||||
│ ├── analog-oscilloscope-hm303-6.txt # Status-Seite für include-Plugin
|
||||
│ ├── cnc-fraese-xyz.txt # Status-Seite für include-Plugin
|
||||
│ └── ...
|
||||
└── {Bereich Name}/ # VON BENUTZER ERSTELLT: Namespace wird von Odoo festgelegt
|
||||
├── analog-oscilloscope-hm303-6.txt # Benutzer erstellt diese Seite manuell (Klick auf Link in Übersicht)
|
||||
├── analog-oscilloscope-hm303-6/ # Benutzer-Unterseiten (optional)
|
||||
│ ├── kalibrierung.txt
|
||||
│ ├── messungen.txt
|
||||
│ └── bilder/
|
||||
│ └── oszillogramme.txt
|
||||
├── cnc-fraese-xyz.txt # Benutzer erstellt diese Seite manuell (Klick auf Link in Übersicht)
|
||||
└── cnc-fraese-xyz/ # Benutzer-Unterseiten (optional)
|
||||
├── programme.txt
|
||||
└── werkzeuge.txt
|
||||
```
|
||||
|
||||
**Wichtig:**
|
||||
- **Odoo erstellt NUR:** `{overview_page_id}` (Übersichtstabelle) und `odoo-status/*.txt` (Status-Seiten)
|
||||
- **Odoo erstellt NIEMALS:** Seiten in `{Bereich Name}/` - diese werden ausschließlich von Benutzern erstellt
|
||||
- **Workflow:** Übersichtstabelle verlinkt auf `{Bereich Name}/equipment-name.txt` → Link ist rot (Seite existiert nicht) → Benutzer klickt darauf → DokuWiki bietet "Seite erstellen" an → Benutzer fügt `{{page>odoo-status:equipment-name}}` ein
|
||||
- **Berechtigungen:** `odoo-status/` Namespace hat spezielle Berechtigungen (nur odoo.odoo kann schreiben)
|
||||
|
||||
### Wichtige Regeln
|
||||
|
||||
#### ✅ DO: Unterverzeichnis OHNE start.txt
|
||||
|
||||
```
|
||||
equipment/
|
||||
├── subpage1.txt
|
||||
├── subpage2.txt
|
||||
└── weitere/
|
||||
└── details.txt
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Breadcrumb zeigt: `Home » Werkstatt » Ausrüstung » Equipment » Subpage1`
|
||||
- Keine Duplikation oder Verwirrung
|
||||
- Equipment-Hauptseite bleibt `equipment.txt`
|
||||
|
||||
#### ❌ DON'T: Unterverzeichnis MIT start.txt
|
||||
|
||||
```
|
||||
equipment/
|
||||
├── start.txt # ❌ NICHT erstellen!
|
||||
├── subpage1.txt
|
||||
└── subpage2.txt
|
||||
```
|
||||
|
||||
**Probleme:**
|
||||
- Breadcrumb wird verwirrend: `Equipment` könnte auf zwei Seiten verweisen
|
||||
- `equipment` und `equipment:start` zeigen unterschiedliche Inhalte
|
||||
- Benutzer wissen nicht, welche Seite die "richtige" ist
|
||||
|
||||
### Include-Plugin für Odoo-Status einbinden
|
||||
|
||||
**Wichtig:** Diese Seite wird NICHT von Odoo erstellt! Der Benutzer erstellt sie manuell nach Klick auf den Link in der Übersichtstabelle.
|
||||
|
||||
In der Benutzer-Dokumentationsseite wird der Odoo-generierte Status eingebunden:
|
||||
|
||||
```dokuwiki
|
||||
===== Analog Oscilloscope HM303-6 =====
|
||||
|
||||
==== Odoo Status ====
|
||||
|
||||
{{page>werkstatt:ausstattung:odoo-status:analog-oscilloscope-hm303-6}}
|
||||
|
||||
==== Eigene Dokumentation ====
|
||||
|
||||
Hier kann der Benutzer seine eigenen Inhalte hinzufügen...
|
||||
|
||||
==== Weitere Dokumentation ====
|
||||
|
||||
<catlist .:analog-oscilloscope-hm303-6 -noNSInBold -sortByTitle>
|
||||
```
|
||||
|
||||
**Syntax-Erklärung:**
|
||||
- `{{page>...}}` → Bindet Odoo-Status-Seite ein (nur lesbar für Benutzer)
|
||||
- `.:namespace` → Relativer Namespace für catlist (Punkt + Doppelpunkt!)
|
||||
- `-noNSInBold` → Namespace-Präfix nicht fett darstellen
|
||||
- `-sortByTitle` → Alphabetisch sortieren
|
||||
- `-exclude:{ns1 ns2}` → Optional: Namespaces ausschließen
|
||||
|
||||
**Ergebnis:**
|
||||
- Odoo-Status wird direkt in Benutzer-Seite eingebettet
|
||||
- Benutzer sehen aktuelle Odoo-Daten ohne direkt auf odoo-status zuzugreifen
|
||||
- Catlist zeigt alle Benutzer-Unterseiten im `equipment/` Verzeichnis
|
||||
- Automatisch aktualisiert wenn Benutzer neue Seiten erstellen
|
||||
- Kein manuelles Pflegen von Links nötig
|
||||
|
||||
### Workflow
|
||||
|
||||
1. **Odoo synchronisiert** → Erstellt/aktualisiert `{overview_page_id}` (Übersichtstabelle) und `odoo-status/equipment.txt` (Status-Seiten)
|
||||
2. **Benutzer sieht Übersichtstabelle** → Equipment sind verlinkt, aber Links sind rot (Seiten existieren nicht)
|
||||
3. **Benutzer klickt roten Link** → DokuWiki zeigt "Diese Seite existiert noch nicht - erstellen?"
|
||||
4. **Benutzer erstellt Seite** → Fügt `{{page>werkstatt:ausstattung:odoo-status:equipment-name}}` ein und eigene Inhalte
|
||||
5. **Benutzer erstellt optional Unterseiten** → Erstellt Verzeichnis `equipment/` für weitere Dokumentation
|
||||
6. **Catlist zeigt automatisch Unterseiten** → Wenn in Hauptseite eingebunden
|
||||
7. **Keine Konflikte** → Odoo schreibt NIEMALS in `{Bereich Name}/`, nur in `start` und `odoo-status/`
|
||||
|
||||
### Vorteile dieser Struktur
|
||||
|
||||
- ✅ **Klare Trennung:** `odoo-status/` (nur Odoo schreibt) vs. `{Bereich Name}/` (nur Benutzer schreiben)
|
||||
- ✅ **Berechtigungsschutz:** Benutzer können Odoo-Daten nicht versehentlich überschreiben
|
||||
- ✅ **Keine Überschreibgefahr:** Odoo erstellt niemals Seiten in Benutzer-Namespaces
|
||||
- ✅ **Einfaches Einbinden:** Ein `{{page>...}}` in Benutzer-Seite, fertig
|
||||
- ✅ **Keine Namenskollisionen:** Odoo und Benutzer haben komplett getrennte Namespaces
|
||||
- ✅ **Benutzer entscheiden:** Nur wer dokumentieren will, erstellt eine Seite (über Link in Übersichtstabelle)
|
||||
- ✅ **Flexibel erweiterbar:** Benutzer können beliebig viele Unterseiten erstellen
|
||||
- ✅ **Automatische Updates:** Odoo-Status wird in alle einbindenden Seiten propagiert
|
||||
- ✅ **Konfigurierbarer Namespace:** Basis-Pfad über Systemparameter `dokuwiki.equipment_namespace` anpassbar
|
||||
4
open_workshop_dokuwiki/__init__.py
Normal file
4
open_workshop_dokuwiki/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import wizard
|
||||
from .hooks import post_init_hook, pre_init_hook
|
||||
68
open_workshop_dokuwiki/__manifest__.py
Normal file
68
open_workshop_dokuwiki/__manifest__.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Open Workshop - DokuWiki Integration',
|
||||
'version': '18.0.2.1.0',
|
||||
'category': 'Maintenance',
|
||||
'summary': 'Synchronisiert Equipment-Daten mit DokuWiki für Dokumentation',
|
||||
'description': """
|
||||
Open Workshop DokuWiki Integration
|
||||
===================================
|
||||
|
||||
Dieses Modul synchronisiert Equipment-Daten aus Odoo mit einem DokuWiki System.
|
||||
|
||||
Features:
|
||||
---------
|
||||
* Automatische Erstellung von Wiki-Seiten für Equipment
|
||||
* Übersichtstabelle aller Equipment (DataTables mit Sortierung/Filterung)
|
||||
* Status-Seiten für Include-Plugin (nur von Odoo generiert, nur lesbar)
|
||||
* Benutzer erstellen eigene Dokumentationsseiten und binden Odoo-Status ein
|
||||
* Smart Button "Wiki öffnen" im Equipment-Formular
|
||||
* Automatische Synchronisation bei Equipment-Änderungen (optional)
|
||||
|
||||
Architektur:
|
||||
------------
|
||||
* Odoo generiert NUR: {overview_page_id} (Übersichtstabelle) und odoo-status/ Seiten
|
||||
* Odoo generiert NIEMALS: Benutzer-Dokumentationsseiten in {bereich}/ Namespaces
|
||||
* Namespace-Struktur (konfigurierbar über dokuwiki.equipment_namespace):
|
||||
- {equipment_namespace}:{overview_page_id} - Übersichtstabelle (von Odoo)
|
||||
- {equipment_namespace}:odoo-status:{equipment_id} - Status-Seiten (von Odoo, nur lesbar)
|
||||
- {equipment_namespace}:odoo-status:c_template - Template für Status-Seiten
|
||||
- {equipment_namespace}:{bereich}:{equipment_id} - Benutzer-Dokumentation (von Benutzern erstellt!)
|
||||
|
||||
Workflow:
|
||||
---------
|
||||
1. Odoo synchronisiert Equipment → erstellt odoo-status/ Seiten
|
||||
2. Übersichtstabelle verlinkt auf {bereich}/{equipment_id} → Link ist rot (existiert nicht)
|
||||
3. Benutzer klickt roten Link → DokuWiki: "Seite erstellen?"
|
||||
4. Benutzer erstellt Seite und fügt {{page>odoo-status:equipment_id}} ein
|
||||
|
||||
ACL-Empfehlung:
|
||||
---------------
|
||||
* odoo-status/ Namespace: Nur odoo.odoo kann schreiben, alle anderen nur lesen
|
||||
* {bereich}/ Namespaces: Benutzer haben volle Schreibrechte
|
||||
""",
|
||||
'author': 'Hobbyhimmel',
|
||||
'website': 'https://hobbyhimmel.de',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'open_workshop_base',
|
||||
'maintenance',
|
||||
'maintenance_equipment_status',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['dokuwiki'],
|
||||
},
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_config_parameter.xml',
|
||||
'data/maintenance_equipment_status_data.xml',
|
||||
'views/maintenance_equipment_views.xml',
|
||||
'views/maintenance_equipment_status_views.xml',
|
||||
'wizard/equipment_wiki_sync_wizard_views.xml',
|
||||
],
|
||||
'post_init_hook': 'post_init_hook',
|
||||
'pre_init_hook': 'pre_init_hook',
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
8
open_workshop_dokuwiki/data/ir_config_parameter.xml
Normal file
8
open_workshop_dokuwiki/data/ir_config_parameter.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
DokuWiki System Parameters werden über post_init_hook initialisiert
|
||||
Dadurch werden sie bei Install und Update korrekt gehandhabt
|
||||
und User-Änderungen bleiben erhalten
|
||||
-->
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- DokuWiki Smiley-Werte für Status aus open_workshop_base -->
|
||||
|
||||
<record id="open_workshop_base.equipment_status_inbetrieb" model="maintenance.equipment.status">
|
||||
<field name="smiley">😊</field>
|
||||
</record>
|
||||
|
||||
<record id="open_workshop_base.equipment_status_defekt" model="maintenance.equipment.status">
|
||||
<field name="smiley">☹️</field>
|
||||
</record>
|
||||
|
||||
<record id="open_workshop_base.equipment_status_wartung" model="maintenance.equipment.status">
|
||||
<field name="smiley">🛠️</field>
|
||||
</record>
|
||||
|
||||
<record id="open_workshop_base.equipment_status_ausgemustert" model="maintenance.equipment.status">
|
||||
<field name="smiley">❌</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
15
open_workshop_dokuwiki/hooks.py
Normal file
15
open_workshop_dokuwiki/hooks.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
def pre_init_hook(env):
|
||||
"""
|
||||
Hook der VOR der Modul-Installation/Update ausgeführt wird.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def post_init_hook(env):
|
||||
"""
|
||||
Hook der nach der Modul-Installation/Update ausgeführt wird.
|
||||
Initialisiert die DokuWiki-Parameter falls sie nicht existieren.
|
||||
"""
|
||||
env['res.config.settings']._init_dokuwiki_parameters()
|
||||
101
open_workshop_dokuwiki/migrations/18.0.2.1.0/pre-migration.py
Normal file
101
open_workshop_dokuwiki/migrations/18.0.2.1.0/pre-migration.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from psycopg2 import sql
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Fügt die neuen Wiki-ID-Felder zur maintenance_equipment Tabelle hinzu.
|
||||
|
||||
Args:
|
||||
cr: Database cursor
|
||||
version: Aktuelle Version vor dem Upgrade (sollte 18.0.2.0.0 sein)
|
||||
"""
|
||||
_logger.info("=" * 80)
|
||||
_logger.info("PRE-MIGRATION SCRIPT: open_workshop_dokuwiki 18.0.2.1.0")
|
||||
_logger.info("Aktuelle Version: %s", version)
|
||||
_logger.info("=" * 80)
|
||||
|
||||
try:
|
||||
# Prüfe ob die Tabelle maintenance_equipment existiert
|
||||
cr.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'maintenance_equipment'
|
||||
)
|
||||
""")
|
||||
table_exists = cr.fetchone()[0]
|
||||
|
||||
if not table_exists:
|
||||
_logger.warning("Tabelle maintenance_equipment existiert nicht - überspringe Migration")
|
||||
return
|
||||
|
||||
_logger.info("Tabelle maintenance_equipment gefunden")
|
||||
|
||||
# Prüfe ob die Felder bereits existieren
|
||||
cr.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'maintenance_equipment'
|
||||
AND column_name IN ('wiki_status_id', 'wiki_doku_id')
|
||||
""")
|
||||
existing_fields = [row[0] for row in cr.fetchall()]
|
||||
_logger.info("Bereits existierende Felder: %s", existing_fields)
|
||||
|
||||
# Füge wiki_status_id hinzu falls nicht vorhanden
|
||||
if 'wiki_status_id' not in existing_fields:
|
||||
_logger.info("Füge Spalte wiki_status_id hinzu...")
|
||||
cr.execute("""
|
||||
ALTER TABLE maintenance_equipment
|
||||
ADD COLUMN wiki_status_id VARCHAR
|
||||
""")
|
||||
cr.commit() # Wichtig: Commit nach jeder Änderung
|
||||
_logger.info("✓ Spalte wiki_status_id erfolgreich hinzugefügt")
|
||||
else:
|
||||
_logger.info("→ Spalte wiki_status_id existiert bereits, überspringe")
|
||||
|
||||
# Füge wiki_doku_id hinzu falls nicht vorhanden
|
||||
if 'wiki_doku_id' not in existing_fields:
|
||||
_logger.info("Füge Spalte wiki_doku_id hinzu...")
|
||||
cr.execute("""
|
||||
ALTER TABLE maintenance_equipment
|
||||
ADD COLUMN wiki_doku_id VARCHAR
|
||||
""")
|
||||
cr.commit() # Wichtig: Commit nach jeder Änderung
|
||||
_logger.info("✓ Spalte wiki_doku_id erfolgreich hinzugefügt")
|
||||
else:
|
||||
_logger.info("→ Spalte wiki_doku_id existiert bereits, überspringe")
|
||||
|
||||
# Verifiziere dass beide Felder jetzt existieren
|
||||
cr.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'maintenance_equipment'
|
||||
AND column_name IN ('wiki_status_id', 'wiki_doku_id')
|
||||
ORDER BY column_name
|
||||
""")
|
||||
final_fields = [row[0] for row in cr.fetchall()]
|
||||
_logger.info("Felder nach Migration: %s", final_fields)
|
||||
|
||||
if len(final_fields) == 2:
|
||||
_logger.info("=" * 80)
|
||||
_logger.info("✓ PRE-MIGRATION ERFOLGREICH ABGESCHLOSSEN")
|
||||
_logger.info("=" * 80)
|
||||
else:
|
||||
_logger.error("!" * 80)
|
||||
_logger.error("FEHLER: Nicht alle Felder wurden hinzugefügt!")
|
||||
_logger.error("Erwartet: ['wiki_doku_id', 'wiki_status_id']")
|
||||
_logger.error("Gefunden: %s", final_fields)
|
||||
_logger.error("!" * 80)
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("!" * 80)
|
||||
_logger.error("FEHLER IM PRE-MIGRATION SCRIPT!")
|
||||
_logger.error("Exception: %s", str(e))
|
||||
_logger.error("!" * 80)
|
||||
raise
|
||||
5
open_workshop_dokuwiki/models/__init__.py
Normal file
5
open_workshop_dokuwiki/models/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import dokuwiki_client
|
||||
from . import maintenance_equipment
|
||||
from . import maintenance_equipment_status
|
||||
from . import res_config_settings
|
||||
392
open_workshop_dokuwiki/models/dokuwiki_client.py
Normal file
392
open_workshop_dokuwiki/models/dokuwiki_client.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from dokuwiki import DokuWiki, DokuWikiError
|
||||
from odoo import api, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Connection Cache (pro Request wiederverwendet)
|
||||
_connection_cache = {}
|
||||
|
||||
|
||||
class DokuWikiClient(models.AbstractModel):
|
||||
"""
|
||||
Wrapper für DokuWiki XML-RPC API Client.
|
||||
Managed die Verbindung zum DokuWiki und stellt Methoden für Seitenoperationen bereit.
|
||||
|
||||
Performance-Optimierung: Verbindungen werden gecached und wiederverwendet
|
||||
um den Overhead von XML-RPC Verbindungsaufbauten zu minimieren.
|
||||
"""
|
||||
_name = 'dokuwiki.client'
|
||||
_description = 'DokuWiki API Client'
|
||||
|
||||
@api.model
|
||||
def _get_cache_key(self):
|
||||
"""
|
||||
Generiert einen Cache-Key basierend auf den Verbindungsparametern.
|
||||
|
||||
Returns:
|
||||
str: Cache-Key (URL + User)
|
||||
"""
|
||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
||||
wiki_url = IrConfigParameter.get_param('dokuwiki.url', '')
|
||||
wiki_user = IrConfigParameter.get_param('dokuwiki.user', '')
|
||||
return f"{wiki_url}:{wiki_user}"
|
||||
|
||||
@api.model
|
||||
def _get_wiki_connection(self, use_cache=True):
|
||||
"""
|
||||
Erstellt eine DokuWiki-Verbindung mit Credentials aus System-Parametern.
|
||||
Verbindungen werden gecached und wiederverwendet für bessere Performance.
|
||||
|
||||
Args:
|
||||
use_cache (bool): Wenn True, wird eine gecachte Verbindung wiederverwendet
|
||||
|
||||
Returns:
|
||||
DokuWiki: Verbundenes DokuWiki-Client-Objekt
|
||||
|
||||
Raises:
|
||||
UserError: Wenn Konfiguration fehlt oder Verbindung fehlschlägt
|
||||
"""
|
||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
# Credentials aus System-Parametern holen
|
||||
wiki_url = IrConfigParameter.get_param('dokuwiki.url')
|
||||
wiki_user = IrConfigParameter.get_param('dokuwiki.user')
|
||||
wiki_password = IrConfigParameter.get_param('dokuwiki.password')
|
||||
|
||||
if not all([wiki_url, wiki_user, wiki_password]):
|
||||
raise UserError(
|
||||
"DokuWiki-Konfiguration unvollständig. "
|
||||
"Bitte unter Einstellungen → Technisch → Parameter → Systemparameter konfigurieren:\n"
|
||||
"- dokuwiki.url\n"
|
||||
"- dokuwiki.user\n"
|
||||
"- dokuwiki.password"
|
||||
)
|
||||
|
||||
# URL validieren/normalisieren
|
||||
if not wiki_url.startswith(('http://', 'https://')):
|
||||
raise UserError(f"DokuWiki URL muss mit http:// oder https:// beginnen: {wiki_url}")
|
||||
|
||||
# Cache-Key generieren
|
||||
cache_key = self._get_cache_key()
|
||||
|
||||
# Gecachte Verbindung wiederverwenden wenn möglich
|
||||
if use_cache and cache_key in _connection_cache:
|
||||
try:
|
||||
# Teste ob Verbindung noch lebt
|
||||
cached_wiki = _connection_cache[cache_key]
|
||||
_ = cached_wiki.version # Test-Call
|
||||
_logger.debug(f"Wiederverwendung gecachter DokuWiki-Verbindung: {wiki_url}")
|
||||
return cached_wiki
|
||||
except Exception as e:
|
||||
_logger.warning(f"Gecachte Verbindung ungültig, erstelle neue: {e}")
|
||||
del _connection_cache[cache_key]
|
||||
|
||||
# Neue Verbindung erstellen
|
||||
try:
|
||||
_logger.info(f"Neue DokuWiki-Verbindung: {wiki_url} (User: {wiki_user})")
|
||||
wiki = DokuWiki(wiki_url, wiki_user, wiki_password)
|
||||
# Test-Verbindung
|
||||
version = wiki.version
|
||||
_logger.info(f"DokuWiki-Verbindung erfolgreich: Version {version}")
|
||||
|
||||
# In Cache speichern
|
||||
if use_cache:
|
||||
_connection_cache[cache_key] = wiki
|
||||
_logger.debug(f"Verbindung im Cache gespeichert: {cache_key}")
|
||||
|
||||
return wiki
|
||||
except DokuWikiError as e:
|
||||
error_details = f"URL: {wiki_url}, User: {wiki_user}, Fehler: {e}"
|
||||
_logger.error(f"DokuWiki-Verbindung fehlgeschlagen: {error_details}")
|
||||
raise UserError(
|
||||
f"Verbindung zu DokuWiki fehlgeschlagen.\n\n"
|
||||
f"Details:\n"
|
||||
f"- URL: {wiki_url}\n"
|
||||
f"- User: {wiki_user}\n"
|
||||
f"- Fehler: {e}\n\n"
|
||||
f"Hinweise:\n"
|
||||
f"- Prüfen Sie ob die URL korrekt ist (sollte auf /lib/exe/xmlrpc.php zeigen)\n"
|
||||
f"- Stellen Sie sicher, dass XML-RPC in DokuWiki aktiviert ist\n"
|
||||
f"- Prüfen Sie die Benutzer-Zugangsdaten"
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(f"Unerwarteter Fehler bei DokuWiki-Verbindung: {e}", exc_info=True)
|
||||
raise UserError(f"Unerwarteter Fehler: {e}")
|
||||
|
||||
@api.model
|
||||
def clear_connection_cache(self):
|
||||
"""
|
||||
Löscht den Verbindungs-Cache (z.B. nach Konfigurationsänderungen).
|
||||
"""
|
||||
global _connection_cache
|
||||
_connection_cache.clear()
|
||||
_logger.info("DokuWiki Verbindungs-Cache geleert")
|
||||
|
||||
@api.model
|
||||
def create_page(self, page_id, content, summary="Erstellt von Odoo"):
|
||||
"""
|
||||
Erstellt oder aktualisiert eine DokuWiki-Seite.
|
||||
|
||||
Args:
|
||||
page_id (str): DokuWiki Page-ID (z.B. "werkstatt:ausruestung:holzwerkstatt:maschine1")
|
||||
content (str): Wiki-Markup-Inhalt der Seite
|
||||
summary (str): Änderungskommentar für die Wiki-History
|
||||
|
||||
Returns:
|
||||
bool: True bei Erfolg
|
||||
|
||||
Raises:
|
||||
UserError: Bei API-Fehlern
|
||||
"""
|
||||
wiki = self._get_wiki_connection()
|
||||
|
||||
try:
|
||||
_logger.info(f"Erstelle/Aktualisiere Wiki-Seite: {page_id} ({len(content)} Zeichen)")
|
||||
wiki.pages.set(page_id, content, summary=summary)
|
||||
_logger.info(f"Wiki-Seite erfolgreich erstellt/aktualisiert: {page_id}")
|
||||
return True
|
||||
except DokuWikiError as e:
|
||||
_logger.error(f"DokuWiki API Fehler bei Seite {page_id}: {e}", exc_info=True)
|
||||
raise UserError(
|
||||
f"Fehler beim Erstellen der Wiki-Seite '{page_id}':\n\n"
|
||||
f"{e}\n\n"
|
||||
f"Mögliche Ursachen:\n"
|
||||
f"- XML-RPC nicht aktiviert in DokuWiki\n"
|
||||
f"- Keine Schreibrechte für den Namespace\n"
|
||||
f"- Ungültige Zeichen in der Page-ID\n"
|
||||
f"- DokuWiki gibt Fehler/Warnings aus (prüfen Sie die DokuWiki-Logs)"
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(f"Unerwarteter Fehler beim Erstellen der Seite {page_id}: {e}", exc_info=True)
|
||||
raise UserError(f"Unerwarteter Fehler: {e}")
|
||||
|
||||
@api.model
|
||||
def get_page(self, page_id):
|
||||
"""
|
||||
Liest den Inhalt einer DokuWiki-Seite.
|
||||
|
||||
Args:
|
||||
page_id (str): DokuWiki Page-ID
|
||||
|
||||
Returns:
|
||||
str: Wiki-Markup-Inhalt der Seite (leer wenn Seite nicht existiert)
|
||||
"""
|
||||
wiki = self._get_wiki_connection()
|
||||
|
||||
try:
|
||||
content = wiki.pages.get(page_id)
|
||||
return content or ""
|
||||
except DokuWikiError as e:
|
||||
_logger.warning(f"Fehler beim Lesen der Wiki-Seite {page_id}: {e}")
|
||||
return ""
|
||||
|
||||
@api.model
|
||||
def page_exists(self, page_id):
|
||||
"""
|
||||
Prüft ob eine DokuWiki-Seite existiert.
|
||||
|
||||
Args:
|
||||
page_id (str): DokuWiki Page-ID
|
||||
|
||||
Returns:
|
||||
bool: True wenn Seite existiert
|
||||
"""
|
||||
content = self.get_page(page_id)
|
||||
return bool(content.strip())
|
||||
|
||||
@api.model
|
||||
def delete_page(self, page_id, summary="Gelöscht von Odoo"):
|
||||
"""
|
||||
Löscht eine DokuWiki-Seite (setzt Inhalt auf leer).
|
||||
|
||||
Args:
|
||||
page_id (str): DokuWiki Page-ID
|
||||
summary (str): Änderungskommentar für die Wiki-History
|
||||
|
||||
Returns:
|
||||
bool: True bei Erfolg
|
||||
"""
|
||||
wiki = self._get_wiki_connection()
|
||||
|
||||
try:
|
||||
wiki.pages.set(page_id, "", summary=summary)
|
||||
_logger.info(f"Wiki-Seite gelöscht: {page_id}")
|
||||
return True
|
||||
except DokuWikiError as e:
|
||||
_logger.error(f"Fehler beim Löschen der Wiki-Seite {page_id}: {e}")
|
||||
raise UserError(f"Fehler beim Löschen der Wiki-Seite: {e}")
|
||||
|
||||
@api.model
|
||||
def list_pages(self, namespace):
|
||||
"""
|
||||
Listet alle Seiten in einem DokuWiki-Namespace auf.
|
||||
|
||||
Args:
|
||||
namespace (str): DokuWiki-Namespace (z.B. "werkstatt:ausruestung")
|
||||
|
||||
Returns:
|
||||
list: Liste von Dictionaries mit Page-Informationen
|
||||
"""
|
||||
wiki = self._get_wiki_connection()
|
||||
|
||||
try:
|
||||
pages = wiki.pages.list(namespace)
|
||||
return pages
|
||||
except DokuWikiError as e:
|
||||
_logger.error(f"Fehler beim Auflisten des Namespace {namespace}: {e}")
|
||||
return []
|
||||
|
||||
@api.model
|
||||
def get_wiki_url(self, page_id):
|
||||
"""
|
||||
Generiert die vollständige URL zu einer DokuWiki-Seite.
|
||||
|
||||
Args:
|
||||
page_id (str): DokuWiki Page-ID
|
||||
|
||||
Returns:
|
||||
str: Vollständige URL zur Wiki-Seite
|
||||
"""
|
||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
||||
wiki_url = IrConfigParameter.get_param('dokuwiki.url', '')
|
||||
|
||||
if not wiki_url:
|
||||
return ""
|
||||
|
||||
# Entferne trailing slash falls vorhanden
|
||||
wiki_url = wiki_url.rstrip('/')
|
||||
|
||||
return f"{wiki_url}/doku.php?id={page_id}"
|
||||
|
||||
@api.model
|
||||
def upload_media(self, media_id, file_content, overwrite=True):
|
||||
"""
|
||||
Lädt eine Mediendatei (Bild) ins DokuWiki hoch.
|
||||
|
||||
Args:
|
||||
media_id (str): DokuWiki Media-ID (z.B. "werkstatt:ausruestung:equipment_3.jpg")
|
||||
file_content (bytes): Binärer Dateiinhalt
|
||||
overwrite (bool): Existierende Datei überschreiben
|
||||
|
||||
Returns:
|
||||
bool: True bei Erfolg
|
||||
|
||||
Raises:
|
||||
UserError: Bei Fehler beim Upload
|
||||
"""
|
||||
wiki = self._get_wiki_connection()
|
||||
|
||||
import tempfile
|
||||
import os
|
||||
import base64
|
||||
import imghdr
|
||||
|
||||
tmp_path = None
|
||||
|
||||
# Unterstütze mehrere Eingabetypen:
|
||||
# - ir.attachment record (Recordset)
|
||||
# - attachment id (int)
|
||||
# - rohe Bytes
|
||||
# - base64-codierter String
|
||||
try:
|
||||
content_bytes = None
|
||||
ext = ''
|
||||
|
||||
# ir.attachment Record
|
||||
if hasattr(file_content, '_name') and getattr(file_content, '_name') == 'ir.attachment':
|
||||
att = file_content.sudo()
|
||||
if not att.datas:
|
||||
raise UserError(f"Attachment {att.id} enthält keine Daten")
|
||||
content_bytes = base64.b64decode(att.datas)
|
||||
fname = att.datas_fname or ''
|
||||
ext = os.path.splitext(fname)[1]
|
||||
|
||||
# attachment id
|
||||
elif isinstance(file_content, int):
|
||||
att = self.env['ir.attachment'].sudo().browse(file_content)
|
||||
if not att.exists() or not att.datas:
|
||||
raise UserError(f"Attachment {file_content} nicht gefunden oder leer")
|
||||
content_bytes = base64.b64decode(att.datas)
|
||||
fname = att.datas_fname or ''
|
||||
ext = os.path.splitext(fname)[1]
|
||||
|
||||
# base64 string
|
||||
elif isinstance(file_content, str):
|
||||
try:
|
||||
content_bytes = base64.b64decode(file_content)
|
||||
except Exception:
|
||||
# Fallback: treat as empty
|
||||
raise UserError("Übergebener String konnte nicht als base64 decodiert werden")
|
||||
ext = os.path.splitext(media_id)[1]
|
||||
|
||||
# rohe bytes
|
||||
else:
|
||||
# assume bytes-like
|
||||
content_bytes = file_content
|
||||
# try extension from media_id
|
||||
ext = os.path.splitext(media_id)[1]
|
||||
|
||||
# Wenn noch keine Extension, versuche Bildtyp zu erkennen
|
||||
if not ext:
|
||||
try:
|
||||
img_type = imghdr.what(None, content_bytes)
|
||||
if img_type:
|
||||
ext = f'.{img_type}'
|
||||
except Exception:
|
||||
ext = ''
|
||||
|
||||
if not ext:
|
||||
ext = '.bin'
|
||||
|
||||
# Sicherstellen, dass ext mit Punkt beginnt
|
||||
if not ext.startswith('.'):
|
||||
ext = f'.{ext}'
|
||||
|
||||
# Temporäre Datei erstellen (dokuwiki.py erwartet Datei-Pfad)
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_file:
|
||||
tmp_file.write(content_bytes)
|
||||
tmp_path = tmp_file.name
|
||||
|
||||
try:
|
||||
wiki.medias.add(media_id, tmp_path, overwrite=overwrite)
|
||||
_logger.info(f"Mediendatei hochgeladen: {media_id} (tmp={tmp_path})")
|
||||
return True
|
||||
except DokuWikiError as e:
|
||||
error_msg = f"Fehler beim Hochladen der Mediendatei {media_id}: {e}"
|
||||
_logger.error(error_msg)
|
||||
raise UserError(error_msg)
|
||||
finally:
|
||||
# Temporäre Datei löschen
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
_logger.warning(f"Konnte temporäre Datei nicht löschen: {tmp_path}")
|
||||
|
||||
@api.model
|
||||
def get_media_url(self, media_id):
|
||||
"""
|
||||
Generiert die vollständige URL zu einer DokuWiki-Mediendatei.
|
||||
|
||||
Args:
|
||||
media_id (str): DokuWiki Media-ID
|
||||
|
||||
Returns:
|
||||
str: Vollständige URL zur Mediendatei
|
||||
"""
|
||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
||||
wiki_url = IrConfigParameter.get_param('dokuwiki.url', '')
|
||||
|
||||
if not wiki_url:
|
||||
return ""
|
||||
|
||||
# Entferne trailing slash falls vorhanden
|
||||
wiki_url = wiki_url.rstrip('/')
|
||||
|
||||
# Ersetze : durch / für Media-Pfad
|
||||
media_path = media_id.replace(':', '/')
|
||||
|
||||
return f"{wiki_url}/lib/exe/fetch.php?media={media_id}"
|
||||
974
open_workshop_dokuwiki/models/maintenance_equipment.py
Normal file
974
open_workshop_dokuwiki/models/maintenance_equipment.py
Normal file
|
|
@ -0,0 +1,974 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache für Wiki-Templates (wird beim Server-Neustart geleert)
|
||||
_template_cache = {}
|
||||
|
||||
class MaintenanceEquipment(models.Model):
|
||||
"""
|
||||
Erweitert das Maintenance Equipment Modell um DokuWiki-Integration.
|
||||
Diese Klasse ermöglicht die Synchronisation von Werkstatt-Equipment-Daten mit einem
|
||||
DokuWiki-System. Jedes Equipment erhält eine zentrale Dokumentationsseite sowie
|
||||
verschiedene Ansichtsseiten (nach Bereich, später auch nach Einsatzzweck).
|
||||
Hauptfunktionen:
|
||||
- Automatische Generierung von Wiki-Seiten aus Equipment-Daten
|
||||
- Template-basierte Wiki-Seiten-Erstellung (verwendet c_template aus DokuWiki)
|
||||
- Bild-Upload und Einbindung in Wiki-Seiten
|
||||
- Zentrale Dokumentationsseite pro Equipment (manuell erweiterbar)
|
||||
- Bereichs-spezifische Übersichtsseiten (automatisch aktualisiert)
|
||||
- Automatische Übersichtstabelle aller Equipment (DataTable mit Sortierung/Filterung)
|
||||
- Automatische Synchronisation bei Feldänderungen (optional)
|
||||
Wiki-Seitenstruktur:
|
||||
- {equipment_namespace}:{overview_page_id} - Übersichtstabelle (von Odoo generiert)
|
||||
- {equipment_namespace}:odoo-status:{wiki_doku_id} - Status-Seite (von Odoo generiert, nur lesbar)
|
||||
- {equipment_namespace}:odoo-status:c_template - Template für Status-Seiten
|
||||
- {equipment_namespace}:{bereich}:{wiki_doku_id} - Benutzer-Dokumentation (NICHT von Odoo erstellt!)
|
||||
Neue Felder:
|
||||
- image_1920: Bild des Equipment (wird automatisch ins Wiki hochgeladen)
|
||||
- wiki_doku_id: Eindeutige ID für Wiki-Dokumentation (aus Equipment-Name generiert)
|
||||
- wiki_page_url: Berechnete URL zur Wiki-Seite
|
||||
- wiki_synced: Status der Synchronisation
|
||||
- wiki_last_sync: Zeitstempel der letzten Synchronisation
|
||||
- wiki_auto_sync: Schalter für automatische Synchronisation bei Änderungen
|
||||
Template-System:
|
||||
Die Wiki-Seiten werden aus einem Template ({equipment_namespace}:odoo-status:c_template)
|
||||
generiert, das folgende Platzhalter unterstützt:
|
||||
- {feldname} - Direkte Equipment-Felder (z.B. {name}, {serial_no})
|
||||
- {ows_machine_id.feldname} - Maschinenfelder (z.B. {ows_machine_id.power})
|
||||
- {wiki_status_page} - ID der Odoo-Status-Seite
|
||||
- {wiki_status_link} - DokuWiki-Link zur Odoo-Status-Seite
|
||||
- {image} - Bild-Einbindung (wird automatisch hochgeladen)
|
||||
- {tags} - Komma-separierte Tag-Liste
|
||||
- {sync_datetime} - Zeitstempel der Synchronisation
|
||||
Übersichtstabelle:
|
||||
Generiert eine zentrale Übersichtstabelle aller Equipment mit konfigurierbaren
|
||||
Spalten. Die Tabelle verwendet das DokuWiki DataTable-Plugin für interaktive
|
||||
Sortierung und Filterung. Konfiguration über Systemparameter:
|
||||
- dokuwiki.overview_page_id - Seiten-ID der Übersicht
|
||||
- dokuwiki.overview_title - Titel der Seite
|
||||
- dokuwiki.overview_columns - Spaltenüberschriften (pipe-separiert)
|
||||
- dokuwiki.overview_column_data - Spaltendaten mit Platzhaltern
|
||||
Verwendung:
|
||||
1. Equipment anlegen/bearbeiten in Odoo
|
||||
2. Bereich (ows_area_id) setzen (erforderlich für Wiki-Sync)
|
||||
3. Optional: Bild hochladen (wird automatisch ins Wiki übertragen)
|
||||
4. "Zum Wiki synchronisieren" klicken (oder automatisch bei Änderungen)
|
||||
5. Wiki-Seiten werden erstellt/aktualisiert
|
||||
6. "Wiki-Seite öffnen" für direkten Browser-Zugriff
|
||||
Notes:
|
||||
- Zentrale Dokumentationsseite wird nur beim ersten Sync erstellt
|
||||
- Bereichsansichten werden bei jedem Sync aktualisiert
|
||||
- Bilder werden als {equipment_namespace}:{bereich}:{wiki_doku_id}.jpg gespeichert (parallele Struktur zu Pages)
|
||||
- Wiki-Namen werden normalisiert (Umlaute, Sonderzeichen, Kleinschreibung)
|
||||
- Bei fehlendem Template wird Fallback-Inhalt verwendet
|
||||
- Übersichtstabelle kann manuell oder automatisch aktualisiert werden
|
||||
"""
|
||||
_inherit = 'maintenance.equipment'
|
||||
|
||||
# Bild-Feld
|
||||
image_1920 = fields.Image(
|
||||
string='Bild',
|
||||
max_width=1920,
|
||||
max_height=1920,
|
||||
help='Equipment-Bild (wird beim Wiki-Sync hochgeladen)'
|
||||
)
|
||||
|
||||
# Wiki-Felder
|
||||
wiki_status_id = fields.Char(
|
||||
string='Wiki Status-ID',
|
||||
readonly=True,
|
||||
help='Eindeutige ID für die Odoo-Status-Seite (automatisch generiert aus Name + Seriennummer, z.B. "formatkreissaege-eq001")'
|
||||
)
|
||||
wiki_doku_id = fields.Char(
|
||||
string='Wiki Dokumentations-ID',
|
||||
readonly=False,
|
||||
help='ID für die Benutzer-Dokumentationsseite (editierbar, initial aus Equipment-Namen generiert, z.B. "formatkreissaege"). Mehrere Equipment können die gleiche ID nutzen.'
|
||||
)
|
||||
wiki_page_url = fields.Char(
|
||||
string='Wiki-Seiten URL',
|
||||
compute='_compute_wiki_page_url',
|
||||
help='URL zur Wiki-Seite im Browser'
|
||||
)
|
||||
wiki_synced = fields.Boolean(
|
||||
string='Wiki synchronisiert',
|
||||
default=False,
|
||||
help='Gibt an, ob die Wiki-Seite aktuell ist'
|
||||
)
|
||||
wiki_last_sync = fields.Datetime(
|
||||
string='Letzte Wiki-Synchronisation',
|
||||
readonly=True,
|
||||
help='Zeitpunkt der letzten erfolgreichen Wiki-Synchronisation'
|
||||
)
|
||||
wiki_auto_sync = fields.Boolean(
|
||||
string='Automatische Wiki-Synchronisation',
|
||||
default=True,
|
||||
help='Bei Änderungen automatisch zum Wiki synchronisieren'
|
||||
)
|
||||
wiki_doku_link = fields.Html(
|
||||
string='Wiki Doku',
|
||||
compute='_compute_wiki_doku_link',
|
||||
sanitize=False,
|
||||
help='Klickbarer Link zur Wiki-Dokumentationsseite'
|
||||
)
|
||||
wiki_status_link = fields.Html(
|
||||
string='Wiki Status',
|
||||
compute='_compute_wiki_status_link',
|
||||
sanitize=False,
|
||||
help='Klickbarer Link zur Wiki-Status-Seite (Odoo-Daten)'
|
||||
)
|
||||
|
||||
@api.depends('wiki_doku_id', 'wiki_page_url')
|
||||
def _compute_wiki_doku_link(self):
|
||||
"""
|
||||
Generiert einen HTML-Link mit wiki_doku_id als Text und wiki_page_url als Ziel.
|
||||
"""
|
||||
for record in self:
|
||||
if record.wiki_doku_id and record.wiki_page_url:
|
||||
record.wiki_doku_link = f'<a href="{record.wiki_page_url}" target="_blank" style="color: #007bff;">{record.wiki_doku_id}</a>'
|
||||
else:
|
||||
record.wiki_doku_link = ''
|
||||
|
||||
@api.depends('wiki_status_id', 'name', 'comp_serial_no', 'ows_area_id')
|
||||
def _compute_wiki_status_link(self):
|
||||
"""
|
||||
Generiert einen HTML-Link mit wiki_status_id als Text zur Odoo-Status-Seite.
|
||||
"""
|
||||
dokuwiki_client = self.env['dokuwiki.client']
|
||||
for record in self:
|
||||
# wiki_status_id dynamisch berechnen falls noch nicht gesetzt
|
||||
wiki_status_id = record.wiki_status_id or record._get_wiki_status_id()
|
||||
if wiki_status_id and record.ows_area_id:
|
||||
status_page_id = record._get_wiki_status_page_id()
|
||||
if status_page_id:
|
||||
status_url = dokuwiki_client.get_wiki_url(status_page_id)
|
||||
record.wiki_status_link = f'<a href="{status_url}" target="_blank" style="color: #007bff;">{wiki_status_id}</a>'
|
||||
else:
|
||||
record.wiki_status_link = ''
|
||||
else:
|
||||
record.wiki_status_link = ''
|
||||
|
||||
@api.depends('ows_area_id')
|
||||
def _compute_wiki_page_url(self):
|
||||
"""
|
||||
Berechnet die URL zur Haupt-Wiki-Seite (nach Bereich).
|
||||
"""
|
||||
dokuwiki_client = self.env['dokuwiki.client']
|
||||
|
||||
for record in self:
|
||||
wiki_doku_id = record._get_wiki_doku_id()
|
||||
if wiki_doku_id and record.ows_area_id:
|
||||
page_id = record._get_wiki_page_id_by_area()
|
||||
record.wiki_page_url = dokuwiki_client.get_wiki_url(page_id)
|
||||
else:
|
||||
record.wiki_page_url = False
|
||||
|
||||
def _get_wiki_status_id(self):
|
||||
"""
|
||||
Generiert die Wiki-Status-ID aus Equipment-Namen und comp_serial_no.
|
||||
Format: normalisierter_name-comp_serial_no (z.B. "formatkreissaege-eq001")
|
||||
|
||||
Diese ID ist eindeutig und wird für die Odoo-Status-Seite und Media-Dateien verwendet.
|
||||
Die comp_serial_no wird automatisch generiert falls noch nicht vorhanden.
|
||||
|
||||
Returns:
|
||||
str: Wiki-Status-ID oder False wenn kein Name vorhanden
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Verwende wiki_status_id falls bereits gesetzt
|
||||
if self.wiki_status_id:
|
||||
return self.wiki_status_id
|
||||
|
||||
# Name ist erforderlich
|
||||
if not self.name:
|
||||
return False
|
||||
|
||||
# comp_serial_no automatisch generieren falls nicht vorhanden
|
||||
if not self.comp_serial_no:
|
||||
self.generate_serial_no()
|
||||
# Refresh nach generate_serial_no
|
||||
self.refresh()
|
||||
|
||||
# wiki_status_id aus Name + comp_serial_no generieren
|
||||
normalized_name = self._normalize_wiki_name(self.name)
|
||||
normalized_serial = self._normalize_wiki_name(self.comp_serial_no)
|
||||
return f"{normalized_name}-{normalized_serial}"
|
||||
|
||||
def _get_wiki_doku_id(self):
|
||||
"""
|
||||
Generiert die Wiki-Dokumentations-ID aus Equipment-Namen (ohne Seriennummer).
|
||||
Format: normalisierter_name (z.B. "formatkreissaege")
|
||||
|
||||
Diese ID ist editierbar und wird für die Benutzer-Dokumentationsseite verwendet.
|
||||
Mehrere Equipment können die gleiche wiki_doku_id haben (z.B. alle Akkuschrauber).
|
||||
|
||||
Returns:
|
||||
str: Wiki-Doku-ID oder False wenn kein Name vorhanden
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Verwende wiki_doku_id falls bereits gesetzt
|
||||
if self.wiki_doku_id:
|
||||
return self.wiki_doku_id
|
||||
|
||||
# Name ist erforderlich
|
||||
if not self.name:
|
||||
return False
|
||||
|
||||
# wiki_doku_id nur aus Name generieren (Duplikate sind erwünscht)
|
||||
normalized_name = self._normalize_wiki_name(self.name)
|
||||
return normalized_name
|
||||
|
||||
def _get_wiki_page_id_by_area(self):
|
||||
"""
|
||||
Generiert die Wiki-Page-ID für die Benutzer-Dokumentationsseite (nach Bereich).
|
||||
Format: {equipment_namespace}:{area_name}:{wiki_doku_id}
|
||||
|
||||
WICHTIG: Diese Seite wird NICHT von Odoo erstellt! Sie wird nur in der Übersichtstabelle
|
||||
verlinkt. Benutzer erstellen sie manuell durch Klick auf den Link.
|
||||
|
||||
Returns:
|
||||
str: Page-ID für Benutzer-Dokumentation
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.ows_area_id:
|
||||
return False
|
||||
|
||||
# Equipment-Namespace aus Systemparameter laden
|
||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
||||
equipment_namespace = IrConfigParameter.get_param(
|
||||
'dokuwiki.equipment_namespace',
|
||||
default='werkstatt:ausstattung'
|
||||
)
|
||||
|
||||
# Area-Name normalisieren (Umlaute, Leerzeichen, Sonderzeichen)
|
||||
area_name = self._normalize_wiki_name(self.ows_area_id.name)
|
||||
|
||||
wiki_doku_id = self._get_wiki_doku_id()
|
||||
return f"{equipment_namespace}:{area_name}:{wiki_doku_id}"
|
||||
|
||||
def _get_wiki_status_page_id(self):
|
||||
"""
|
||||
Generiert die Wiki-Page-ID für die Odoo-Status-Seite (nur lesbar für Benutzer).
|
||||
Format: {equipment_namespace}:odoo-status:{wiki_status_id}
|
||||
Diese Seite wird von Odoo generiert und kann mit dem include-Plugin eingebunden werden.
|
||||
|
||||
Returns:
|
||||
str: Page-ID der Odoo-Status-Seite
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Namespace aus Systemparameter laden (verwendet jetzt central_documentation_namespace)
|
||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
||||
status_namespace = IrConfigParameter.get_param(
|
||||
'dokuwiki.central_documentation_namespace',
|
||||
default='werkstatt:ausstattung:odoo-status'
|
||||
)
|
||||
|
||||
wiki_status_id = self._get_wiki_status_id()
|
||||
return f"{status_namespace}:{wiki_status_id}"
|
||||
|
||||
def _normalize_wiki_name(self, name):
|
||||
"""
|
||||
Normalisiert einen Namen für DokuWiki-Page-IDs.
|
||||
- Umlaute ersetzen
|
||||
- Leerzeichen und Sonderzeichen durch Bindestriche ersetzen
|
||||
- Kleinbuchstaben
|
||||
|
||||
Args:
|
||||
name (str): Original-Name
|
||||
|
||||
Returns:
|
||||
str: Normalisierter Name
|
||||
"""
|
||||
if not name:
|
||||
return "unnamed"
|
||||
|
||||
# Umlaute ersetzen
|
||||
replacements = {
|
||||
'ä': 'ae', 'ö': 'oe', 'ü': 'ue',
|
||||
'Ä': 'ae', 'Ö': 'oe', 'Ü': 'ue',
|
||||
'ß': 'ss'
|
||||
}
|
||||
for old, new in replacements.items():
|
||||
name = name.replace(old, new)
|
||||
|
||||
# Nur alphanumerische Zeichen und Bindestriche
|
||||
name = re.sub(r'[^a-zA-Z0-9\-_]', '-', name)
|
||||
|
||||
# Mehrfache Bindestriche durch einen ersetzen
|
||||
name = re.sub(r'-+', '-', name)
|
||||
|
||||
# Führende/Trailing Bindestriche entfernen
|
||||
name = name.strip('-')
|
||||
|
||||
# Kleinbuchstaben
|
||||
name = name.lower()
|
||||
|
||||
return name or "unnamed"
|
||||
|
||||
def _render_template_from_wiki(self, view_type='area'):
|
||||
"""
|
||||
Lädt c_template aus DokuWiki und ersetzt Platzhalter mit Odoo-Feldwerten.
|
||||
Template-Pfad wird dynamisch aus dem Systemparameter 'dokuwiki.central_documentation_namespace' generiert.
|
||||
Standard: werkstatt:ausstattung:odoo-status:c_template
|
||||
|
||||
Platzhalter-Format:
|
||||
- {feldname} für maintenance.equipment Felder, z.B. {name}, {serial_no}
|
||||
- {ows_machine_id.feldname} für ows.machine Felder, z.B. {ows_machine_id.power}
|
||||
- {wiki_status_page} für die Odoo-Status-Seite ID
|
||||
- {wiki_status_link} für Link zur Odoo-Status-Seite
|
||||
- {ows_area} für Bereichsname
|
||||
- {category} für Kategoriename
|
||||
- {sync_datetime} für aktuelles Datum/Zeit
|
||||
|
||||
Args:
|
||||
view_type (str): 'area' oder 'purpose'
|
||||
|
||||
Returns:
|
||||
str: Gerenderter Wiki-Markup-Inhalt
|
||||
"""
|
||||
self.ensure_one()
|
||||
global _template_cache
|
||||
dokuwiki_client = self.env['dokuwiki.client']
|
||||
|
||||
# Template-Pfad aus Systemparameter generieren
|
||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
||||
status_namespace = IrConfigParameter.get_param(
|
||||
'dokuwiki.central_documentation_namespace',
|
||||
default='werkstatt:ausstattung:odoo-status'
|
||||
)
|
||||
template_page_id = f"{status_namespace}:c_template"
|
||||
|
||||
try:
|
||||
# Template aus Cache oder Wiki laden
|
||||
if template_page_id not in _template_cache:
|
||||
template_content = dokuwiki_client.get_page(template_page_id)
|
||||
if not template_content:
|
||||
_logger.warning(f"Template {template_page_id} nicht gefunden, verwende Fallback")
|
||||
return self._generate_wiki_main_page_content_fallback(view_type)
|
||||
|
||||
_template_cache[template_page_id] = template_content
|
||||
_logger.info(f"Template geladen und gecacht: {template_page_id} ({len(template_content)} Zeichen)")
|
||||
else:
|
||||
_logger.debug(f"Template aus Cache verwendet: {template_page_id}")
|
||||
|
||||
template_content = _template_cache[template_page_id]
|
||||
|
||||
# Werte-Dictionary vorbereiten
|
||||
values = self._prepare_template_values(view_type)
|
||||
|
||||
# Debug-Logging für partner_ref
|
||||
if 'partner_ref' in values:
|
||||
_logger.info(f"partner_ref Wert für {self.name}: '{values['partner_ref']}'")
|
||||
|
||||
# Platzhalter ersetzen
|
||||
rendered_content = template_content
|
||||
for key, value in values.items():
|
||||
placeholder = '{' + key + '}'
|
||||
rendered_content = rendered_content.replace(placeholder, str(value or ''))
|
||||
|
||||
return rendered_content
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning(f"Fehler beim Laden des Templates {template_page_id}: {e}")
|
||||
return self._generate_wiki_main_page_content_fallback(view_type)
|
||||
|
||||
def _prepare_template_values(self, view_type='area'):
|
||||
"""
|
||||
Bereitet Dictionary mit allen verfügbaren Feldwerten für Template-Rendering vor.
|
||||
|
||||
Args:
|
||||
view_type (str): 'area' oder 'purpose'
|
||||
|
||||
Returns:
|
||||
dict: Dictionary mit Feldnamen als Keys und Werten
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Odoo-Status-Seite
|
||||
status_page_id = self._get_wiki_status_page_id()
|
||||
|
||||
# Odoo Base URL
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069')
|
||||
odoo_equipment_url = f"{base_url}/web#id={self.id}&model=maintenance.equipment&view_type=form"
|
||||
|
||||
# Wiki Page IDs
|
||||
wiki_page_id = self._get_wiki_page_id_by_area() if self.ows_area_id else ''
|
||||
|
||||
# Basis-Werte für maintenance.equipment
|
||||
values = {
|
||||
# Spezielle Werte
|
||||
'wiki_status_page': status_page_id,
|
||||
'wiki_status_link': f"[[{status_page_id}|📊 Odoo Status]]",
|
||||
'wiki_page_id': wiki_page_id, # Page-ID der Hauptseite (nach Bereich)
|
||||
'wiki_page_url': self.wiki_page_url or '', # Externe URL zur Hauptseite
|
||||
'odoo_link': f"[[{odoo_equipment_url}|🔗 In Odoo öffnen]]",
|
||||
'odoo_url': odoo_equipment_url,
|
||||
'sync_datetime': datetime.now().strftime('%d.%m.%Y %H:%M'),
|
||||
|
||||
# Standard Equipment-Felder
|
||||
'name': self.name or '',
|
||||
'serial_no': self.serial_no or '',
|
||||
'model': self.model or '',
|
||||
'ows_area': self.ows_area_id.name if self.ows_area_id else '',
|
||||
'category': self.category_id.name if self.category_id else '',
|
||||
'status': self.status_id.name if self.status_id else '',
|
||||
'status_smiley': self.status_id.smiley if self.status_id and self.status_id.smiley else '',
|
||||
'location': self.location or '',
|
||||
'assign_date': self.assign_date.strftime('%d.%m.%Y') if self.assign_date else '',
|
||||
'cost': str(self.cost) if self.cost else '',
|
||||
'warranty_date': self.warranty_date.strftime('%d.%m.%Y') if self.warranty_date else '',
|
||||
'color': str(self.color) if self.color else '',
|
||||
'note': self.note or '',
|
||||
'partner_id': self.partner_id.name if self.partner_id else '',
|
||||
'partner_ref': self.partner_ref or '',
|
||||
}
|
||||
|
||||
# Tags hinzufügen (falls vorhanden)
|
||||
if hasattr(self, 'tag_ids') and self.tag_ids:
|
||||
# Komma-separierte Liste für Template
|
||||
values['tags'] = ', '.join(self.tag_ids.mapped('name'))
|
||||
# DokuWiki Bullet-Liste für Tabellenzellen
|
||||
values['tags_list'] = ' '.join(self.tag_ids.mapped('name')) if self.tag_ids else ''
|
||||
# Anzahl der Tags
|
||||
values['tags_count'] = str(len(self.tag_ids))
|
||||
else:
|
||||
values['tags'] = ''
|
||||
values['tags_list'] = ''
|
||||
values['tags_count'] = '0'
|
||||
|
||||
# ows.machine Felder hinzufügen (falls verknüpft)
|
||||
if self.ows_machine_id:
|
||||
machine = self.ows_machine_id
|
||||
# Nur existierende Felder hinzufügen
|
||||
ows_machine_fields = {}
|
||||
|
||||
# Standard-Felder von ows.machine
|
||||
if hasattr(machine, 'name') and machine.name:
|
||||
ows_machine_fields['ows_machine_id.name'] = machine.name
|
||||
if hasattr(machine, 'model') and machine.model:
|
||||
ows_machine_fields['ows_machine_id.model'] = machine.model
|
||||
if hasattr(machine, 'serial_no') and machine.serial_no:
|
||||
ows_machine_fields['ows_machine_id.serial_no'] = machine.serial_no
|
||||
if hasattr(machine, 'location') and machine.location:
|
||||
ows_machine_fields['ows_machine_id.location'] = machine.location
|
||||
if hasattr(machine, 'note'):
|
||||
ows_machine_fields['ows_machine_id.note'] = machine.note or ''
|
||||
if hasattr(machine, 'ows_category') and machine.ows_category:
|
||||
ows_machine_fields['ows_machine_id.category'] = machine.ows_category
|
||||
if hasattr(machine, 'ows_category_icon') and machine.ows_category_icon:
|
||||
ows_machine_fields['ows_machine_id.category_icon'] = machine.ows_category_icon
|
||||
|
||||
values.update(ows_machine_fields)
|
||||
|
||||
# Bild-Upload und Referenz (falls vorhanden)
|
||||
if self.image_1920:
|
||||
wiki_status_id = self._get_wiki_status_id()
|
||||
# Media-ID mit gleicher Namespace-Struktur wie Pages (DokuWiki Best Practice)
|
||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
||||
equipment_namespace = IrConfigParameter.get_param(
|
||||
'dokuwiki.equipment_namespace',
|
||||
default='werkstatt:ausstattung'
|
||||
)
|
||||
# Area-Name normalisieren (wie bei Pages)
|
||||
area_name = self._normalize_wiki_name(self.ows_area_id.name) if self.ows_area_id else 'unbekannt'
|
||||
# Bild ins Wiki hochladen (erkenne Extension aus Bytes)
|
||||
try:
|
||||
import base64
|
||||
import imghdr
|
||||
|
||||
# image_1920 ist base64-kodiert, in bytes umwandeln für XML-RPC
|
||||
image_bytes = base64.b64decode(self.image_1920)
|
||||
|
||||
# Typ/Extension erkennen (jpeg -> .jpg)
|
||||
img_type = None
|
||||
try:
|
||||
img_type = imghdr.what(None, image_bytes)
|
||||
except Exception:
|
||||
img_type = None
|
||||
|
||||
if img_type == 'jpeg':
|
||||
ext = '.jpg'
|
||||
elif img_type:
|
||||
ext = f'.{img_type}'
|
||||
elif image_bytes.startswith(b'%PDF'):
|
||||
ext = '.pdf'
|
||||
else:
|
||||
ext = '.bin'
|
||||
|
||||
media_id = f"{equipment_namespace}:{area_name}:{wiki_status_id}{ext}"
|
||||
|
||||
dokuwiki_client = self.env['dokuwiki.client']
|
||||
dokuwiki_client.upload_media(media_id, image_bytes, overwrite=True)
|
||||
|
||||
# DokuWiki Image-Syntax: {{namespace:file.jpg?300}}
|
||||
values['image'] = f"{{{{:{media_id}?100}}}}"
|
||||
values['image_large'] = f"{{{{:{media_id}}}}}"
|
||||
values['image_id'] = media_id
|
||||
_logger.info(f"Bild hochgeladen: {media_id}")
|
||||
except Exception as e:
|
||||
_logger.error(f"Fehler beim Bild-Upload: {e}", exc_info=True)
|
||||
values['image'] = ''
|
||||
values['image_large'] = ''
|
||||
values['image_id'] = ''
|
||||
else:
|
||||
values['image'] = ''
|
||||
values['image_large'] = ''
|
||||
values['image_id'] = ''
|
||||
|
||||
# View-Typ spezifische Werte
|
||||
if view_type == 'area':
|
||||
values['view_type'] = 'Bereich'
|
||||
values['view_name'] = self.ows_area_id.name if self.ows_area_id else 'Unbekannt'
|
||||
else:
|
||||
values['view_type'] = 'Einsatzzweck'
|
||||
values['view_name'] = 'TODO: Einsatzzweck'
|
||||
|
||||
return values
|
||||
|
||||
def _generate_wiki_main_page_content_fallback(self, view_type='area'):
|
||||
"""
|
||||
Fallback-Methode: Generiert hart-codierten Wiki-Markup-Inhalt,
|
||||
wenn c_template.txt nicht verfügbar ist.
|
||||
Dieser Content wird in odoo-status/ gespeichert und ist nur lesbar.
|
||||
|
||||
Args:
|
||||
view_type (str): 'area' oder 'purpose'
|
||||
|
||||
Returns:
|
||||
str: Wiki-Markup-Inhalt für odoo-status Seite
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Header je nach View-Typ
|
||||
if view_type == 'area':
|
||||
view_name = self.ows_area_id.name if self.ows_area_id else "Unbekannt"
|
||||
view_label = "Bereich"
|
||||
else:
|
||||
# Später für Einsatzzweck
|
||||
view_name = "TODO: Einsatzzweck"
|
||||
view_label = "Einsatzzweck"
|
||||
|
||||
content = f"""====== {self.name} - Odoo Status ======
|
||||
|
||||
**{view_label}:** {view_name}
|
||||
**Kategorie:** {self.category_id.name if self.category_id else 'Keine'}
|
||||
**Seriennummer:** {self.serial_no or 'Keine'}
|
||||
**Modell:** {self.model or 'Keine'}
|
||||
**Status:** {self.status_id.name if self.status_id else 'Unbekannt'} {self.status_id.smiley if self.status_id and self.status_id.smiley else ''}
|
||||
|
||||
===== Technische Daten =====
|
||||
|
||||
**Hersteller:** {self.partner_id.name if self.partner_id else 'Unbekannt'}
|
||||
**Standort:** {self.location or 'Nicht angegeben'}
|
||||
**Kosten:** {self.cost if self.cost else 'Nicht angegeben'}
|
||||
**Garantie bis:** {self.warranty_date.strftime('%d.%m.%Y') if self.warranty_date else 'Keine'}
|
||||
|
||||
===== Notizen =====
|
||||
|
||||
{self.note or 'Keine Notizen'}
|
||||
|
||||
----
|
||||
//Diese Seite wird automatisch von Odoo generiert und ist nur lesbar.//
|
||||
//Letzte Synchronisation: {datetime.now().strftime('%d.%m.%Y %H:%M')} //
|
||||
"""
|
||||
return content
|
||||
|
||||
def _generate_wiki_main_page_content(self, view_type='area'):
|
||||
"""
|
||||
Generiert den Wiki-Markup-Inhalt für die Odoo-Status-Seite.
|
||||
Verwendet c_template.txt aus DokuWiki falls verfügbar, sonst Fallback.
|
||||
Diese Seite wird in odoo-status/ gespeichert und ist nur lesbar.
|
||||
|
||||
Args:
|
||||
view_type (str): 'area' oder 'purpose'
|
||||
|
||||
Returns:
|
||||
str: Wiki-Markup-Inhalt für odoo-status Seite
|
||||
"""
|
||||
return self._render_template_from_wiki(view_type)
|
||||
|
||||
def _generate_wiki_doku_page_content(self):
|
||||
"""
|
||||
DEPRECATED: Diese Methode wird nicht mehr verwendet.
|
||||
Odoo erstellt keine Benutzer-Dokumentationsseiten mehr.
|
||||
Benutzer erstellen diese Seiten manuell durch Klick auf den Link in der Übersichtstabelle.
|
||||
"""
|
||||
_logger.warning("_generate_wiki_doku_page_content() wurde aufgerufen - diese Methode ist deprecated!")
|
||||
return ""
|
||||
|
||||
def sync_to_dokuwiki(self):
|
||||
"""
|
||||
Synchronisiert Equipment-Daten zum DokuWiki.
|
||||
|
||||
WICHTIG: Erstellt NUR die Odoo-Status-Seite in odoo-status/ Namespace!
|
||||
Benutzer-Dokumentationsseiten werden NICHT erstellt - diese erstellen Benutzer
|
||||
manuell durch Klick auf den Link in der Übersichtstabelle.
|
||||
|
||||
Erstellt/aktualisiert:
|
||||
- wiki_doku_id beim ersten Sync (falls noch nicht gesetzt)
|
||||
- Odoo-Status-Seite (odoo-status:wiki_doku_id) - wird immer aktualisiert
|
||||
|
||||
Returns:
|
||||
bool: True bei Erfolg
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.ows_area_id:
|
||||
raise UserError("Bereich muss gesetzt sein für Wiki-Synchronisation!")
|
||||
|
||||
if not self.name:
|
||||
raise UserError("Equipment-Name muss gesetzt sein für Wiki-Synchronisation!")
|
||||
|
||||
# wiki_status_id beim ersten Sync setzen (aus Equipment-Namen und comp_serial_no generieren)
|
||||
if not self.wiki_status_id:
|
||||
generated_status_id = self._get_wiki_status_id()
|
||||
if not generated_status_id:
|
||||
raise UserError("wiki_status_id konnte nicht generiert werden!")
|
||||
self.write({'wiki_status_id': generated_status_id})
|
||||
_logger.info(f"Wiki-Status-ID für {self.name} gesetzt: {generated_status_id}")
|
||||
|
||||
# wiki_doku_id beim ersten Sync setzen (aus Equipment-Namen generieren, editierbar)
|
||||
if not self.wiki_doku_id:
|
||||
generated_doku_id = self._get_wiki_doku_id()
|
||||
if not generated_doku_id:
|
||||
generated_doku_id = self._normalize_wiki_name(self.name)
|
||||
self.write({'wiki_doku_id': generated_doku_id})
|
||||
_logger.info(f"Wiki-Doku-ID für {self.name} gesetzt: {generated_doku_id}")
|
||||
|
||||
dokuwiki_client = self.env['dokuwiki.client']
|
||||
|
||||
try:
|
||||
# Odoo-Status-Seite erstellen/aktualisieren (immer beim Sync)
|
||||
status_page_id = self._get_wiki_status_page_id()
|
||||
status_content = self._generate_wiki_main_page_content(view_type='area')
|
||||
|
||||
dokuwiki_client.create_page(
|
||||
status_page_id,
|
||||
status_content,
|
||||
f"Synchronisiert von Odoo: {self.name}"
|
||||
)
|
||||
_logger.info(f"Odoo-Status-Seite für {self.name} aktualisiert: {status_page_id}")
|
||||
|
||||
# WICHTIG: Benutzer-Dokumentationsseiten werden NICHT erstellt!
|
||||
# Diese werden nur in der Übersichtstabelle verlinkt und von Benutzern
|
||||
# manuell erstellt durch Klick auf den roten Link.
|
||||
|
||||
# Sync-Status aktualisieren
|
||||
self.write({
|
||||
'wiki_synced': True,
|
||||
'wiki_last_sync': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
_logger.info(f"Equipment {self.name} erfolgreich zu DokuWiki synchronisiert")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(f"Fehler bei Wiki-Synchronisation für {self.name}: {e}")
|
||||
self.write({'wiki_synced': False})
|
||||
raise UserError(f"Wiki-Synchronisation fehlgeschlagen: {e}")
|
||||
|
||||
def action_sync_to_dokuwiki(self):
|
||||
"""
|
||||
Button-Action für manuelle Wiki-Synchronisation.
|
||||
"""
|
||||
for record in self:
|
||||
record.sync_to_dokuwiki()
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Wiki-Synchronisation'),
|
||||
'message': _('Equipment wurde erfolgreich zum Wiki synchronisiert.'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
def action_open_wiki(self):
|
||||
"""
|
||||
Button-Action zum Öffnen der Wiki-Seite im Browser.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.wiki_page_url:
|
||||
raise UserError("Wiki-URL nicht verfügbar. Bitte erst zum Wiki synchronisieren.")
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': self.wiki_page_url,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def write(self, vals):
|
||||
"""
|
||||
Override write um automatische Synchronisation zu triggern.
|
||||
"""
|
||||
result = super().write(vals)
|
||||
|
||||
# Felder die eine Synchronisation auslösen
|
||||
sync_fields = {'name', 'serial_no', 'ows_area_id', 'category_id', 'status_id',
|
||||
'model', 'partner_id', 'partner_ref', 'location', 'note', 'image_1920', 'tag_ids'}
|
||||
|
||||
# Flag ob Übersichtstabelle aktualisiert werden soll
|
||||
should_update_overview = False
|
||||
|
||||
if sync_fields & set(vals.keys()):
|
||||
# Nur synchronisieren wenn auto_sync aktiviert und bereits synced
|
||||
for record in self:
|
||||
if record.wiki_auto_sync and record.wiki_synced:
|
||||
try:
|
||||
record.sync_to_dokuwiki()
|
||||
should_update_overview = True
|
||||
except Exception as e:
|
||||
_logger.warning(f"Automatische Wiki-Sync fehlgeschlagen: {e}")
|
||||
# Nicht abbrechen, nur loggen
|
||||
else:
|
||||
# Sync-Status zurücksetzen
|
||||
record.write({'wiki_synced': False})
|
||||
|
||||
# Optional: Übersichtstabelle aktualisieren
|
||||
if should_update_overview:
|
||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
||||
auto_update_overview = IrConfigParameter.get_param(
|
||||
'dokuwiki.auto_update_overview_table',
|
||||
default='False'
|
||||
)
|
||||
|
||||
if auto_update_overview.lower() == 'true':
|
||||
try:
|
||||
self.env['maintenance.equipment'].action_sync_overview_table()
|
||||
_logger.info("Übersichtstabelle automatisch aktualisiert")
|
||||
except Exception as e:
|
||||
_logger.warning(f"Automatische Übersichtstabellen-Sync fehlgeschlagen: {e}")
|
||||
|
||||
return result
|
||||
|
||||
# ==========================================
|
||||
# Übersichtstabelle (DataTable)
|
||||
# ==========================================
|
||||
|
||||
def _generate_overview_table_row(self, column_data_template):
|
||||
"""
|
||||
Generiert eine DokuWiki DataTable Zeile für dieses Equipment mit konfigurierbaren Spalten.
|
||||
|
||||
Args:
|
||||
column_data_template (str): Template mit Platzhaltern, z.B. "{status_smiley}|{partner_id}|{model}"
|
||||
|
||||
Returns:
|
||||
str: Wiki-Markup für eine Tabellenzeile
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Template-Werte vorbereiten (nutzt bestehende Platzhalter-Engine)
|
||||
values = self._prepare_template_values(view_type='area')
|
||||
|
||||
# Platzhalter im Template ersetzen
|
||||
rendered_data = column_data_template
|
||||
for key, value in values.items():
|
||||
placeholder = '{' + key + '}'
|
||||
rendered_data = rendered_data.replace(placeholder, str(value or '-'))
|
||||
|
||||
# Pipe-separierte Werte in DokuWiki-Tabellenzeile umwandeln
|
||||
columns = rendered_data.split('|')
|
||||
row = '| ' + ' | '.join(columns) + ' |'
|
||||
|
||||
return row
|
||||
|
||||
@api.model
|
||||
def action_sync_overview_table(self):
|
||||
"""
|
||||
Aktualisiert die Übersichtstabelle in DokuWiki mit allen Equipment-Einträgen.
|
||||
Verwendet konfigurierbare Spalten aus Systemparametern (kein Template mehr nötig).
|
||||
|
||||
Returns:
|
||||
dict: Ergebnis-Dictionary mit 'total', 'success', 'error_messages'
|
||||
"""
|
||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
||||
dokuwiki_client = self.env['dokuwiki.client']
|
||||
|
||||
# Config Parameter (Defaults werden via _register_hook automatisch gesetzt)
|
||||
overview_page_id = IrConfigParameter.get_param('dokuwiki.overview_page_id')
|
||||
overview_title = IrConfigParameter.get_param('dokuwiki.overview_title')
|
||||
overview_columns = IrConfigParameter.get_param('dokuwiki.overview_columns')
|
||||
overview_column_data = IrConfigParameter.get_param('dokuwiki.overview_column_data')
|
||||
|
||||
# Validierung
|
||||
if not all([overview_page_id, overview_title, overview_columns, overview_column_data]):
|
||||
raise UserError("DokuWiki Übersichtstabellen-Parameter nicht konfiguriert! Bitte Modul neu installieren.")
|
||||
|
||||
try:
|
||||
# Alle Equipment mit ows_area_id laden (sortiert nach Bereich, dann Name)
|
||||
equipment_records = self.search([
|
||||
('ows_area_id', '!=', False)
|
||||
], order='ows_area_id, name')
|
||||
|
||||
if not equipment_records:
|
||||
raise UserError("Keine Equipment-Einträge mit Bereich gefunden!")
|
||||
|
||||
# Tabellenzeilen generieren
|
||||
table_rows = []
|
||||
for equipment in equipment_records:
|
||||
try:
|
||||
row = equipment._generate_overview_table_row(overview_column_data)
|
||||
table_rows.append(row)
|
||||
except Exception as e:
|
||||
_logger.warning(f"Fehler beim Generieren der Zeile für {equipment.name}: {e}")
|
||||
continue
|
||||
|
||||
# Rows zusammenfügen
|
||||
table_rows_markup = '\n'.join(table_rows)
|
||||
|
||||
# Spalten-Header generieren (aus overview_columns)
|
||||
column_headers = overview_columns.split('|')
|
||||
header_row = '^ ' + ' ^ '.join(column_headers) + ' ^'
|
||||
|
||||
# Sync-Zeitstempel
|
||||
sync_datetime = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
|
||||
|
||||
# Komplette Seite zusammenbauen (ohne Template)
|
||||
overview_content = f"""====== {overview_title} ======
|
||||
|
||||
Diese Seite wird automatisch aktualisiert.
|
||||
|
||||
**Letztes Update:** {sync_datetime}
|
||||
|
||||
<datatable Equipment-Übersicht>
|
||||
{header_row}
|
||||
{table_rows_markup}
|
||||
</datatable>
|
||||
|
||||
----
|
||||
|
||||
**Hinweis:** Diese Tabelle ist interaktiv:
|
||||
* Klicke auf die Spaltenköpfe zum Sortieren
|
||||
* Nutze das Suchfeld zum Filtern
|
||||
* Klicke auf die Links in der Spalte "Name" für Details
|
||||
|
||||
**Status:** 🙂 Maschine/Gerät in gutem Zustand, 🙁 - Maschine/Gerät ist Defekt, ❌ Maschine/Gerät wurde ausgemustert
|
||||
|
||||
**Sicherheit:** 🟢 **keine** Einweisungspflicht, 🟡 keine **explizite** Einweisungspflicht, 🔴 explizite Einweisungspflicht
|
||||
|
||||
(Für Einweisungstermine bitte den Thekendienst befragen oder trage dich [[https://hobbyhimmel.de/so-gehts/einweisungen/|hier]] im Kalender für einen Termin ein)
|
||||
|
||||
|
||||
"""
|
||||
|
||||
# Seite in DokuWiki speichern
|
||||
dokuwiki_client.create_page(
|
||||
overview_page_id,
|
||||
overview_content,
|
||||
f"Automatisches Update der Übersichtstabelle ({len(table_rows)} Equipment)"
|
||||
)
|
||||
|
||||
_logger.info(f"✓ Übersichtstabelle aktualisiert: {len(table_rows)} Equipment in {overview_page_id}")
|
||||
|
||||
return {
|
||||
'total': len(equipment_records),
|
||||
'success': len(table_rows),
|
||||
'error_messages': '',
|
||||
'overview_url': dokuwiki_client.get_wiki_url(overview_page_id),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Fehler beim Aktualisieren der Übersichtstabelle: {str(e)}"
|
||||
_logger.error(error_msg)
|
||||
return {
|
||||
'total': 0,
|
||||
'success': 0,
|
||||
'error_messages': error_msg,
|
||||
'overview_url': '',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def action_clear_template_cache(self):
|
||||
"""
|
||||
Leert den Template-Cache und erzwingt ein Neuladen aller Templates aus DokuWiki.
|
||||
Nützlich nach Template-Änderungen im Wiki.
|
||||
|
||||
Returns:
|
||||
dict: Notification mit Anzahl geleerte Cache-Einträge
|
||||
"""
|
||||
global _template_cache
|
||||
|
||||
count = len(_template_cache)
|
||||
cache_keys = list(_template_cache.keys())
|
||||
_template_cache.clear()
|
||||
|
||||
_logger.info(f"Template-Cache geleert: {count} Einträge ({', '.join(cache_keys)})")
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Template-Cache geleert'),
|
||||
'message': _(f'{count} Template(s) aus dem Cache entfernt. Nächster Sync lädt Templates neu.'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
@api.model
|
||||
def action_reset_wiki_sync_status(self):
|
||||
"""
|
||||
Setzt den Wiki-Synchronisationsstatus für alle Equipment zurück.
|
||||
Nützlich nach Namespace-Änderungen oder wenn alle Equipment neu synchronisiert werden sollen.
|
||||
|
||||
Setzt zurück:
|
||||
- wiki_synced auf False
|
||||
- wiki_last_sync auf NULL
|
||||
|
||||
Optional (auskommentiert):
|
||||
- wiki_doku_id auf NULL (nur wenn IDs komplett neu generiert werden sollen)
|
||||
|
||||
Returns:
|
||||
dict: Notification mit Anzahl zurückgesetzter Equipment
|
||||
"""
|
||||
equipment_records = self.search([
|
||||
('wiki_synced', '=', True)
|
||||
])
|
||||
|
||||
count = len(equipment_records)
|
||||
|
||||
if count == 0:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Wiki-Sync Reset'),
|
||||
'message': _('Keine synchronisierten Equipment gefunden.'),
|
||||
'type': 'warning',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
# Reset durchführen
|
||||
equipment_records.write({
|
||||
'wiki_synced': False,
|
||||
'wiki_last_sync': False,
|
||||
'wiki_status_id': False, # Status-ID neu generieren
|
||||
# wiki_doku_id wird NICHT zurückgesetzt - ist manuell editierbar!
|
||||
})
|
||||
|
||||
_logger.info(f"Wiki-Sync-Status für {count} Equipment zurückgesetzt")
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Wiki-Sync Reset'),
|
||||
'message': _(f'{count} Equipment wurden zurückgesetzt und müssen neu synchronisiert werden.'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class MaintenanceEquipmentStatus(models.Model):
|
||||
"""
|
||||
Extension of the maintenance.equipment.status model to add DokuWiki smiley support.
|
||||
|
||||
This class extends the standard Odoo maintenance equipment status model to include
|
||||
a smiley field that can be used for DokuWiki integration. The smiley field allows
|
||||
administrators to associate DokuWiki emoticon syntax with each maintenance equipment
|
||||
status, enabling visual representation of equipment status in DokuWiki documentation.
|
||||
|
||||
The smiley field accepts DokuWiki emoticon syntax (e.g., ':-)', ':-(', ':-D', etc.)
|
||||
which can be used to display appropriate emoticons when exporting or displaying
|
||||
equipment status information in DokuWiki format.
|
||||
|
||||
This extension is particularly useful for workshops or maintenance departments that
|
||||
use DokuWiki as their documentation platform and want to have a visual representation
|
||||
of equipment status alongside textual information.
|
||||
"""
|
||||
_inherit = 'maintenance.equipment.status'
|
||||
|
||||
smiley = fields.Char(
|
||||
string='Smiley',
|
||||
help='DokuWiki Smiley für diesen Status (z.B. :-) oder :-()'
|
||||
)
|
||||
54
open_workshop_dokuwiki/models/res_config_settings.py
Normal file
54
open_workshop_dokuwiki/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from odoo import api, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
@api.model
|
||||
def _register_hook(self):
|
||||
"""
|
||||
Hook der bei jedem Modul-Load (auch bei Update) ausgeführt wird.
|
||||
Initialisiert fehlende DokuWiki-Parameter.
|
||||
"""
|
||||
super()._register_hook()
|
||||
self._init_dokuwiki_parameters()
|
||||
|
||||
@api.model
|
||||
def _init_dokuwiki_parameters(self):
|
||||
"""
|
||||
Initialisiert DokuWiki-Parameter beim Modul-Install/-Update falls sie nicht existieren.
|
||||
Wird automatisch beim Laden des Moduls aufgerufen.
|
||||
"""
|
||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
# Default-Parameter
|
||||
defaults = {
|
||||
'dokuwiki.url': 'https://wiki.hobbyhimmel.de',
|
||||
'dokuwiki.user': 'odoo.odoo',
|
||||
'dokuwiki.password': 'CHANGE_ME',
|
||||
'dokuwiki.equipment_namespace': 'werkstatt:ausstattung', # NEU: Basis-Namespace für Equipment
|
||||
'dokuwiki.central_documentation_namespace': 'werkstatt:ausstattung:odoo-status', # NEU: Namespace für Odoo-generierte Status-Seiten
|
||||
'dokuwiki.overview_page_id': 'werkstatt:ausstattung:uebersicht', # Seiten-ID für Übersichtstabelle
|
||||
'dokuwiki.overview_title': 'Geräte & Maschinen - Übersicht',
|
||||
'dokuwiki.overview_columns': 'Name|Zustand|Sicherheit|Bereich|Standort|Bild',
|
||||
'dokuwiki.overview_column_data': '[[{wiki_page_id}|{name}]]|{status_smiley}|{ows_machine_id.category_icon}|{ows_area}|{location}|{image}',
|
||||
'dokuwiki.auto_update_overview_table': 'False', # Automatische Übersichtstabellen-Aktualisierung bei Equipment-Änderungen
|
||||
}
|
||||
|
||||
# Nur fehlende Parameter anlegen
|
||||
count_created = 0
|
||||
count_existing = 0
|
||||
for key, value in defaults.items():
|
||||
if not IrConfigParameter.get_param(key):
|
||||
IrConfigParameter.set_param(key, value)
|
||||
count_created += 1
|
||||
_logger.info(f"DokuWiki parameter '{key}' initialized with default value")
|
||||
else:
|
||||
count_existing += 1
|
||||
|
||||
if count_created > 0:
|
||||
_logger.info(f"DokuWiki parameters: {count_created} created, {count_existing} already existing")
|
||||
2
open_workshop_dokuwiki/security/ir.model.access.csv
Normal file
2
open_workshop_dokuwiki/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_equipment_wiki_sync_wizard,equipment.wiki.sync.wizard,model_equipment_wiki_sync_wizard,base.group_user,1,1,1,1
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="maintenance_equipment_status_form_view" model="ir.ui.view">
|
||||
<field name="name">maintenance.equipment.status.form.dokuwiki</field>
|
||||
<field name="model">maintenance.equipment.status</field>
|
||||
<field name="inherit_id" ref="maintenance_equipment_status.maintenance_equipment_status_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="name" position="after">
|
||||
<field name="smiley" placeholder="z.B. :-) oder :-("/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="maintenance_equipment_status_tree_view" model="ir.ui.view">
|
||||
<field name="name">maintenance.equipment.status.tree.dokuwiki</field>
|
||||
<field name="model">maintenance.equipment.status</field>
|
||||
<field name="inherit_id" ref="maintenance_equipment_status.maintenance_equipment_status_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="name" position="after">
|
||||
<field name="smiley" optional="show"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
120
open_workshop_dokuwiki/views/maintenance_equipment_views.xml
Normal file
120
open_workshop_dokuwiki/views/maintenance_equipment_views.xml
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Equipment Tree View Extension - Wiki Link -->
|
||||
<record id="maintenance_equipment_view_tree_dokuwiki" model="ir.ui.view">
|
||||
<field name="name">maintenance.equipment.tree.dokuwiki</field>
|
||||
<field name="model">maintenance.equipment</field>
|
||||
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Wiki Doku-Link nach dem Namen -->
|
||||
<field name="name" position="after">
|
||||
<field name="wiki_doku_link" widget="html" string="Wiki Doku" optional="show"/>
|
||||
<field name="wiki_status_link" widget="html" string="Wiki Status" optional="hide"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Equipment Form View Extension -->
|
||||
<record id="maintenance_equipment_view_form_dokuwiki" model="ir.ui.view">
|
||||
<field name="name">maintenance.equipment.form.dokuwiki</field>
|
||||
<field name="model">maintenance.equipment</field>
|
||||
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Bild-Feld oben rechts neben den Smart Buttons -->
|
||||
<xpath expr="//sheet/div[@name='button_box']" position="before">
|
||||
<field name="image_1920" widget="image" class="oe_avatar" options="{'preview_image': 'image_1920'}"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Smart Button im Header -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_open_wiki"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-book"
|
||||
invisible="not wiki_page_url">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Wiki</span>
|
||||
<span class="o_stat_text text-success" invisible="not wiki_synced">Synchronisiert</span>
|
||||
<span class="o_stat_text text-warning" invisible="wiki_synced">Nicht synchronisiert</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Wiki-Felder in neuem Tab -->
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Wiki" name="wiki">
|
||||
<group>
|
||||
<group string="Wiki Dokumentations-Seite (Benutzer-Doku)" colspan="2">
|
||||
<field name="wiki_doku_id" string="Seitenname"/>
|
||||
<field name="wiki_page_url" widget="url" string="🔗 Zur Doku-Seite" class="oe_link"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Wiki Status-Seite (Odoo-Daten, automatisch)">
|
||||
<field name="wiki_status_id" string="Seitenname" readonly="1"/>
|
||||
</group>
|
||||
<group string="Synchronisations-Status">
|
||||
<field name="wiki_synced" readonly="1"/>
|
||||
<field name="wiki_last_sync" readonly="1"/>
|
||||
<field name="wiki_auto_sync"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
<group string="Aktionen">
|
||||
<button name="action_sync_to_dokuwiki"
|
||||
string="Zum Wiki synchronisieren"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
icon="fa-refresh"/>
|
||||
<button name="action_open_wiki"
|
||||
string="Wiki im Browser öffnen"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-external-link"
|
||||
invisible="not wiki_page_url"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group string="Hilfe">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h4 class="alert-heading">📚 DokuWiki-Integration</h4>
|
||||
<p>
|
||||
Dieses Equipment ist mit DokuWiki verbunden. Die Synchronisation erstellt <strong>zwei unterschiedliche Seiten</strong>:
|
||||
</p>
|
||||
<hr/>
|
||||
<h5>📊 Wiki Status Seite (automatisch, nur lesbar)</h5>
|
||||
<ul>
|
||||
<li><strong>Zweck:</strong> Enthält aktuelle Odoo-Daten (Status, Standort, Hersteller etc.)</li>
|
||||
<li><strong>Format:</strong> werkstatt:ausstattung:odoo-status:[name-seriennummer]</li>
|
||||
<li><strong>Eindeutigkeit:</strong> Jedes Equipment hat eine eigene Status-Seite</li>
|
||||
<li><strong>Update:</strong> Wird bei jeder Synchronisation automatisch aktualisiert</li>
|
||||
</ul>
|
||||
|
||||
<h5>📖 Wiki Dokumentations Seite (manuell, editierbar)</h5>
|
||||
<ul>
|
||||
<li><strong>Zweck:</strong> Benutzer-Dokumentation, Anleitungen, Tipps</li>
|
||||
<li><strong>Format:</strong> werkstatt:ausstattung:[bereich]:[doku-name]</li>
|
||||
<li><strong>Besonderheit:</strong> Kann von mehreren Equipment geteilt werden (z.B. "akkuschrauber")</li>
|
||||
<li><strong>Erstellung:</strong> Wird NICHT von Odoo erstellt - Benutzer erstellen diese manuell</li>
|
||||
</ul>
|
||||
<hr/>
|
||||
<p class="mb-0">
|
||||
<strong>💡 Tipp:</strong> Die Status-Seite kann mit dem include-Plugin in die Dokumentations-Seite eingebunden werden:<br/>
|
||||
<code>{{page>werkstatt:ausstattung:odoo-status:[name-seriennummer]}}</code>
|
||||
</p>
|
||||
<hr/>
|
||||
<p class="mb-0">
|
||||
<strong>⚙️ Automatische Synchronisation:</strong> Wenn aktiviert, werden Änderungen automatisch zur Status-Seite übertragen.
|
||||
</p>
|
||||
</div>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
2
open_workshop_dokuwiki/wizard/__init__.py
Normal file
2
open_workshop_dokuwiki/wizard/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import equipment_wiki_sync_wizard
|
||||
157
open_workshop_dokuwiki/wizard/equipment_wiki_sync_wizard.py
Normal file
157
open_workshop_dokuwiki/wizard/equipment_wiki_sync_wizard.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EquipmentWikiSyncWizard(models.TransientModel):
|
||||
_name = 'equipment.wiki.sync.wizard'
|
||||
_description = 'Massen-Synchronisation von Equipment zu DokuWiki'
|
||||
|
||||
sync_mode = fields.Selection([
|
||||
('all', 'Alle Equipment'),
|
||||
('area', 'Nach Bereich filtern'),
|
||||
('unsynced', 'Nur nicht synchronisierte'),
|
||||
('overview_table', 'Übersichtstabelle aktualisieren'),
|
||||
('reset_sync', 'Sync-Status zurücksetzen'),
|
||||
], string='Synchronisations-Modus', default='all', required=True)
|
||||
|
||||
ows_area_id = fields.Many2one(
|
||||
'ows.machine.area',
|
||||
string='Bereich',
|
||||
help='Nur Equipment aus diesem Bereich synchronisieren'
|
||||
)
|
||||
|
||||
# Statistik-Felder (nach Sync)
|
||||
total_count = fields.Integer(string='Gefunden', readonly=True)
|
||||
success_count = fields.Integer(string='Erfolgreich', readonly=True)
|
||||
error_count = fields.Integer(string='Fehler', readonly=True)
|
||||
error_messages = fields.Text(string='Fehlermeldungen', readonly=True)
|
||||
overview_url = fields.Char(string='Übersichtsseite URL', readonly=True)
|
||||
|
||||
@api.onchange('sync_mode')
|
||||
def _onchange_sync_mode(self):
|
||||
"""Bereich-Feld nur bei 'area' Modus anzeigen"""
|
||||
if self.sync_mode != 'area':
|
||||
self.ows_area_id = False
|
||||
|
||||
def action_sync_equipment(self):
|
||||
"""
|
||||
Hauptaktion: Equipment synchronisieren basierend auf gewähltem Modus
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Übersichtstabelle-Modus: Schneller Update ohne Equipment-Iteration
|
||||
if self.sync_mode == 'overview_table':
|
||||
return self._sync_overview_table()
|
||||
|
||||
# Reset-Modus: Sync-Status zurücksetzen
|
||||
if self.sync_mode == 'reset_sync':
|
||||
return self.env['maintenance.equipment'].action_reset_wiki_sync_status()
|
||||
|
||||
# Equipment-Liste basierend auf Modus ermitteln
|
||||
domain = []
|
||||
|
||||
if self.sync_mode == 'area':
|
||||
if not self.ows_area_id:
|
||||
raise UserError("Bitte einen Bereich auswählen!")
|
||||
domain.append(('ows_area_id', '=', self.ows_area_id.id))
|
||||
elif self.sync_mode == 'unsynced':
|
||||
domain.append(('wiki_synced', '=', False))
|
||||
|
||||
# Equipment finden
|
||||
equipment_records = self.env['maintenance.equipment'].search(domain)
|
||||
|
||||
if not equipment_records:
|
||||
raise UserError("Keine Equipment-Einträge gefunden, die synchronisiert werden können!")
|
||||
|
||||
total = len(equipment_records)
|
||||
success = 0
|
||||
errors = 0
|
||||
error_list = []
|
||||
|
||||
_logger.info(f"Starte Wiki-Synchronisation für {total} Equipment-Einträge")
|
||||
|
||||
# Jeden Equipment-Eintrag synchronisieren
|
||||
for equipment in equipment_records:
|
||||
try:
|
||||
# Prüfen ob Bereich gesetzt ist
|
||||
if not equipment.ows_area_id:
|
||||
error_msg = f"{equipment.name}: Kein Bereich gesetzt"
|
||||
error_list.append(error_msg)
|
||||
errors += 1
|
||||
_logger.warning(error_msg)
|
||||
continue
|
||||
|
||||
# Standard-Synchronisation (erstellt nur Odoo-Status-Seiten)
|
||||
equipment.sync_to_dokuwiki()
|
||||
success += 1
|
||||
_logger.info(f"✓ {equipment.name} synchronisiert")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"{equipment.name}: {str(e)}"
|
||||
error_list.append(error_msg)
|
||||
errors += 1
|
||||
_logger.error(f"✗ {error_msg}")
|
||||
|
||||
# Statistik speichern
|
||||
self.write({
|
||||
'total_count': total,
|
||||
'success_count': success,
|
||||
'error_count': errors,
|
||||
'error_messages': '\n'.join(error_list) if error_list else 'Keine Fehler',
|
||||
})
|
||||
|
||||
# Ergebnis-View anzeigen
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Wiki-Synchronisation Ergebnis',
|
||||
'res_model': 'equipment.wiki.sync.wizard',
|
||||
'view_mode': 'form',
|
||||
'res_id': self.id,
|
||||
'target': 'new',
|
||||
'context': {'show_result': True},
|
||||
}
|
||||
|
||||
def _sync_overview_table(self):
|
||||
"""
|
||||
Aktualisiert die Übersichtstabelle in DokuWiki.
|
||||
Ruft die Methode im maintenance.equipment Model auf.
|
||||
|
||||
Returns:
|
||||
dict: Action zum Anzeigen des Ergebnisses
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
_logger.info("Starte Übersichtstabellen-Synchronisation")
|
||||
|
||||
# Equipment Model aufrufen
|
||||
result = self.env['maintenance.equipment'].action_sync_overview_table()
|
||||
|
||||
# Statistik speichern
|
||||
self.write({
|
||||
'total_count': result.get('total', 0),
|
||||
'success_count': result.get('success', 0),
|
||||
'error_count': result.get('total', 0) - result.get('success', 0),
|
||||
'error_messages': result.get('error_messages', 'Keine Fehler'),
|
||||
'overview_url': result.get('overview_url', ''),
|
||||
})
|
||||
|
||||
# Ergebnis-View anzeigen
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Übersichtstabelle aktualisiert',
|
||||
'res_model': 'equipment.wiki.sync.wizard',
|
||||
'view_mode': 'form',
|
||||
'res_id': self.id,
|
||||
'target': 'new',
|
||||
'context': {'show_result': True},
|
||||
}
|
||||
|
||||
def action_clear_template_cache(self):
|
||||
"""
|
||||
Leert den Template-Cache (delegiert an maintenance.equipment).
|
||||
"""
|
||||
return self.env['maintenance.equipment'].action_clear_template_cache()
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Wizard Form View -->
|
||||
<record id="equipment_wiki_sync_wizard_form" model="ir.ui.view">
|
||||
<field name="name">equipment.wiki.sync.wizard.form</field>
|
||||
<field name="model">equipment.wiki.sync.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Wiki-Synchronisation">
|
||||
<group invisible="context.get('show_result', False)">
|
||||
<group>
|
||||
<field name="sync_mode" widget="radio"/>
|
||||
<field name="ows_area_id" invisible="sync_mode != 'area'" required="sync_mode == 'area'"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Ergebnis-Anzeige -->
|
||||
<group invisible="not context.get('show_result', False)">
|
||||
<group>
|
||||
<field name="total_count"/>
|
||||
<field name="success_count"/>
|
||||
<field name="error_count"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="overview_url" widget="url" string="Übersichtsseite" invisible="not overview_url"/>
|
||||
<field name="error_messages" widget="text" colspan="2" invisible="error_count == 0"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<footer>
|
||||
<button string="Synchronisieren"
|
||||
name="action_sync_equipment"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="context.get('show_result', False)"/>
|
||||
<button string="Template-Cache leeren"
|
||||
name="action_clear_template_cache"
|
||||
type="object"
|
||||
class="btn-warning"
|
||||
invisible="context.get('show_result', False)"
|
||||
help="Leert den Template-Cache und erzwingt Neuladen beim nächsten Sync"/>
|
||||
<button string="Schließen"
|
||||
special="cancel"
|
||||
class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu-Action -->
|
||||
<record id="action_equipment_wiki_sync_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Wiki-Synchronisation</field>
|
||||
<field name="res_model">equipment.wiki.sync.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="context">{}</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu-Eintrag unter Wartung → Konfiguration -->
|
||||
<menuitem id="menu_equipment_wiki_sync"
|
||||
name="Wiki-Synchronisation"
|
||||
parent="maintenance.menu_maintenance_configuration"
|
||||
action="action_equipment_wiki_sync_wizard"
|
||||
sequence="99"/>
|
||||
</odoo>
|
||||
139
open_workshop_employee_imagegenerator/README.md
Normal file
139
open_workshop_employee_imagegenerator/README.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Open Workshop - Employee Image Generator
|
||||
|
||||
Erstelle professionelle Namensschilder (Thekenschilder) für Mitarbeiter direkt in Odoo.
|
||||
|
||||
## Beschreibung
|
||||
|
||||
Dieses Modul ersetzt die externe "thekenheld" WebApp und integriert die Funktionalität direkt in Odoo's HR-Modul. Mitarbeiter-Namensschilder können nun bequem aus dem Employee-Formular heraus erstellt werden.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Foto-Upload & Zuschnitt**: Cropper.js Integration für professionelle Bildbearbeitung
|
||||
- ✅ **Festes Seitenverhältnis**: 369x492 Pixel (optimiert für Thekenschilder)
|
||||
- ✅ **Text-Overlay**: Name und Schwerpunkte werden automatisch hinzugefügt
|
||||
- ✅ **Direkte Integration**: Button im Employee-Formular
|
||||
- ✅ **Sofortige Verfügbarkeit**: Generiertes Bild wird direkt als Employee Avatar gespeichert
|
||||
- ✅ **POS Integration**: Bilder werden automatisch im Customer Display angezeigt (via open_workshop_pos_customer_display)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Modul in Odoo installieren
|
||||
2. Berechtigungen: HR / Mitarbeiter Module erforderlich
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Namensschild erstellen:
|
||||
|
||||
1. Öffne einen Mitarbeiter-Datensatz (HR → Mitarbeiter)
|
||||
2. Klicke auf den Button **"Namensschild erstellen"** im Header
|
||||
3. **Schritt 1**: Wähle ein Foto aus (mindestens 369x492 Pixel empfohlen)
|
||||
4. **Schritt 2**: Schneide das Foto zu - bewege und zoome für den perfekten Ausschnitt
|
||||
5. **Schritt 3**: Überprüfe Name und Schwerpunkte, passe bei Bedarf an
|
||||
6. **Vorschau**: Sieh dir das finale Namensschild an
|
||||
7. Klicke **"Speichern"** - das Bild wird als Employee Avatar gesetzt
|
||||
|
||||
### Schwerpunkte definieren:
|
||||
|
||||
Im Employee-Formular gibt es ein neues Feld **"Schwerpunkte"** (job_focus):
|
||||
- Trage hier die Tätigkeitsschwerpunkte ein
|
||||
- Z.B. "Fahrrad-Reparatur, E-Bikes, Werkstatt"
|
||||
- Max. 2 Zeilen für optimale Darstellung
|
||||
- Falls leer, wird alternativ das job_title Feld verwendet
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Architektur
|
||||
|
||||
**Frontend-only Lösung** mit:
|
||||
- **OWL Component**: Client Action für Dialog
|
||||
- **Cropper.js**: Professionelles Bild-Cropping
|
||||
- **Canvas API**: Text-Overlay Generation
|
||||
- **Base64 Upload**: Direktes Speichern als image_1920
|
||||
|
||||
### Dateien
|
||||
|
||||
```
|
||||
open_workshop_employee_imagegenerator/
|
||||
├── __init__.py
|
||||
├── __manifest__.py
|
||||
├── models/
|
||||
│ ├── __init__.py
|
||||
│ └── hr_employee.py # Erweiterung von hr.employee
|
||||
├── views/
|
||||
│ └── hr_employee_views.xml # Button im Form + job_focus Feld
|
||||
├── static/
|
||||
│ ├── src/
|
||||
│ │ ├── js/
|
||||
│ │ │ └── employee_image_widget.js # OWL Component
|
||||
│ │ ├── xml/
|
||||
│ │ │ └── employee_image_widget.xml # Template
|
||||
│ │ └── css/
|
||||
│ │ └── employee_image_widget.css # Styles
|
||||
│ └── lib/
|
||||
│ └── cropperjs/
|
||||
│ ├── cropper.js # Cropper.js Library
|
||||
│ └── cropper.css # Cropper.js Styles
|
||||
```
|
||||
|
||||
### Workflow
|
||||
|
||||
1. User klickt "Namensschild erstellen"
|
||||
2. Python Action öffnet Client Action mit Context (employee_id, name, job_focus)
|
||||
3. JavaScript Dialog (3 Schritte):
|
||||
- Upload → FileReader lädt Bild als Data URL
|
||||
- Crop → Cropper.js erzeugt zugeschnittenes Bild (369x492)
|
||||
- Text → Canvas API zeichnet Text-Overlay
|
||||
4. Base64 Image wird zu hr.employee.image_1920 gespeichert
|
||||
5. Page Reload zeigt neues Bild
|
||||
|
||||
### Canvas Text-Overlay
|
||||
|
||||
Das generierte Namensschild hat:
|
||||
- **Foto**: 369x492 Pixel (Vollbild)
|
||||
- **Text-Overlay**: 120px Höhe am unteren Rand
|
||||
- **Hintergrund**: Halbtransparent weiß (92% Opazität)
|
||||
- **Name**: Fett, 24px, zentriert
|
||||
- **Schwerpunkte**: Normal, 16px, zentriert, max. 2 Zeilen
|
||||
|
||||
## Integration
|
||||
|
||||
### Mit POS Customer Display
|
||||
|
||||
Dieses Modul arbeitet nahtlos mit `open_workshop_pos_customer_display` zusammen:
|
||||
- Generierte Bilder werden automatisch im Customer Display angezeigt
|
||||
- Verwendung der hr.employee.public URL
|
||||
- Keine zusätzliche Konfiguration nötig
|
||||
|
||||
### Ersetzt thekenheld WebApp
|
||||
|
||||
Die externe Docker-Container WebApp ist nicht mehr nötig:
|
||||
- ✅ Keine separate Instanz erforderlich
|
||||
- ✅ Alle Daten in Odoo
|
||||
- ✅ Berechtigungsverwaltung über Odoo
|
||||
- ✅ Besserer Workflow
|
||||
|
||||
## Migration von thekenheld
|
||||
|
||||
Falls bereits Bilder in der alten WebApp erstellt wurden:
|
||||
1. Bilder aus `/var/www/html/uploads/` kopieren
|
||||
2. Manuell in Odoo Employee-Datensätze hochladen
|
||||
3. Oder: Neues Namensschild mit diesem Tool erstellen
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
- `hr` - Human Resources Module
|
||||
- `web` - Odoo Web Framework
|
||||
- Cropper.js v1.6.1 (inkludiert)
|
||||
|
||||
## Version
|
||||
|
||||
18.0.1.0.0
|
||||
|
||||
## Lizenz
|
||||
|
||||
LGPL-3
|
||||
|
||||
## Credits
|
||||
|
||||
Entwickelt für Open Workshop / Hobbyhimmel
|
||||
Basierend auf der thekenheld WebApp
|
||||
2
open_workshop_employee_imagegenerator/__init__.py
Normal file
2
open_workshop_employee_imagegenerator/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
53
open_workshop_employee_imagegenerator/__manifest__.py
Normal file
53
open_workshop_employee_imagegenerator/__manifest__.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Open Workshop - Employee Image Generator',
|
||||
'version': '18.0.1.0.0',
|
||||
'category': 'Human Resources',
|
||||
'summary': 'Upload and crop employee photos (369x492) with job focus',
|
||||
'description': """
|
||||
Employee Image Generator
|
||||
=========================
|
||||
|
||||
Upload and crop employee photos directly in Odoo.
|
||||
|
||||
Features:
|
||||
---------
|
||||
* Upload and crop employee photos to fixed 369x492 pixels
|
||||
* Add job focus areas (Schwerpunkte)
|
||||
* Integrated Cropper.js for professional image editing
|
||||
* Fixed crop frame - only image moves and zooms
|
||||
* Direct integration in Employee form
|
||||
* Saved photo can be used in POS Customer Display
|
||||
|
||||
Usage:
|
||||
------
|
||||
1. Go to Employee form
|
||||
2. Click "Namensschild erstellen" button
|
||||
3. Upload photo, zoom and position it in fixed crop frame
|
||||
4. Enter job focus areas (optional)
|
||||
5. Save - photo and job_focus are saved to employee
|
||||
""",
|
||||
'author': 'Open Workshop',
|
||||
'website': 'https://www.open-workshop.de',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['hr', 'web'],
|
||||
'data': [
|
||||
'views/hr_employee_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
# Cropper.js CSS
|
||||
'open_workshop_employee_imagegenerator/static/lib/cropperjs/cropper.css',
|
||||
'open_workshop_employee_imagegenerator/static/src/css/employee_image_widget.css',
|
||||
'open_workshop_employee_imagegenerator/static/src/css/badge_template.css',
|
||||
# Cropper.js Library
|
||||
'open_workshop_employee_imagegenerator/static/lib/cropperjs/cropper.js',
|
||||
# Our custom widgets
|
||||
'open_workshop_employee_imagegenerator/static/src/js/employee_image_widget.js',
|
||||
'open_workshop_employee_imagegenerator/static/src/xml/employee_image_widget.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
2
open_workshop_employee_imagegenerator/models/__init__.py
Normal file
2
open_workshop_employee_imagegenerator/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import hr_employee
|
||||
32
open_workshop_employee_imagegenerator/models/hr_employee.py
Normal file
32
open_workshop_employee_imagegenerator/models/hr_employee.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
# Zusätzliches Feld für Schwerpunkte (falls nicht job_title verwendet wird)
|
||||
job_focus = fields.Char(
|
||||
string='Schwerpunkte',
|
||||
help='Tätigkeitsschwerpunkte für Namensschild'
|
||||
)
|
||||
|
||||
def action_open_image_generator(self):
|
||||
"""Öffnet den Image Generator Dialog"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'employee_image_generator',
|
||||
'name': 'Namensschild erstellen',
|
||||
'target': 'new',
|
||||
'params': {
|
||||
'employee_id': self.id,
|
||||
'employee_name': self.name,
|
||||
'job_focus': self.job_focus or self.job_title or '',
|
||||
},
|
||||
'context': {
|
||||
'employee_id': self.id,
|
||||
'employee_name': self.name,
|
||||
'job_focus': self.job_focus or self.job_title or '',
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/*!
|
||||
* Cropper.js v1.6.1
|
||||
* https://fengyuanchen.github.io/cropperjs
|
||||
*
|
||||
* Copyright 2015-present Chen Fengyuan
|
||||
* Released under the MIT license
|
||||
*
|
||||
* Date: 2023-09-17T03:44:17.565Z
|
||||
*/.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
|
||||
File diff suppressed because one or more lines are too long
20
open_workshop_employee_imagegenerator/static/lib/html2canvas.min.js
vendored
Normal file
20
open_workshop_employee_imagegenerator/static/lib/html2canvas.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,96 @@
|
|||
/* Badge Template CSS - 1:1 von thekenheld print.html */
|
||||
|
||||
.badge-template {
|
||||
position: relative;
|
||||
background: white;
|
||||
width: 21cm;
|
||||
height: 29.7cm;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.badge-template header,
|
||||
.badge-template footer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgb(255, 255, 255);
|
||||
padding-right: 1.5cm;
|
||||
padding-left: 1.5cm;
|
||||
height: 30mm;
|
||||
}
|
||||
|
||||
.badge-template header {
|
||||
top: 0;
|
||||
padding-top: 5mm;
|
||||
padding-bottom: 3mm;
|
||||
}
|
||||
|
||||
.badge-template footer {
|
||||
bottom: 0;
|
||||
color: rgb(241, 245, 247);
|
||||
padding-top: 3mm;
|
||||
padding-bottom: 5mm;
|
||||
}
|
||||
|
||||
.badge-template .content {
|
||||
padding-top: 40mm;
|
||||
padding-bottom: 40mm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.badge-template .logo {
|
||||
width: 43mm;
|
||||
height: 20mm;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.badge-template .headline1 {
|
||||
height: 13mm;
|
||||
font-size: 10mm;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge-template .headline2 {
|
||||
height: 9mm;
|
||||
font-size: 6mm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.badge-template .headline3 {
|
||||
height: 10mm;
|
||||
font-size: 7mm;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge-template .centered-image {
|
||||
height: 130mm;
|
||||
width: 100mm;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
padding: 5mm 0 0 0;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.badge-template .your-name {
|
||||
height: 22mm;
|
||||
font-size: 19mm;
|
||||
padding: 0;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.badge-template .topics {
|
||||
height: 10mm;
|
||||
font-size: 8mm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.badge-template .warnung {
|
||||
font-size: 9mm;
|
||||
color: red;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/* Employee Image Generator Styles */
|
||||
|
||||
.o_employee_image_generator {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.o_employee_image_generator .img-container {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.o_employee_image_generator .img-container img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.o_employee_image_generator canvas {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Cropper.js Container Anpassungen */
|
||||
.o_employee_image_generator .cropper-container {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.o_employee_image_generator .progress {
|
||||
height: 3px;
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.o_employee_image_generator .progress-bar {
|
||||
background-color: #0d6efd;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Step Indicators */
|
||||
.o_employee_image_generator .step-indicator {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Upload Area */
|
||||
.o_employee_image_generator .upload-area {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.o_employee_image_generator .upload-area:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Canvas Preview */
|
||||
.o_employee_image_generator canvas {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Modal Anpassungen */
|
||||
.o_employee_image_generator .modal-dialog {
|
||||
margin: 1.75rem auto;
|
||||
}
|
||||
|
||||
.o_employee_image_generator .modal-content {
|
||||
border: none;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.o_employee_image_generator .modal-header {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.o_employee_image_generator .modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.o_employee_image_generator .modal-footer {
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.o_employee_image_generator .modal-dialog {
|
||||
max-width: 95%;
|
||||
margin: 0.5rem auto;
|
||||
}
|
||||
|
||||
.o_employee_image_generator .modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Component, useState, useRef, onMounted } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
/**
|
||||
* Employee Image Generator Client Action
|
||||
*
|
||||
* Ermöglicht das Erstellen von professionellen Namensschildern für Mitarbeiter.
|
||||
* Verwendet Cropper.js für Bildbearbeitung und Canvas API für Text-Overlay.
|
||||
*/
|
||||
class EmployeeImageGenerator extends Component {
|
||||
static template = "open_workshop_employee_imagegenerator.ImageGeneratorDialog";
|
||||
static props = {
|
||||
"*": true,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
this.notification = useService("notification");
|
||||
|
||||
// Hole Context aus props oder action
|
||||
const context = this.props.action?.context || this.props.context || {};
|
||||
|
||||
this.state = useState({
|
||||
step: 1, // 1: Upload, 2: Crop, 3: Text, 4: Preview
|
||||
employeeId: context.employee_id,
|
||||
employeeName: context.employee_name || '',
|
||||
jobFocus: context.job_focus || '',
|
||||
companyLogo: null,
|
||||
imageDataUrl: null,
|
||||
croppedImageDataUrl: null,
|
||||
finalImageDataUrl: null,
|
||||
});
|
||||
|
||||
this.fileInput = useRef("fileInput");
|
||||
this.imagePreview = useRef("imagePreview");
|
||||
this.cropperImage = useRef("cropperImage");
|
||||
this.badgeTemplate = useRef("badgeTemplate");
|
||||
|
||||
this.cropper = null;
|
||||
// Zielgröße für das zugeschnittene Foto
|
||||
this.cropWidth = 369;
|
||||
this.cropHeight = 492;
|
||||
// Zielgröße für das finale Badge (A4-Format)
|
||||
this.badgeWidth = 794;
|
||||
this.badgeHeight = 1123;
|
||||
// Umrechnungsfaktor für mm (falls benötigt)
|
||||
this.pxPerMm = this.badgeWidth / 210;
|
||||
|
||||
// Lade Company-Logo beim Start
|
||||
this.loadCompanyLogo();
|
||||
}
|
||||
|
||||
async loadCompanyLogo() {
|
||||
try {
|
||||
// Hole das Logo der aktuellen Company
|
||||
const company = await this.orm.call('res.company', 'search_read', [[]], {
|
||||
fields: ['logo'],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (company && company.length > 0 && company[0].logo) {
|
||||
// Company-Logo ist base64-codiert, füge data:image/png prefix hinzu
|
||||
this.state.companyLogo = `data:image/png;base64,${company[0].logo}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Konnte Company-Logo nicht laden:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Schritt 1: Datei-Upload
|
||||
onFileSelect(ev) {
|
||||
const files = ev.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
|
||||
// Prüfe ob es ein Bild ist
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this.notification.add('Bitte wähle eine Bilddatei aus', {
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Lade Bild als Data URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.state.imageDataUrl = e.target.result;
|
||||
this.state.step = 2;
|
||||
|
||||
// Initialisiere Cropper im nächsten Tick
|
||||
setTimeout(() => this.initCropper(), 100);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Schritt 2: Initialisiere Cropper.js
|
||||
initCropper() {
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy();
|
||||
}
|
||||
|
||||
const imageElement = this.cropperImage.el;
|
||||
if (!imageElement || !window.Cropper) {
|
||||
console.error('Cropper.js not loaded or image element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const aspectRatio = this.cropWidth / this.cropHeight;
|
||||
|
||||
this.cropper = new window.Cropper(imageElement, {
|
||||
aspectRatio: aspectRatio,
|
||||
viewMode: 1, // Erlaubt unbegrenztes Zoomen
|
||||
dragMode: 'move', // Bild verschieben
|
||||
autoCropArea: 0.7, // Crop-Rahmen ist 70% des Containers - besser sichtbar
|
||||
restore: false,
|
||||
guides: true,
|
||||
center: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: false, // Crop-Rahmen NICHT verschiebbar
|
||||
cropBoxResizable: false, // Crop-Rahmen NICHT in Größe veränderbar
|
||||
toggleDragModeOnDblclick: false,
|
||||
zoomable: true,
|
||||
zoomOnWheel: true, // Zoom mit Mausrad
|
||||
zoomOnTouch: true, // Zoom mit Touch-Gesten
|
||||
minContainerWidth: 200,
|
||||
minContainerHeight: 200,
|
||||
ready: function() {
|
||||
// Setze initiales Zoom-Level auf "fit in container"
|
||||
this.cropper.zoomTo(0);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Bild zuschneiden und weiter zu Schritt 3
|
||||
cropImage() {
|
||||
if (!this.cropper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = this.cropper.getCroppedCanvas({
|
||||
width: this.cropWidth,
|
||||
height: this.cropHeight,
|
||||
});
|
||||
|
||||
if (canvas) {
|
||||
this.state.croppedImageDataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
this.state.step = 3;
|
||||
|
||||
// Destroy cropper um Ressourcen freizugeben
|
||||
this.cropper.destroy();
|
||||
this.cropper = null;
|
||||
|
||||
// Gehe zu Schritt 3 (das Template ist bereits sichtbar via t-if)
|
||||
// Die Vorschau wird automatisch angezeigt durch das HTML-Template
|
||||
}
|
||||
}
|
||||
|
||||
// Schritt 3: Text-Änderung → Vorschau ist Live durch HTML-Template
|
||||
onTextChange() {
|
||||
// Das Template aktualisiert sich automatisch durch t-model
|
||||
// Keine zusätzliche Logik nötig - OWL reactive state macht das automatisch!
|
||||
}
|
||||
|
||||
// Schritt 4: Finale Speicherung - Speichere nur das zugeschnittene Foto + job_focus
|
||||
async saveFinalImage() {
|
||||
try {
|
||||
// Verwende das bereits zugeschnittene Foto (369×492px)
|
||||
if (!this.state.croppedImageDataUrl) {
|
||||
this.notification.add('Kein zugeschnittenes Foto vorhanden', { type: 'danger' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Extrahiere Base64-Daten (entferne data:image/jpeg;base64, prefix)
|
||||
const imageData = this.state.croppedImageDataUrl.split(',')[1];
|
||||
|
||||
// Speichere Foto + job_focus in Odoo
|
||||
await this.orm.write('hr.employee', [this.state.employeeId], {
|
||||
image_1920: imageData,
|
||||
job_focus: this.state.jobFocus || '',
|
||||
});
|
||||
|
||||
this.notification.add('Foto erfolgreich gespeichert!', {
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
// Schließe Dialog und kehre zurück zum Mitarbeiter-Formular
|
||||
await this.action.doAction({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'hr.employee',
|
||||
res_id: this.state.employeeId,
|
||||
views: [[false, 'form']],
|
||||
target: 'current',
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern:', error);
|
||||
this.notification.add(`Fehler beim Speichern: ${error.message}`, {
|
||||
type: 'danger',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Abbrechen
|
||||
cancel() {
|
||||
if (this.props.close) {
|
||||
this.props.close();
|
||||
} else {
|
||||
this.action.doAction({
|
||||
type: 'ir.actions.act_window_close'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Zurück zum vorherigen Schritt
|
||||
previousStep() {
|
||||
if (this.state.step > 1) {
|
||||
this.state.step--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Registriere als Client Action
|
||||
registry.category("actions").add("employee_image_generator", EmployeeImageGenerator);
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="open_workshop_employee_imagegenerator.ImageGeneratorDialog">
|
||||
<div class="o_employee_image_generator modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">
|
||||
<i class="fa fa-image me-2"/>
|
||||
Namensschild erstellen
|
||||
</h4>
|
||||
<button type="button" class="btn-close" t-on-click="cancel"/>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="modal-body">
|
||||
<!-- Schritt-Anzeige -->
|
||||
<div class="mb-3">
|
||||
<div class="progress" style="height: 3px;">
|
||||
<div class="progress-bar"
|
||||
role="progressbar"
|
||||
t-att-style="'width: ' + (state.step * 33) + '%'"
|
||||
aria-valuenow="state.step"
|
||||
aria-valuemin="1"
|
||||
aria-valuemax="3"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-2 text-muted small">
|
||||
<span t-att-class="{'text-primary fw-bold': state.step === 1}">1. Bild hochladen</span>
|
||||
<span t-att-class="{'text-primary fw-bold': state.step === 2}">2. Bild zuschneiden</span>
|
||||
<span t-att-class="{'text-primary fw-bold': state.step === 3}">3. Text & Vorschau</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schritt 1: Upload -->
|
||||
<div t-if="state.step === 1" class="text-center p-5">
|
||||
<i class="fa fa-cloud-upload fa-5x text-muted mb-3"/>
|
||||
<h5>Wähle ein Foto aus</h5>
|
||||
<p class="text-muted">Das Bild sollte mindestens 369x492 Pixel groß sein</p>
|
||||
<input type="file"
|
||||
t-ref="fileInput"
|
||||
class="d-none"
|
||||
accept="image/*"
|
||||
t-on-change="onFileSelect"/>
|
||||
<button class="btn btn-primary btn-lg"
|
||||
t-on-click="() => this.fileInput.el.click()">
|
||||
<i class="fa fa-folder-open me-2"/>
|
||||
Datei auswählen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Schritt 2: Crop -->
|
||||
<div t-if="state.step === 2">
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="fa fa-info-circle me-2"/>
|
||||
Bewege und zoome das Bild, um den gewünschten Ausschnitt zu wählen
|
||||
</div>
|
||||
<div class="img-container" style="max-height: 500px; overflow: hidden; display: flex; align-items: center; justify-content: center;">
|
||||
<img t-ref="cropperImage"
|
||||
t-att-src="state.imageDataUrl"
|
||||
style="max-width: 100%; max-height: 100%; display: block;"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schritt 3: Text und Vorschau -->
|
||||
<div t-if="state.step === 3">
|
||||
<div class="row">
|
||||
<!-- Linke Spalte: Eingabefelder -->
|
||||
<div class="col-md-6">
|
||||
<h5 class="mb-3">Textinformationen</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="employeeName" class="form-label">Name</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="employeeName"
|
||||
t-model="state.employeeName"
|
||||
t-on-input="onTextChange"
|
||||
placeholder="Max Mustermann"/>
|
||||
<small class="form-text text-muted">
|
||||
Dieser Name wird auf dem Namensschild angezeigt
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="jobFocus" class="form-label">Schwerpunkte</label>
|
||||
<textarea class="form-control"
|
||||
id="jobFocus"
|
||||
rows="3"
|
||||
t-model="state.jobFocus"
|
||||
t-on-input="onTextChange"
|
||||
placeholder="z.B. Fahrrad-Reparatur, E-Bikes, Werkstatt"/>
|
||||
<small class="form-text text-muted">
|
||||
Tätigkeitsbereiche oder Spezialisierung (optional)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-light">
|
||||
<strong>Tipp:</strong> Kurze Texte sehen besser aus.
|
||||
Maximal 2 Zeilen für Schwerpunkte.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechte Spalte: Vorschau -->
|
||||
<div class="col-md-6">
|
||||
<h5 class="mb-3">Vorschau</h5>
|
||||
<div class="border rounded p-3 d-flex justify-content-center align-items-center"
|
||||
style="background: rgb(204, 204, 204); min-height: 400px;">
|
||||
<!-- Wrapper nimmt nur den skalierten Platz ein (21cm × 29.7cm × 0.35 = 7.35cm × 10.4cm) -->
|
||||
<div style="width: 7.35cm; height: 10.4cm; position: relative;">
|
||||
<div style="width: 21cm; height: 29.7cm; transform: scale(0.35); transform-origin: top left; position: absolute; top: 0; left: 0;">
|
||||
<div t-ref="badgeTemplate" class="badge-template">
|
||||
<header>
|
||||
<div class="logo">
|
||||
<img t-if="state.companyLogo" t-att-src="state.companyLogo" alt="Logo" class="logo"/>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content">
|
||||
<div class="headline1">An der Theke für Dich</div>
|
||||
<div class="headline2">Werkstattaufsicht und Werkzeugausgabe</div>
|
||||
<img t-if="state.croppedImageDataUrl"
|
||||
t-att-src="state.croppedImageDataUrl"
|
||||
alt="avatar"
|
||||
class="centered-image"/>
|
||||
<div class="your-name" t-esc="state.employeeName || 'Dein Name'"/>
|
||||
<hr/>
|
||||
<div class="headline3">Themenschwerpunkte:</div>
|
||||
<div class="topics" t-esc="state.jobFocus || 'Deine Schwerpunkte'"/>
|
||||
|
||||
</div>
|
||||
<footer><div class="warnung">! Zugang zur Theke nur für Berechtigte !</div></footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-2 text-center">
|
||||
A4-Format (21×29.7cm) - 35% Vorschau
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-footer">
|
||||
<button type="button"
|
||||
class="btn btn-secondary"
|
||||
t-on-click="cancel">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
<button t-if="state.step > 1 and state.step < 3"
|
||||
type="button"
|
||||
class="btn btn-light"
|
||||
t-on-click="previousStep">
|
||||
<i class="fa fa-arrow-left me-2"/>
|
||||
Zurück
|
||||
</button>
|
||||
|
||||
<button t-if="state.step === 2"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
t-on-click="cropImage">
|
||||
Weiter
|
||||
<i class="fa fa-arrow-right ms-2"/>
|
||||
</button>
|
||||
|
||||
<button t-if="state.step === 3"
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
t-on-click="saveFinalImage">
|
||||
<i class="fa fa-check me-2"/>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Erweitere das Employee Form View -->
|
||||
<record id="view_employee_form_image_generator" model="ir.ui.view">
|
||||
<field name="name">hr.employee.form.image.generator</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Füge Button nach dem image_1920 Feld ein -->
|
||||
<xpath expr="//field[@name='image_1920']" position="after">
|
||||
<button name="action_open_image_generator"
|
||||
type="object"
|
||||
string="✏️ Bild bearbeiten"
|
||||
class="btn-link"
|
||||
style="margin-left: 8px;"
|
||||
invisible="not id"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Füge job_focus Feld nach job_title ein -->
|
||||
<xpath expr="//field[@name='job_title']" position="after">
|
||||
<field name="job_focus" placeholder="z.B. Fahrrad-Reparatur, E-Bikes, Werkstatt"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from .hooks import post_init_hook
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Open Workshop POS',
|
||||
'version': '18.0.1.0.0',
|
||||
'version': '18.0.1.0.1',
|
||||
'category': 'Point of Sale',
|
||||
'summary': 'POS Integration für Open Workshop - Machine Access & Customer UI',
|
||||
'description': """
|
||||
|
|
@ -27,6 +27,7 @@ Autor: HobbyHimmel
|
|||
'depends': [
|
||||
'open_workshop_base',
|
||||
'point_of_sale',
|
||||
'l10n_de', # Kontenplan für Deutschland (erstellt Bankjournal für POS Demo-Daten)
|
||||
],
|
||||
'data': [
|
||||
# Views
|
||||
|
|
@ -38,12 +39,24 @@ 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',
|
||||
|
|
@ -52,4 +65,5 @@ Autor: HobbyHimmel
|
|||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'post_init_hook': 'post_init_hook',
|
||||
}
|
||||
|
|
|
|||
46
open_workshop_pos/hooks.py
Normal file
46
open_workshop_pos/hooks.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# -*- 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")
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# POS-spezifische Modelle (falls benötigt)
|
||||
from . import pos_order
|
||||
|
|
|
|||
|
|
@ -55,3 +55,9 @@
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
.bg-200, .text-bg-200 {
|
||||
--background-color: rgb(122, 122, 122);
|
||||
background-color: var(--background-color) !important;
|
||||
}
|
||||
20
open_workshop_pos/static/src/js/ows_control_buttons_patch.js
Normal file
20
open_workshop_pos/static/src/js/ows_control_buttons_patch.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/** @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;
|
||||
},
|
||||
});
|
||||
36
open_workshop_pos/static/src/js/ows_order_patch.js
Normal file
36
open_workshop_pos/static/src/js/ows_order_patch.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/** @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;
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/** @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;
|
||||
},
|
||||
});
|
||||
|
|
@ -35,7 +35,17 @@ export class OwsPosCustomerSidebar extends Component {
|
|||
}
|
||||
|
||||
getDate(order) {
|
||||
const date = new Date(order.date_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 dd = String(date.getDate()).padStart(2, '0');
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
|
|
|
|||
36
open_workshop_pos/static/src/js/ows_product_card_patch.js
Normal file
36
open_workshop_pos/static/src/js/ows_product_card_patch.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/** @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;
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
// 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
// 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";
|
||||
|
||||
class OwsProductScreen extends ProductScreen {
|
||||
static components = Object.assign({}, ProductScreen.components, {
|
||||
// Patch the ProductScreen components to add the OwsPosSidebar
|
||||
patch(ProductScreen, {
|
||||
components: {
|
||||
...ProductScreen.components,
|
||||
OwsPosSidebar,
|
||||
});
|
||||
}
|
||||
|
||||
registry.category("pos_screens").remove("ProductScreen");
|
||||
registry.category("pos_screens").add("ProductScreen", OwsProductScreen);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/** @odoo-module */
|
||||
|
||||
// This file is now deprecated - order patching moved to ows_order_patch.js
|
||||
// Keeping for backwards compatibility
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?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>
|
||||
|
|
@ -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.name">
|
||||
<t t-foreach="area.machines" t-as="machine" t-key="machine.id">
|
||||
<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 ? '✅' : '❌'" />
|
||||
|
|
|
|||
21
open_workshop_pos/static/src/xml/ows_partner_list_patch.xml
Normal file
21
open_workshop_pos/static/src/xml/ows_partner_list_patch.xml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?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>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?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>
|
||||
25
open_workshop_pos/static/src/xml/ows_product_card_patch.xml
Normal file
25
open_workshop_pos/static/src/xml/ows_product_card_patch.xml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?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>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?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
Loading…
Reference in New Issue
Block a user