Compare commits

...

86 Commits

Author SHA1 Message Date
90e52b269a open_workshop_base: Ausgemusterte Maschinen aus POS-Zugriffsliste ausblenden
Modul: open_workshop_base
Datei: open_workshop_base/models/ows_models.py

In der Methode get_access_list_grouped() wird die Suchdomain um den
Filter ('equipment_id.status_id.name', '!=', 'Ausgemustert') erweitert.
Maschinen, deren verknüpftes maintenance.equipment den Status
'Ausgemustert' trägt, erscheinen nicht mehr in der POS-Maschinenansicht.
2026-04-03 16:14:27 +02:00
19bca86b3d fix: use unique machine id instead of name for t-foreach key
Replace machine.name with machine.id as t-key in t-foreach to prevent
'duplicate key' errors when multiple machines have the same name.
Added machine.id to backend response for unique identification.
2026-03-09 21:19:02 +01:00
61213b2c0c fix: filter machine access HTML view to show only red category machines
Only display machines with category='red' (training required) in the
partner machine access HTML view to avoid cluttering the view with
non-training-relevant machines.
2026-03-09 21:18:34 +01:00
f2b5710178 Add POS partner search modes 2026-02-14 13:31:29 +01:00
f834d70859 open_workshop_mqtt entfernt 2026-02-11 21:04:10 +01:00
9b9b8d025e IOT Bridge noch nicht so richtig funktionsfähig. Odoo MQTT muss noch aufgeräumt werden 2026-02-07 19:45:48 +01:00
d0b7a5737d IOT Bridge noch nicht so richtig funktionsfähig. Odoo MQTT muss noch aufgeräumt werden 2026-02-07 19:44:47 +01:00
8a414ed3ac Improve product assignment UX with many2many_tags widget
- Add computed Many2many fields for direct product display in equipment view
- Implement name_get() methods for OwsMachineProduct and OwsMachineTraining
- Apply category filters to product selection (Maschinennutzung, Einweisungen, Kurse)
- Update README.md with comprehensive module documentation
2026-02-07 19:12:15 +01:00
5a27dc6a65 refactor: Remove old service-based MQTT integration
Removed deprecated in-process MQTT service in favor of standalone IoT Bridge container:

Deleted:
- services/ directory (iot_bridge_service, mqtt_client, session_detector, parsers)
- Old tests (test_mqtt_connection, test_mqtt_mocked, test_session_detector, test_topic_matching, test_no_duplicate_messages)

Modified:
- mqtt_connection.py: action_start/stop now show deprecation message
- mqtt_connection.py: Auto-start on Odoo restart disabled
- mqtt_device.py: Auto-subscribe on device changes disabled
- tests/__init__.py: Removed old test imports
- tests/common.py: Replaced with stub (legacy support only)

Reason:
- Old integration ran MQTT client in Odoo process (tight coupling)
- New architecture: Standalone IoT Bridge container + REST API
- Better separation of concerns, scalability, and maintainability
- All functionality moved to iot_bridge/ (Phase 1) + REST API (Phase 2)

All old code is preserved in git history if needed.
Models (mqtt.device, mqtt.session, mqtt.connection, ows.iot.event) remain unchanged.
2026-02-05 16:49:18 +01:00
c55b0e59d2 feat(odoo): IoT Bridge Phase 2 - Odoo REST API
Phase 2.1 - Models:
- ows.iot.event Model für Event-Logging
  - event_uid (unique constraint) für Duplikat-Prävention
  - event_type (session_started/updated/stopped/timeout/heartbeat)
  - payload_json mit auto-extraction (power_w, state)
  - Auto-Linking zu mqtt.device und mqtt.session
  - Processing-Status tracking
- mqtt.device erweitert mit device_id (External ID für API)
- Existing mqtt.session bereits perfekt strukturiert

Phase 2.2 - REST API Controller:
- GET /ows/iot/config
  - Returns aktive Devices mit session_config als JSON
  - Public auth (später API-Key optional)
- POST /ows/iot/event
  - JSON-RPC Format (Odoo type='json')
  - Schema-Validation (required: event_uid, event_type, device_id, timestamp)
  - Duplikat-Check: 409 Conflict für idempotency
  - Auto-Processing: Session create/update/complete
  - Response codes: 201/409/400/500

Phase 2.3 - Bridge OdooClient:
- Echte REST API Implementation
  - get_config(): GET /ows/iot/config
  - send_event(): POST /ows/iot/event (JSON-RPC)
  - Duplicate handling (409 = success)
  - HTTP Session mit requests library
- config.py: OdooConfig mit base_url, database, username, api_key
- main.py: Conditional OdooClient/MockOdooClient based on use_mock

Testing Guide:
- PHASE2_TESTING.md mit kompletter Anleitung
- Manuelle API Tests (curl examples)
- Integration Test Steps (Odoo + Mosquitto + Bridge)
- Success Criteria Checklist

Bereit für Phase 2.4 Integration Testing!
2026-02-05 16:43:26 +01:00
989720ed21 feat(docker): IoT Bridge Phase 1.5 - Docker Container Build
- Multi-stage Dockerfile für minimales Production Image (71MB)
  - Builder Stage: gcc + pip install mit --user
  - Runtime Stage: python:3.11-slim, non-root user (bridge:1000)
  - Fix: Python packages nach /usr/local/lib/python3.11/site-packages
- .dockerignore für optimierte Build-Context
- requirements.txt erweitert mit PyYAML>=6.0
- config.yaml.dev für lokale Development-Tests
  - Mosquitto container integration
  - Vorbereitet für docker-compose.dev.yaml

Container tested:
  - Erfolgreicher Build (Image: 8b690d20b5f7)
  - Runtime test mit produktivem MQTT Broker
  - Session Detection funktioniert
  - Event Queue verarbeitet Messages

Phase 1 (MQTT Bridge) ist damit vollständig containerisiert.
2026-02-05 16:33:07 +01:00
6676433d46 feat(mqtt): IoT Bridge Phase 1.4 - Event Queue mit Retry Logic
Phase 1.4 abgeschlossen: Event Queue mit exponential backoff retry logic

Neue Features:
- event_queue.py: Thread-safe Queue mit Retry-Mechanismus
  * Exponential backoff: 2^retry_count, max 60s
  * Max 10 retries pro Event
  * Background-Thread für kontinuierliche Verarbeitung
  * Queue-Statistiken (pending, sent, failed, retry counts)

- Event-UID Generation (UUID) in allen Events
  * session_started, session_heartbeat, session_ended, session_timeout
  * Ermöglicht Idempotenz in Odoo

- MockOdooClient Failure-Simulation
  * mock_failure_rate (0.0-1.0) in config.yaml
  * Wirft Exceptions für Retry-Testing

- Config-Erweiterungen
  * LoggingConfig.log_file (Optional[str])
  * OdooConfig.mock_failure_rate (float, default 0.0)

Änderungen:
- main.py: Queue-Integration mit Background-Thread
  * on_event_generated() nutzt Queue statt direktem send
  * Graceful shutdown: Queue-Processing vor MQTT-Disconnect
  * Alte IotBridge-Klasse entfernt (duplicate code cleanup)

- session_detector.py: event_uid zu allen Events hinzugefügt
- odoo_client.py: MockOdooClient mit failure_rate Parameter

Tests (alle PASSED):
- Unit Tests: test_event_queue.py (13/13 passed)
  * QueuedEvent retry logic & exponential backoff
  * Queue operations (enqueue, statistics)
  * Successful send, retry scenarios, max retries exceeded

- Integration Tests: test_retry_logic.py (2/2 passed in 48.29s)
  * test_retry_on_odoo_failure: Events werden bei Failures enqueued
  * test_eventual_success_after_retries: 50% failure → eventual success

Bridge ist jetzt resilient gegen Odoo-Ausfälle!
2026-02-04 17:57:12 +01:00
c1df940daf feat(mqtt): IoT Bridge Phase 1.3 - Session Detection mit Integration Tests
- IoT Bridge Grundstruktur (config, logging, MQTT client)
- Session Detector mit 5-State-Machine und Aggregation
- Heartbeat Events alle 10s (configurable)
- Aggregation: interval_working_s, interval_standby_s, avg_power_w
- Unit Tests: 9/9 passed (session_detector)
- Integration Tests: 7/7 passed (mit Shelly Simulator)
- Lokaler Mosquitto Container Support (docker-compose.dev.yaml)
- --config CLI argument für main.py
- Auto-Reconnect und TLS Support

Test Coverage:
- Session Start/End Detection
- State Transitions (IDLE→STANDBY→WORKING)
- Heartbeat Emission mit Aggregation
- Multi-Device Parallel Sessions
- Timeout Detection (20s)

Phase 1.3  COMPLETE
2026-02-04 16:46:13 +01:00
92f9548d34 WIP: Duplikate-Fix und ausführliche Service-Dokumentation
- Entfernt doppelte Topic-Subscription (war Ursache für Duplikate)
- Subscription passiert nur noch in _on_connect() Callback
- Ausführliche Dokumentation in iot_bridge_service.py hinzugefügt
- Test für Duplikate erstellt (test_no_duplicate_messages.py)
- Recovery-Logik für Container-Restart dokumentiert

HINWEIS: Service-Design muss überarbeitet werden!
- Aktuell: Lazy Init, manueller Start
- Sollte sein: Auto-Init beim Odoo-Start, always-on MQTT
- Nächster Schritt: Odoo 18 Service Pattern + MQTT Bridge Pattern recherchieren
2026-01-31 11:19:44 +01:00
b46fed0f8e feat: SessionDetector Integration - Live-Tracking & Timeout Worker
Integriert SessionDetector vollständig in iot_bridge_service:

iot_bridge_service.py:
- SessionDetector Import & _detectors Dictionary
- Timeout Worker Thread für automatisches Session-Ende
- _restore_detector_states() bei Service-Start
- _get_or_create_detector() Factory Methode
- _process_session() ruft detector.process_power_event(env, power, ts)
- _timeout_worker_loop() prüft alle 10s auf Timeouts

models/mqtt_session.py:
- current_power_w Field für Live-Power Updates
- current_state Field für State Machine Status (idle/starting/standby/working/stopping)
- last_message_time Field für Timeout Detection

tests/__init__.py:
- Import test_session_detector Module

Diese Änderungen sind essentiell für SessionDetector!
Ohne sie würde der Detector nicht aufgerufen werden.
2026-01-30 16:49:50 +01:00
90e3422e8b fix: Repariere alle Unit Tests - SessionDetector jetzt produktionsreif
-  Alle 26 Tests grün (0 failed, 0 errors)
- Phase 2 ist damit 100% fertig!

Tests repariert:
- test_session_detector.py: Alle 7 Tests an env-passing angepasst
  - SessionDetector(device.id, device.name) statt SessionDetector(device)
  - process_power_event(env, power, ts) statt process_power_event(power, ts)
- test_mqtt_mocked.py: Alle 4 Service-Tests korrigiert
  - start_connection_with_env(connection_id, env) Signatur
  - stop_connection(connection_id) Parameter hinzugefügt
  - IotBridgeService direkt instanziiert
  - device.topic_pattern statt nicht-existierendem device_id

Verbesserungen:
- run-tests.sh: Klare Ausgabe mit ✓✓✓ ALL TESTS PASSED ✓✓✓
- run-tests.sh: Parsed Odoo Test-Output korrekt
- run-tests.sh: Exit Code (0=success, 1=failure)

Test-Coverage:
- SessionDetector State Machine vollständig getestet
- Alle 5 States: IDLE/STARTING/STANDBY/WORKING/STOPPING
- Debounce Logic: Start + Stop Timer
- Duration Tracking: Standby/Working Zeiten
- Timeout Detection: 20s Message Timeout
- State Recovery: Nach Restart

SessionDetector ist produktionsreif!
2026-01-30 16:46:35 +01:00
f1b0c50fbf fix: MQTT topic matching + UI button fixes
- Fix MQTT topic pattern matching (_mqtt_topic_matches):
  * Implement proper # wildcard (multi-level)
  * Implement proper + wildcard (single-level)
  * Fix bug where first device got ALL messages
  * Now shaperorigin/# only matches shaperorigin/* topics

- Fix Stop Connection button (Odoo-style):
  * Remove manual commit() - let Odoo handle it
  * Use write() to update state
  * Handle case where service doesn't have connection

- Fix Test Connection hanging:
  * Add proper cleanup with sleep after disconnect
  * Catch cleanup exceptions

- Add @unittest.skip to real MQTT tests:
  * TransactionCase incompatible with paho-mqtt threads
  * See TODO.md M8 for details

- Fix run-tests.sh:
  * Remove -i flag (was hanging)
  * Simplify to direct output redirect

- Add TODO.md documentation
- Update .gitignore for test logs
2026-01-28 22:08:59 +01:00
7f827cf7eb fix: POS-Funktionalität in open_workshop_pos verschoben + Workarounds
- pos_order.py von open_workshop_base nach open_workshop_pos verschoben
  POS-spezifische Logik gehört ins POS-Modul, nicht ins Base-Modul

- Workaround für Odoo 18 pos_sms Bug hinzugefügt (hooks.py)
  Problem: pos_sms hatte auto_install=True in älteren Odoo 18 Versionen
  Lösung: Post-init Hook löscht verwaiste pos_sms Views automatisch

- l10n_de als Dependency hinzugefügt
  Benötigt für POS Demo-Daten (Bankjournal muss existieren)

open_workshop_base kann jetzt ohne POS installiert werden
open_workshop_pos enthält alle POS-spezifische Funktionalität
2026-01-28 18:55:55 +01:00
dfb77411d9 fix: open_workshop_base Installation-Fehler behoben
- POS-Dependency entfernt: pos_order.py Import deaktiviert (Modul benötigt kein POS mehr)
  HINWEIS: pos_order.py sollte später eventuell nach open_workshop_pos verschoben werden
- maintenance_equipment_status zu Dependencies hinzugefügt
- Menu-Lade-Reihenfolge korrigiert: menu_views.xml wird nun nach den View-Dateien geladen
- Action-Referenz korrigiert: open_workshop_base.action_machine_area_list -> action_machine_area_list
- Altes open_workshop Modul entfernt (war bereits auf installable=False)
- open_workshop_auto_backup Modul entfernt

Das Modul kann nun ohne point_of_sale installiert werden.
2026-01-27 22:29:14 +01:00
59539e0201 WIP: MQTT Tests - Mocked approach created but needs better testing strategy
- Created test_mqtt_mocked.py with unittest.mock (following OCA patterns)
- Old tests with real MQTT broker hang in TransactionCase tearDown
- Created run-tests.sh following OCA/oca-ci best practices
- TODO: Find proper way to test MQTT with background threads in Odoo
- TODO: Either fully mock or use different test approach (not TransactionCase)
2026-01-25 10:15:52 +01:00
b6a0f0462d feat: MQTT integration - auto-start, session detection, UI fixes
- Auto-start connections after Odoo restart via _register_hook()
- Start/Stop buttons with invalidate_recordset() and reload action
- Session detection with ShellyParser (RPC, telemetry, status)
- Fixed imports: datetime, ShellyParser
- Parser matches prototype with device_id, timestamp extraction
- Sessions created/ended based on power > 0 threshold
2026-01-24 23:40:03 +01:00
5fcaef0336 feat(M5): Implement MQTT reconnect, state recovery, and error handling
 M5 Complete - All 21 tests passing

**MQTT Auto-Reconnect:**
- Exponential backoff (1s → 60s) in mqtt_client.py
- Automatic reconnection on disconnect
- Reset delay on successful connect

**State Recovery:**
- load_sessions() and get_running_sessions() in event_storage.py
- restore_state() in session_detector.py
- _restore_running_sessions() in main.py before MQTT connect
- Running sessions continue with timeout detection after restart

**Error Handling:**
- All parsers wrapped in try-except (shelly_parser, session_detector)
- Errors logged with exc_info=True for debugging
- Bridge continues running on malformed messages

**Bug Fixes:**
- Fixed field name mismatch: duration_s → total_duration_s
- Fixed None handling in session end methods
- Fixed infinite recursion in _read_sessions()

Test Results: 21 passed in 175.46s (0:02:55)
2026-01-24 19:21:05 +01:00
bb3e492d30 feat: Add multi-device support (M4)
- Support multiple machines in parallel
- Each device uses unique topic_prefix
- Add 3 integration tests for multi-device scenarios
- Update config.yaml.example with 2-machine setup
- All 8 integration tests passing
- Test scenarios: parallel sessions, independent timeouts, different thresholds
2026-01-24 13:01:41 +01:00
aeb8e5660b feat: Add timeout detection and comprehensive test suite
- Implement check_timeouts() for session timeout detection (20s)
- Fix code duplication bug in session_detector.py
- Add periodic timeout check in main loop (every 1s)
- Add 13 unit tests for session detection
- Add 5 integration tests with real MQTT broker
- Document Odoo integration strategy in README
- Remove hardcoded credentials, use env vars
- All 18 tests passing
2026-01-24 11:32:23 +01:00
4c03959437 feat(mqtt): Implement M0-M3 - MQTT IoT Bridge with Session Detection
- M0: MQTT Client with TLS/SSL support
  - Connection to mqtt.majufilo.eu:8883
  - Topic subscription with wildcards
  - JSON payload parsing

- M1: Shelly PM Mini G3 Integration
  - Status message parser (shaperorigin/status/pm1:0)
  - Custom MQTT topic prefix support
  - Power, voltage, current, frequency extraction
  - Device mapping via topic_prefix

- M2: Event Normalization
  - Event Schema v1 with UUID-based event IDs
  - ISO 8601 UTC timestamps
  - Machine/Device info mapping
  - Metrics normalization (power_w, voltage_v, current_a, frequency_hz)
  - Filter None values

- M3: Session Detection Engine
  - State machine: IDLE → STARTING → RUNNING → STOPPING → IDLE
  - Power-based threshold detection (configurable per machine)
  - Debounce logic (separate for start/stop)
  - Session events with duration calculation
  - Persistent storage (JSONL for events, JSON for sessions)
  - Odoo-ready data format for future migration

Data Storage:
- events.jsonl: JSON Lines format (one event per line)
- sessions.json: Session records with start/end/duration
- Ready for migration to open_workshop.session model

Multi-device ready: Add devices via config.yaml with unique topic_prefix
2026-01-22 19:59:17 +01:00
75d91984d1 feat: Wiki-Links in Tree View als klickbare Namen anzeigen
Änderungen:
- maintenance_equipment.py: Zwei neue computed HTML-Felder hinzugefügt:
  - wiki_doku_link: Zeigt wiki_doku_id als Link zur Dokumentations-Seite
  - wiki_status_link: Zeigt wiki_status_id als Link zur Odoo-Status-Seite
- maintenance_equipment_views.xml: Tree View erweitert mit Wiki-Links
  - Wiki Doku: Standardmäßig sichtbar (optional='show')
  - Wiki Status: Standardmäßig ausgeblendet (optional='hide')
- maintenance_equipment_views.xml: Formular-Layout verbessert
  - Doku-Seite mit prominentem Link oben

Nutzen:
- Benutzer sehen jetzt 'sabko-laser' statt vollständige URL
- Links sind klickbar und öffnen in neuem Tab
- Übersichtlichere List View mit relevanten Wiki-Informationen
2026-01-19 20:57:43 +01:00
77cd3f4595 fix: Deprecated force_sync Feature entfernt und wiki_doku_id bei Reset erhalten
Änderungen:
- maintenance_equipment.py: wiki_doku_id wird bei action_reset_wiki_sync_status() nicht mehr zurückgesetzt (ist manuell editierbar)
- equipment_wiki_sync_wizard.py: Deprecated force_sync Feld und zugehörige Logik entfernt
- equipment_wiki_sync_wizard_views.xml: force_sync UI-Element entfernt
- FEATURE_REQUEST/Odoo18_MQTT_IoT.md: Projektplan für MQTT-basierte IoT-Events hinzugefügt

Grund:
- force_sync Feature referenzierte nicht existierende Methoden (_generate_wiki_doku_page_content, _get_wiki_doku_page_id)
- wiki_doku_id ist jetzt manuell editierbar und sollte nicht automatisch zurückgesetzt werden
- Cleanup von veralteter/fehlerhafter Funktionalität
2026-01-19 20:27:17 +01:00
4684d33241 fix(dokuwiki): Migration-Script optimiert - entfernt Docstring
Die Kommentare waren überflüssig. Das vereinfachte Script funktioniert.
Lokal getestet mit Produktions-Datenbank: Upgrade erfolgreich.
2026-01-18 14:38:31 +01:00
85a90f90fd fix(dokuwiki): Verbessertes Pre-Migration-Script mit robuster Fehlerbehandlung
- Explizite Commits nach jeder ALTER TABLE Operation
- Besseres Logging mit Statusmeldungen
- Verifizierung nach Migration
- Fehlerbehandlung mit detaillierten Fehlermeldungen
- Prüfung ob maintenance_equipment Tabelle existiert
2026-01-18 14:23:46 +01:00
0d27ca36b0 feat(dokuwiki): Trennung von wiki_status_id und wiki_doku_id, verbesserte UI
- Neues Feld wiki_status_id: Eindeutige ID für Odoo-Status-Seite (name-seriennummer)
- Überarbeitung wiki_doku_id: Editierbar, für Benutzer-Dokumentation (Duplikate erlaubt)
- Funktion _get_wiki_doku_page_id → _get_wiki_status_page_id (bessere Benennung)
- Template-Platzhalter: wiki_doku_page/link → wiki_status_page/link
- Pre-Migration-Script für Upgrade ohne Fehler (18.0.2.1.0)

View-Verbesserungen:
- 3-spaltige Darstellung: Wiki Status Seite | Wiki Dokumentations Seite
- Separate Gruppe für Wiki URL
- Erweiterte Hilfe mit Unterscheidung Status-/Doku-Seite
- Include-Plugin-Beispiel für DokuWiki-Integration

Version: 18.0.2.0.0 → 18.0.2.1.0
2026-01-18 13:47:38 +01:00
b85ec97d9c DokuWiki: wiki_doku_id nutzt jetzt name + comp_serial_no für garantierte Eindeutigkeit
- _get_wiki_doku_id() generiert Format: {normalized_name}-{comp_serial_no}
- Automatische Generierung von comp_serial_no falls nicht vorhanden
- Nutzt aspl_equipment_qrcode_generator Modul für eindeutige Seriennummern
- Entfernt Fallback zu name+model (kein Fallback mehr)
- Vorbereitung für editierbare wiki_doku_id in nächstem Schritt
2026-01-18 12:53:06 +01:00
f9cbcffe34 open_workshop_dokuwiki: tags_list now with space separated output 2026-01-17 20:12:07 +01:00
fe09d1439f Update custom widget 2026-01-13 17:53:31 +01:00
23b9867eec Increase backup cron time limit to 1 hour
- Default 120s timeout was too short for large SFTP uploads
- Set time_limit to 3600s (1 hour) to prevent incomplete backups
- Fixes timeout during SFTP upload causing server reload
2026-01-13 17:51:10 +01:00
00df958985 Umstellung: Employee Image Generator speichert nur Foto, Customer Display baut Badge dynamisch
Employee Image Generator:
- Speichert nur zugeschnittenes 369×492px Foto (nicht mehr A4-Badge)
- Speichert job_focus zusätzlich zu image_1920
- html2canvas Dependency entfernt
- Vorschau bleibt zur Visualisierung

Customer Display:
- Baut Badge dynamisch aus Employee-Daten (Foto, Name, job_focus)
- Responsive Design basierend auf thekenheld print.html
- Neues employee_badge.css für Screen-Optimierung
- Footer bleibt immer am unteren Bildschirmrand
- job_focus mit max-height und overflow für lange Texte
- Lädt job_focus über hr.employee ID (nicht user_id)

Vorteile: Flexibler, aktuelle Daten, kleinere Dateien, wiederverwendbares Foto
2026-01-12 19:10:43 +01:00
98c96018d6 Fix SFTP makedirs error with trailing slashes
- Normalize remote paths by removing trailing slashes
- Improve error handling for directory creation
- Fix OSError handling (errno not reliably set in SFTP)
- Prevents duplicate directory creation attempts
2026-01-11 20:43:25 +01:00
b2f7a5edbf added auto_backup tool 2026-01-11 20:21:24 +01:00
d6c2986ebf Verbessere Image Generator: Fixierter Crop-Rahmen, optimierte Vorschau-Skalierung
- Crop-Rahmen ist fixiert (nicht verschiebbar/skalierbar), nur Bild bewegbar
- Zuschnitt auf exakt 369×492 Pixel
- Finales Badge im A4-Format (794×1123px / 21×29.7cm)
- Vorschau auf 35% skaliert mit korrektem Platzverbrauch
- Große Bilder passen initial in Container, unbegrenztes Zoomen möglich
- Badge-Template behält alle mm/cm-Maße aus print.html
2026-01-11 18:22:10 +01:00
67184e7e01 fixed template 2026-01-10 19:18:55 +01:00
ee0e6c8b98 added ignored lib 2026-01-10 19:14:25 +01:00
1c9829a7aa feat: open_workshop_employee_imagegenerator - Thekenheld Badge-Generator als Odoo Modul
- OWL Component mit 3-step Wizard (Upload → Crop → Text/Preview)
- Cropper.js für Bildausschnitt (369×492px)
- html2canvas für PNG-Generierung (794×1123px A4)
- Badge-Template mit originalen CSS von Thekenheld
- Speicherung in hr.employee.image_1920
2026-01-10 16:42:39 +01:00
1abeb97afa Cleanup: Remove debug logs, unused backend files, add comprehensive comments
- Removed all console.log debug statements from JavaScript files
- Deleted unnecessary backend models (hr_employee.py, res_users.py, pos_session.py, pos_config.py)
- Added comprehensive German comments explaining the BroadcastChannel architecture
- Updated README with detailed technical documentation about frontend-only approach
- Removed debug info box from customer display template
- Simplified __init__.py files (pure frontend solution)

The module now uses a clean, maintainable frontend-only approach that:
- Patches Chrome component with effect(batched()) for reactive updates
- Sends cashier data via BroadcastChannel (same pattern as order updates)
- Uses hr.employee.public URL format (identical to POS navbar)
- Falls back to company logo when no employee image available
2026-01-10 15:10:51 +01:00
daddb51327 Add POS customer display customization module
- Show current cashier's employee image in customer display sidebar
- Image updates automatically when cashier changes
- Fallback to company logo when no image available or before first order
- Uses BroadcastChannel to sync between POS and customer display
- Full-size image display with correct aspect ratio (object-fit: contain)
2026-01-10 14:49:50 +01:00
5d1faa7b5b POS: Preislistenabhängige Preisanzeige auf Produktkarten
- Fügt reaktive Preisanzeige auf POS-Produktkarten hinzu
- Preis wird basierend auf der Preisliste des aktuellen Kunden berechnet
- Automatische Aktualisierung bei Kundenwechsel/Preislistenänderung
- Unterstützt Maßeinheiten für gewichtsbasierte Produkte
2026-01-09 22:11:01 +01:00
21b0d6305f POS: Rechnungs-Button im Payment Screen entfernt 2026-01-09 21:05:20 +01:00
988421a0c5 POS: Replace internal note button with pricelist button
- Remove internal note button from control buttons
- Add pricelist button directly visible in main view (not only in Actions dialog)
- Shows current pricelist name of the order/partner
- Allows direct access to change pricelist without opening Actions menu
2026-01-09 15:24:26 +01:00
b26e70dbe9 fix: Support multiple file types (PNG, PDF, JPEG) for DokuWiki media uploads
- Make upload_media accept ir.attachment records, IDs, or raw bytes
- Detect file extension from binary content using imghdr
- Build media_id with correct extension (.png, .jpg, .pdf)
- Prevent DokuWiki 'file extension mismatch' error
- Improve temp file cleanup with proper error handling
2026-01-08 17:13:34 +01:00
38d0becfad Fix: Korrigiere Zeitanzeige in POS Sidebar (UTC zu lokaler Zeit) 2026-01-07 19:01:41 +01:00
943d48af58 DokuWiki: Media-Namespace an DokuWiki Best Practice angepasst
- Geändert von :media: Sub-Namespace zu paralleler Struktur :{area}:
- Media-IDs folgen jetzt dem gleichen Muster wie Pages
- Alt: werkstatt:ausstattung:media:{id}.jpg
- Neu: werkstatt:ausstattung:{bereich}:{id}.jpg
- Entspricht DokuWiki-Konvention für parallele Page/Media-Strukturen
- Dokumentation im Docstring aktualisiert
2026-01-07 13:41:50 +01:00
09f28c8070 DokuWiki: Fix partner_ref sync und Template-Cache
- partner_ref zu sync_fields hinzugefügt für automatische Synchronisation
- action_clear_template_cache() Methode hinzugefügt zum manuellen Leeren des Caches
- Template-Cache-Leeren Button im Wiki-Sync Wizard
- Debug-Logging für partner_ref Wert

Fixes: partner_ref wurde nicht automatisch synchronisiert und Template-Änderungen erforderten Server-Neustart
2026-01-07 12:37:34 +01:00
40a5fe8b0f wiki_doku_id wird bei einem Sync Statut reset auch zurück gesetzt 2026-01-07 11:02:22 +01:00
31a40e95d7 Add voucher codes display in POS receipt
- Create ows_order_patch.js to collect voucher codes from orderlines
- Add voucher codes section at end of receipt with large readable format
- Include voucher code, product name, and value for each voucher
- Move partner name logic from receipt_header_patch to order_patch
- Add German labels (GUTSCHEIN-CODES, Wert:)
2026-01-06 14:48:09 +01:00
91209dead4 manifest 2026-01-06 14:12:51 +01:00
9cb6dc8ac5 Add partner name display in POS receipt header
- Patch PosOrder.export_for_printing() to include partner data in headerData
- Update receipt header template to display partner name above tracking number
- Partner name shown only when partner is selected for the order
2026-01-06 14:03:55 +01:00
93385abb0f [ADD] open_workshop_pos: Standard-Kategorie beim POS-Start
- Automatische Auswahl einer konfigurierbaren Standard-Kategorie
- Konfiguration über Kategorie-Name oder ID möglich
- Aktuell auf 'Nutzung' gesetzt
- Vereinfacht die Navigation für häufig genutzte Kategorien
2026-01-06 11:43:56 +01:00
e443f8709d added darker background color for pos 2026-01-06 11:29:16 +01:00
b151894c39 [IMP] open_workshop_pos: Fix Produktsuche und verbessere UI
- Fix: ProductScreen Patch korrigiert - Suchfeld erscheint wieder
  * Verwendung von patch() statt Klassen-Vererbung für korrekte Komponenten-Registrierung
  * Behebt OwlError 'Cannot find OwsPosSidebar'

- Add: Preisanzeige auf Produktkarten
  * Listenpreis wird unter Produktname angezeigt
  * Konsistente Ausrichtung am unteren Rand
  * Produkte mit/ohne Bild haben gleiche Preis-Position

- UX: Warenkorb-Mengenanzeige auf Produktkarten entfernt
  * Ruhigeres, übersichtlicheres Erscheinungsbild
  * Mengen nur noch in Bestellübersicht sichtbar
2026-01-06 11:25:24 +01:00
0c4b58e0a6 Update Open Workshop Dokuwiki Version 2025-12-27 20:37:02 +01:00
e460acddf3 DokuWiki: Reset-Funktion für Wiki-Sync-Status
Neue Funktion im Modul:
- action_reset_wiki_sync_status() in maintenance_equipment.py
- Setzt wiki_synced und wiki_last_sync zurück
- Aufrufbar über Wizard: Wartung → Konfiguration → Wiki-Synchronisation
- Neuer Modus: 'Sync-Status zurücksetzen'

Verwendung:
1. Nach Namespace-Änderungen (ausruestung → ausstattung)
2. Wenn alle Equipment neu synchronisiert werden sollen
3. Bei Wiki-Struktur-Änderungen

Setzt NUR den Status zurück, nicht die wiki_doku_id
(wiki_doku_id kann bei Bedarf manuell zurückgesetzt werden)
2025-12-27 20:25:17 +01:00
a1c6903893 Update Dokuwiki Template in maintenance equipment 2025-12-27 19:54:43 +01:00
72a46e3657 DokuWiki: Übersichtstabelle in 'uebersicht' statt 'start'
- Systemparameter dokuwiki.overview_page_id: werkstatt:ausstattung:uebersicht
- README.md aktualisiert mit neuem Pfad
- 'start' Seite kann nun für andere Zwecke genutzt werden (z.B. Willkommensseite)
2025-12-27 17:01:55 +01:00
641bfb3ade DokuWiki Integration: Neue Namespace-Struktur mit odoo-status
BREAKING CHANGES:
- Namespace-Struktur komplett überarbeitet
- ausruestung → ausstattung (konfigurierbar über dokuwiki.equipment_namespace)
- Neue Struktur: ausstattung:odoo-status/ für Odoo-generierte Status-Seiten
- Odoo erstellt KEINE Benutzer-Seiten mehr in {bereich}/ Namespaces

Neue Systemparameter:
- dokuwiki.equipment_namespace = werkstatt:ausstattung (Basis-Namespace)
- dokuwiki.central_documentation_namespace = werkstatt:ausstattung:odoo-status

Workflow-Änderung:
1. Odoo synchronisiert → erstellt nur start und odoo-status/ Seiten
2. Übersichtstabelle verlinkt auf {bereich}/{equipment_id} → Links sind rot
3. Benutzer klickt roten Link → DokuWiki bietet Seitenerstellung an
4. Benutzer erstellt Seite mit {{page>odoo-status:equipment_id}}

Vorteile:
- Klare Trennung: odoo-status/ (nur Odoo) vs. {bereich}/ (nur Benutzer)
- Keine Überschreibgefahr - Odoo und Benutzer haben getrennte Namespaces
- Benutzer entscheiden selbst ob/wann sie Dokumentation erstellen
- ACL-Schutz: odoo-status/ nur von odoo.odoo schreibbar
2025-12-27 16:56:17 +01:00
a73c0cb299 Integration Plan für automatisierte DokuWiki-Report-Generierung
Roadmap für vollautomatische monatliche PNG-Export-Generierung:
- Graph-Renderer mit Matplotlib/Plotly (alle Metabase-Features möglich)
- Scheduled Action (Cron) für monatliche Ausführung
- DokuWiki XML-RPC Upload Integration
- 5 Diagramm-Typen: Tagesansicht, Jahresvergleich, Trends, Wiederholungstäter
- Aufwand: 5-7 Stunden Implementierung

Vorteil: Vollautomatisch, professionelle Visualisierung mit Beschriftungen,
kein manueller Metabase-Export mehr nötig
2025-12-26 23:47:24 +01:00
4626379d6e Korrektur Ø Nutzer/Tag Berechnung und finale Verbesserungen
- Ø Nutzer/Tag Formel korrigiert: Buchungen/Tage statt Nutzer/Tage
- Jahr/Monat Felder zu DailyStats hinzugefügt für flexible Gruppierung
- Alle 2034 Datensätze neu generiert mit korrekten Werten
- show_measures zu allen Graph-Views hinzugefügt (Odoo-Limitation: funktioniert nicht)

LIMITIERUNG: Weitere Verbesserungen gegenüber Metabase nicht möglich
- Odoo Graph-Views können keine Werte auf Balken anzeigen
- 100% gestapelte Diagramme nicht möglich
- Komplexe Visualisierungen wie Häufigkeitsverteilungen nicht realisierbar
- Für Präsentationen und Export: Metabase bleibt überlegen

Empfehlung: Odoo für operative Arbeit, Metabase für Präsentationen nutzen
2025-12-26 23:40:42 +01:00
9be8f320f7 Tägliche Statistiken mit Drill-Down und Jahresvergleich
- Tägliche Statistiken (DailyStats) implementiert mit 2034 Datensätzen
- Wochenende vs. Wochentag farblich unterschieden in Tagesansicht
- Jahr/Monat Felder für Jahresvergleich hinzugefügt
- Button 'Tagesansicht' in Kanban-Dashboard für Drill-Down zu täglichen Statistiken
- Alle Dashboard-Graphen auf Jahresvergleich umgestellt (Monat × Jahr, nicht gestapelt)
- Jahresvergleich, Ø Nutzer/Tag, Wiederholungstäter, Nutzer pro Monat - alle mit Jahresvergleich
2025-12-26 21:35:22 +01:00
d6a98cfbd0 initiale Version von Open Workshop Report 2025-12-26 19:14:17 +01:00
0135035a54 DokuWiki Integration: Performance-Optimierung und Best Practices
- Connection Caching: Wiederverwendung von DokuWiki-Verbindungen (von 12min auf 20s für 50 Equipment)
- Template Caching: Template nur einmal laden statt pro Equipment
- Dokumentation: Best Practices für erweiterbare DokuWiki-Seitenstruktur (page.txt + page/ ohne start.txt)
- Catlist-Syntax dokumentiert für automatische Unterseiten-Auflistung
2025-12-24 14:13:59 +01:00
f79e126c8c DokuWiki Integration: wiki_doku_id aus Name+Modell, zentraler Namespace konfigurierbar
- wiki_doku_id wird nun aus equipment.name und equipment.model kombiniert generiert
- Systemparameter 'dokuwiki.central_documentation_namespace' für konfigurierbaren Namespace
- Verbessertes Error-Handling in dokuwiki_client mit detaillierteren Fehlermeldungen
- Docker-Netzwerk Unterstützung dokumentiert
- Gelöschte veraltete WordPress-API Dateien
2025-12-23 14:48:44 +01:00
253d289633 feat(dokuwiki): Flexible overview table with configurable columns
- Made overview table columns fully configurable via system parameters
- Added wiki_page_id placeholder for internal DokuWiki links
- Removed DokuWiki template dependency for overview table
- Added _register_hook to initialize parameters on module load/update
- Updated README with new configuration approach
- System parameters: overview_page_id, overview_title, overview_columns, overview_column_data
- Default columns: Name (with link), Status, Security Category, Area, Location, Image
2025-12-20 12:49:58 +01:00
de317f46e6 feat(dokuwiki): Add equipment overview table with DataTables
- Add _generate_overview_table_row() to generate table row per equipment
- Add action_sync_overview_table() for fast overview page sync
- Add new sync mode 'Übersichtstabelle aktualisieren' in wizard
- Add config parameters for overview page and template IDs
- Add overview_url field to wizard result view
- Columns: Name (linked), Status, Security Category, Area, Location, Image
- Performance: Single page update without iterating all equipment
- Include DokuWiki template and setup documentation
- Requires DokuWiki DataTables plugin for sortable/filterable table
- Fix: Use status_id instead of equipment_status_id
2025-12-19 20:40:29 +01:00
8ae586cca6 feat: Auto-create ows.machine on equipment import/create
- Add @api.model_create_multi hook to MaintenanceEquipment.create()
- Add load() override to handle Excel/CSV import correctly
- Add _create_missing_ows_machines() helper with recursion prevention
- Modify OwsMachine.create() to only create equipment if equipment_id not provided
- Use context flag 'skip_ows_machine_creation' to prevent infinite recursion
- Fixes: Equipment import now auto-creates ows.machine and displays OWS tab
- Migration tested: Compatible with SQL-based post-migration (23/23 machines migrated)
2025-12-19 20:12:54 +01:00
e18f2880a4 Make post-migration.py more robust when setting default status
- Check if status 'In Betrieb' exists before setting it
- Add warning if status not found instead of SQL error
- Status records are created automatically via XML data file
2025-12-18 20:56:14 +01:00
43cf5754fd Add automated maintenance equipment status creation with smileys
- open_workshop_base: Create 4 status records (In Betrieb, Defekt, Wartung, Ausgemustert) via XML data
- open_workshop_dokuwiki: Add smiley field to status model and populate with DokuWiki smileys/emojis
- Use noupdate=1 to prevent overwriting user customizations
- Status data loads automatically during module installation
2025-12-18 20:53:04 +01:00
4c048b5ca9 fix(ows_models): Update create() to use @api.model_create_multi for Odoo 18
Fixed DeprecationWarning by updating OwsMachine.create() method:
- Changed from @api.model with single vals parameter
- To @api.model_create_multi with vals_list parameter
- Now supports batch creation of multiple records
- Removed duplicate empty OwsMachineArea class definition

This resolves the Odoo 18 deprecation warning about create method not supporting batch operations.
2025-12-15 20:55:36 +01:00
686b201792 docs(migration): Add comments explaining related field synchronization
Added documentation to clarify:
- Why store=True is used for related fields (performance for filtering/searching)
- Why manual synchronization is needed in migration (related fields don't auto-sync during SQL INSERT)

No functional changes, only improved code documentation.
2025-12-15 20:46:14 +01:00
bd235e3ca0 fix(migration): Synchronize related fields (area_id, category) from ows.machine to maintenance.equipment
The related fields in maintenance.equipment with store=True were not automatically
synchronized during migration. Added explicit SQL UPDATE statements to copy values:
- ows_area_id from area_id
- ows_category from category

This ensures the maintenance.equipment records have all the OWS-specific data
from the original ows.machine records after migration.
2025-12-15 20:36:41 +01:00
a5f8fd32c3 feat(dokuwiki): Add partner fields, editable smileys, category icon, and mass sync wizard
- Add partner_id and partner_ref template placeholders for supplier information
- Implement editable status smiley field in maintenance.equipment.status
- Add UI views for editing smileys in Odoo (Wartung → Konfiguration → Equipment Status)
- Fix category field bug: use ows_category instead of category_id for security category
- Add category_icon placeholder displaying emoji indicators (🔴/🟡/🟢)
- Implement mass synchronization wizard (Wartung → Konfiguration → Wiki-Synchronisation)
  - Sync all equipment or filter by area
  - Sync only unsynchronized equipment
  - Force-sync option to overwrite existing documentation pages
  - Statistics display with error reporting
- Add security access rules for wizard
- Update README with new placeholders
2025-12-14 20:55:21 +01:00
3fa050f153 feat(dokuwiki): Add wiki-based template system and image upload
- Implemented wiki-based template system using c_template
  - Template loaded from werkstatt:ausruestung:c_template
  - Placeholder replacement for all equipment and related fields
  - Fallback to hardcoded content if template unavailable
  - Only view pages use templates, central doku page unchanged

- Added image upload capability for equipment
  - New field: image_1920 (Image field, max 1920x1920px)
  - View integration: Positioned as avatar before button_box
  - Upload to DokuWiki media directory during sync
  - Temporary file approach for dokuwiki.py library compatibility

- Extended template placeholders:
  - status: Equipment status from status_id
  - odoo_link: Formatted link back to Odoo equipment form
  - odoo_url: Direct URL to equipment in Odoo
  - image: Equipment image (300px width)
  - image_large: Equipment image (original size)
  - image_id: Media path for custom DokuWiki syntax

- Updated documentation with all available placeholders
2025-12-13 23:02:49 +01:00
e53c4028c9 feat(dokuwiki): Implement single-page DokuWiki integration
- Simplified architecture: ONE central documentation page per equipment
- Wiki ID generation from equipment name (e.g., 'sabako-laser')
- Namespace structure: werkstatt:ausruestung:doku:{name}
- View pages by area: werkstatt:ausruestung:{area}:{name}
- Smart page protection: central doku only created once, not overwritten
- View pages update on each sync (shows current Odoo metadata)
- Template detection: distinguishes real content from DokuWiki auto-templates
- JSONB multilingual name field handling
- Include Plugin integration for embedded content display
- Automatic sync option via write() override
- Configuration via System Parameters (url, user, password)
- Settings page in General Settings for easy credential management
2025-12-13 21:50:19 +01:00
5057985024 docs(feature-plan): Change namespace to werkstatt:ausruestung and add initial ACL phase
- Changed namespace from ausruestung:* to werkstatt:ausruestung:*
  - Main pages: werkstatt:ausruestung:bereich:maschinenname
  - Doku pages: werkstatt:ausruestung:bereich:maschinenname:doku

- Added two-phase ACL approach:
  - Phase 1: Only @odoo has access (initial setup/testing)
    - werkstatt:ausruestung:* @odoo 8
    - werkstatt:ausruestung:* @ALL 0
  - Phase 2: Werkstatt gets read/edit access (later rollout)
    - Main pages: @werkstatt read-only
    - Doku pages: @werkstatt editable

- Updated all code examples with new namespace
- Updated ACL permission table
- Added phased rollout advantage
2025-12-13 19:23:11 +01:00
00f33faebe docs(feature-plan): Refine DokuWiki architecture - inverted include approach
- Changed from marker-based sync to inverted include architecture:
  - Odoo generates read-only main page with equipment data
  - Main page includes user-editable :doku subpage via Include Plugin
  - Clear separation: Odoo owns structure, users own documentation

- Two-page system per equipment:
  - ausruestung:bereich:maschine (Odoo-generated, read-only)
  - ausruestung:bereich:maschine:doku (user-editable)

- Generisches ACL-System (maintenance-free):
  - ausruestung:*:doku @werkstatt 2 (editable)
  - ausruestung:* @werkstatt 1 (read-only)
  - Works automatically for all equipment

- Simplified sync algorithm:
  - Odoo completely overwrites main page (no marker parsing)
  - User content protected on separate page
  - Include renders both seamlessly

- Added detailed Python code examples
- Documented ACL wildcard matching rules
- Added table showing permission resolution

Benefits:
- No risk of users deleting sync markers
- Users cannot accidentally break Odoo data
- Self-healing: Odoo always regenerates correct structure
- Scalable: Works for 1000+ equipment pages
2025-12-13 18:16:33 +01:00
2eff81ce54 docs(feature-plan): Clarify sync mechanism - unidirectional with protected sections
- Changed 'bidirektional' to 'unidirektional' (Odoo → DokuWiki)
- Clarified data ownership:
  - Odoo = Master for structured data (name, serial_no, area, category)
  - DokuWiki = Master for freetext documentation (manuals, tips)

- Added sync marker system:
  - <- Ensures POS functionality is activated after ODOO_SYNC_START: section_name -->
  - <- Ensures POS functionality is activated after ODOO_SYNC_END: section_name -->
  - Only content between markers gets updated by Odoo
  - All other sections (Bedienung, Tipps & Tricks) remain untouched

- Documented sync algorithm with Python code example
- Users can freely edit wiki without fear of data loss
- DokuWiki versioning preserves all changes
2025-12-13 17:53:54 +01:00
92490aeb9c docs(feature-plan): Replace WordPress with DokuWiki integration
- Replaced open_workshop_api (WordPress REST API) with open_workshop_dokuwiki
- DokuWiki chosen for equipment documentation:
  - File-based, no separate database needed
  - Built-in versioning and history
  - Wiki syntax perfect for technical docs
  - Granular ACL (Odoo writable, all readable)
  - XML-RPC API for bidirectional sync

- Planned DokuWiki features:
  - Automatic wiki page per equipment
  - Namespace structure: ausruestung:bereich:maschinenname
  - Template system with equipment data
  - Smart button 'Wiki öffnen' in Equipment form
  - Auto-sync on equipment changes

- WordPress integration now optional:
  - Can be added later for public presentation
  - DokuWiki handles documentation (priority)
  - WordPress for marketing/SEO (future)

- Updated architecture diagram and implementation status
- open_workshop_api moved to 'optional' status
2025-12-13 17:50:51 +01:00
1b203eeb10 feat(equipment-view): Integrate OWS into Maintenance menu with Equipment-centric views
- Extended maintenance.equipment model with Related fields to ows.machine
  - ows_machine_id: One2many inverse relation
  - ows_category: Related to machine category (editable, stored)
  - ows_area_id: Related to machine area (editable, stored)
  - ows_product_ids: Related One2many for usage products
  - ows_training_ids: Related One2many for training products

- Equipment Form View Extensions:
  - Added ows_area_id and ows_category after location field
  - New 'Offene Werkstatt (Hobbyhimmel)' notebook page with:
    - Security section (category icon, category, area)
    - Usage products section (Nutzungsprodukte)
    - Training products section (Einweisungsprodukte)
  - Page visibility controlled by ows_machine_id existence

- Equipment List/Search View Extensions:
  - Added ows_category_icon and ows_area_id columns
  - Added OWS search fields (area, category)
  - Added category filters (green/yellow/red)
  - Added OWS grouping options (by area, by category)

- Menu Structure Migration:
  - Removed standalone 'Ausrüstung' top-level menu
  - Integrated all OWS config into Maintenance → Configuration:
    - Bereiche (Areas)
    - Zuordnungen (Assignments) container
      - Nutzungsprodukte (Usage Products)
      - Einweisungsprodukte (Training Products)

- Created simplified tree views for Equipment context:
  - view_ows_machine_product_tree_simple (no machine_id column)
  - view_ows_machine_training_tree_simple (no machine_id column)

- Freed location field from automatic area synchronization:
  - Removed area → location mapping in create()/write()
  - location now available for detailed physical location info
  - ows_area_id provides workspace area assignment

BREAKING CHANGE: Standalone OWS menu removed, all features now in Maintenance app
UX IMPROVEMENT: Single Equipment view shows all data, no split views needed
2025-12-13 17:43:07 +01:00
ed339b0e2e feat(open_workshop_base): Erweiterte Search-View und Gruppierung
- Füge maintenance.equipment Felder zur Suche hinzu (model, partner_id, location, company_id)
- Erweitere Gruppierungsoptionen (Hersteller, Techniker, Standort)
- Standard-Gruppierung nach Bereich aktiviert
- Dokumentation: _inherits Pattern erklärt in Feature Plan
2025-12-13 14:38:42 +01:00
e1feeb6d75 feat(aspl_equipment_qrcode_generator): Fix QR-code generation and PDF rendering
- Move QR-code generation from report to wizard (before PDF generation)
- Add attachment=True to qr_code Binary field for filestore storage
- Simplify report class - only provides data, no QR generation
- Change template from t-field to <img> with base64 data URL for proper PDF rendering
- Fix JSONB field extraction (name, category) for de_DE/en_US
- Add equipment_ids field to wizard form (invisible)
- Apply changes to all 3 label formats (2x5, 2x7, 4x7)

QR-codes now display correctly in generated PDFs and contain equipment
details plus direct link to Odoo equipment record.
2025-12-11 21:53:21 +01:00
128 changed files with 7307 additions and 2245 deletions

11
.gitignore vendored
View File

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

View 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.51 Tag)
**Deliverables**
- Git-Repo Struktur
- Docker Compose: mosquitto + simulator + bridge
- Odoo Modul Skeleton `ows_iot_bridge`
### M1 Odoo Endpoint & Modelle (12 Tage)
**Deliverables**
- `/ows/iot/event` Controller inkl. Token-Auth
- Modelle `ows.iot.device`, `ows.iot.event`
- Admin UI: Geräte anlegen, Token setzen, letzte Events anzeigen
### M2 Session-Engine (12 Tage)
**Deliverables**
- Modell `ows.machine.session`
- Event → Session Zuordnung
- Timeout/Abbruch-Logik
- Parameter für Debounce/Timeout
### M3 Simulatoren & End-to-End Test (12 Tage)
**Deliverables**
- Machine Simulator + Scale Simulator
- Bridge mit Retry-Queue
- End-to-End: Simulator → MQTT → Bridge → Odoo → Session entsteht
### M4 Qualität & Betrieb (1 Tag)
**Deliverables**
- Logging-Konzept (Odoo + Bridge)
- Tests (Unit: Controller/Auth, Integration: Session Engine)
- Doku: Event-Schema v1, Topics, Beispielpayloads
### Optional M5 POS/Anzeige (später)
- Realtime Anzeige im POS oder auf Display
- Live-Weight in POS
---
## 9. Testplan (Simulation-first)
### 9.1 Unit Tests (Odoo)
- Auth: gültiger Token → 200
- ungültig/fehlend → 401/403
- Schema-Validation → 400
- Idempotenz: duplicate `event_uid` → 409 oder duplicate-flag
### 9.2 Integration Tests
- Sequenz: start → heartbeat → stop → Session duration plausibel
- stop ohne start → kein Crash, Event loggt Fehlerzustand
- Timeout: start → keine heartbeat → Session aborted
### 9.3 Last-/Stabilitätstest (Simulator)
- 20 Maschinen, je 1 Event/s, 1h Lauf
- Ziel: Odoo bleibt stabil, Event-Insert performant, Queue läuft nicht über
---
## 10. Betriebs- und Sicherheitskonzept
- Token-Rotation möglich (neues Token, altes deaktivieren)
- Reverse Proxy:
- HTTPS
- Rate limiting auf `/ows/iot/event`
- optional Basic WAF Regeln
- Payload-Größenlimit (z. B. 1664 KB)
- Bridge persistiert Retry-Queue auf Disk
---
## 11. Offene Entscheidungen (später, nicht blocker für MVP)
- Event-Consumer in Odoo vs. Bridge-only Webhook (empfohlen: Webhook)
- Genaues Mapping zu bestehenden open_workshop Modellen (`ows.machine`, Nutzer/RFID)
- Abrechnung: Preisregeln, Rundungen, POS-Integration
- Realtime: Odoo Bus vs. eigener WebSocket Service
---
## 12. Nächste Schritte (konkret)
1. `ows_iot_bridge` Modul: Endpoint + Device/Event Modelle implementieren
2. Docker Compose: mosquitto + bridge + simulator bereitstellen
3. Simulatoren produzieren v1-Events; Bridge sendet an Odoo
4. Session-Engine anschließen und End-to-End testen
---
## Anhang A: Beispiel-Event (Maschine run_start)
```json
{
"schema_version": "v1",
"event_uid": "c2a7d6f1-7d8d-4a63-9a7f-4e6d7b0d9e2a",
"ts": "2026-01-10T12:34:56Z",
"source": "simulator",
"device_id": "esp32-fraser-01",
"entity_type": "machine",
"entity_id": "formatkreissaege",
"event_type": "run_start",
"confidence": "high",
"payload": {
"power_w": 820,
"vibration": 0.73,
"reason": "power_threshold"
}
}
```
## Anhang B: Beispiel-Event (Waage stable_weight)
```json
{
"schema_version": "v1",
"event_uid": "b6d0a2c5-9b1f-4a0b-8b19-7f2e1b8f3d11",
"ts": "2026-01-10T12:40:12Z",
"source": "simulator",
"device_id": "scale-sim-01",
"entity_type": "scale",
"entity_id": "waage-01",
"event_type": "stable_weight",
"confidence": "high",
"payload": {
"weight_g": 1532.4,
"unit": "g",
"stable_ms": 1200
}
}
```

View File

@ -68,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
View 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

View File

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

View File

@ -12,7 +12,7 @@
<div class="o_label_data">
<div class="text-center o_label_right_column">
<t t-if="equipment.qr_code">
<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>

View File

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

View File

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

View File

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

View File

@ -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 &lt;token&gt;).', 'openworkshop-odoo-api' ); ?>
</p>
<?php
}
/**
* Shortcode: [openworkshop_machines]
* Attributes:
* limit Anzahl der Einträge (optional)
*/
public function shortcode_machines( $atts ) {
$atts = shortcode_atts(
array(
'limit' => 0,
),
$atts,
'openworkshop_machines'
);
$data = $this->fetch_machines();
if ( is_wp_error( $data ) ) {
return '<p>' . esc_html__( 'Fehler beim Laden der Maschinendaten aus Odoo.', 'openworkshop-odoo-api' ) . '</p>';
}
if ( ! is_array( $data ) || empty( $data ) ) {
return '<p>' . esc_html__( 'Keine Maschinen gefunden.', 'openworkshop-odoo-api' ) . '</p>';
}
$limit = intval( $atts['limit'] );
if ( $limit > 0 ) {
$data = array_slice( $data, 0, $limit );
}
ob_start();
?>
<div class="openworkshop-machines">
<table class="openworkshop-machines-table">
<thead>
<tr>
<th><?php esc_html_e( 'Name', 'openworkshop-odoo-api' ); ?></th>
<th><?php esc_html_e( 'Bereich', 'openworkshop-odoo-api' ); ?></th>
<th><?php esc_html_e( 'Status', 'openworkshop-odoo-api' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $data as $machine ) : ?>
<tr>
<td>
<?php echo isset( $machine['name'] ) ? esc_html( $machine['name'] ) : ''; ?>
</td>
<td>
<?php echo isset( $machine['area'] ) ? esc_html( $machine['area'] ) : ''; ?>
</td>
<td>
<?php echo isset( $machine['status'] ) ? esc_html( $machine['status'] ) : ''; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
return ob_get_clean();
}
/**
* Calls the Odoo API and returns decoded JSON or WP_Error.
*/
protected function fetch_machines() {
$settings = $this->get_settings();
if ( empty( $settings['base_url'] ) ) {
return new WP_Error( 'openworkshop_no_base_url', __( 'Keine Odoo Basis-URL konfiguriert.', 'openworkshop-odoo-api' ) );
}
$url = $settings['base_url'] . $settings['machines_endpoint'];
$args = array(
'timeout' => 10,
'headers' => array(
'Accept' => 'application/json',
),
);
if ( ! empty( $settings['api_token'] ) ) {
$args['headers']['Authorization'] = 'Bearer ' . $settings['api_token'];
}
$response = wp_remote_get( $url, $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
if ( $code < 200 || $code >= 300 ) {
return new WP_Error( 'openworkshop_bad_status', sprintf( 'HTTP %d', $code ) );
}
$data = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return new WP_Error( 'openworkshop_json_error', json_last_error_msg() );
}
return $data;
}
}
function openworkshop_odoo_api_bootstrap() {
static $instance = null;
if ( $instance === null ) {
$instance = new OpenWorkshop_Odoo_API_Plugin();
}
return $instance;
}
openworkshop_odoo_api_bootstrap();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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');
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -194,7 +194,7 @@ class ResPartner(models.Model):
<tbody>
"""
machines = self.env['ows.machine'].search([('area_id', '=', area.id)], 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

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

View File

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

View File

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

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

View 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

View 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

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

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

View File

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

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

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_equipment_wiki_sync_wizard equipment.wiki.sync.wizard model_equipment_wiki_sync_wizard base.group_user 1 1 1 1

View File

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

View 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&gt;werkstatt:ausstattung:odoo-status:[name-seriennummer]}}</code>
</p>
<hr/>
<p class="mb-0">
<strong>⚙️ Automatische Synchronisation:</strong> Wenn aktiviert, werden Änderungen automatisch zur Status-Seite übertragen.
</p>
</div>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

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

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

View File

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

View 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

View File

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

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

View File

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

View 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 '',
}
}

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'Open Workshop POS',
'version': '18.0.1.0.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',
}

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

View File

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

View File

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

View 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;
},
});

View 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;
}
});

View File

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

View File

@ -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');

View 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;
}
});

View File

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

View File

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

View File

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

View File

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

View File

@ -59,7 +59,7 @@
<t t-if="area.machines.length > 0">
<div class="client-details-area" t-att-style="'border: solid 3px ' + area.color_hex + '; margin: 5px;'">
<ul>
<t t-foreach="area.machines" t-as="machine" t-key="machine.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 ? '✅' : '❌'" />

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

View File

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

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

View File

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