Compare commits

..

86 Commits

Author SHA1 Message Date
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
550cdac1eb fix: Set company_id in equipment migration to fix serial number generation
- Add default company_id to equipment records during migration
- Fixes 'Generate Serial Number' button not working for migrated equipment
- Equipment without company_id could not generate serial numbers
2025-12-11 19:22:52 +01:00
b68ac293c8 added qrcode for equipment modul 2025-12-11 19:19:02 +01:00
42db76a9c7 feat: Improve equipment creation flow and UX
- Made location field readonly in maintenance.equipment view
- Hide equipment_id field in machine form (auto-created)
- Name and serial_no now directly editable in machine form
- Area is now required field
- Location automatically synced from area to equipment
- Equipment auto-created on machine save with all fields
- Renamed menu from 'Maschinen' to 'Ausrüstung'
- Improved user flow: No need to switch between views

Changes:
- Added maintenance_equipment_views.xml for readonly location
- Updated create() to always auto-create equipment
- Enhanced write() to sync name/serial_no changes to equipment
- Menu labels updated to 'Ausrüstung'
2025-12-07 21:33:06 +01:00
f87755e7d0 Merge branch '18.0-MultiModulOpenWorkshop' into 18.0
Integration of maintenance.equipment with automated migration workflow
2025-12-07 21:10:35 +01:00
8fb58c744e feat: Migrate to maintenance.equipment with automated OpenUpgrade workflow
- Integrated maintenance.equipment using _inherits pattern in open_workshop_base
- Removed duplicate fields (code, description, storage_location, purchase_price, purchase_date)
- Delegated equipment management to OCA maintenance module
- Added Smart Button UI pattern for equipment details
- Implemented automated migration workflow:
  * SQL script renames module open_workshop → open_workshop_base
  * Pre-migration: Installs maintenance, adds equipment_id column
  * Post-migration: Migrates 23 machines with proper JSONB names and locations
- Restored old open_workshop module (installable=False) for DB compatibility
- Updated CI/CD workflow with migration steps
- Area mapping corrected: area.name → equipment.location
- JSONB handling: Using SQL jsonb_build_object() for proper storage
- Serial numbers: From old code field or generated as OWS-{id}

Tested and verified:
 23 machines successfully migrated
 JSONB names extractable: name->>'de_DE' and name->>'en_US'
 Locations correctly mapped: Fablab, Holzbereich, etc.
 equipment_id linkage functional
2025-12-07 21:09:35 +01:00
ceb8af7e48 Fix: POS Template - duplicate key error in ows_pos_customer_sidebar
- t-key fallback: order.uid || order.id || order_index
- Verhindert 'undefined' duplicate key error im POS
- Asset-Bundle korrigiert: point_of_sale._assets_pos
2025-12-07 17:18:09 +01:00
71c6ba56ed Kapitel 3+4: POS-Code in separates Modul ausgelagert
- Neues Modul open_workshop_pos erstellt
- POS JavaScript, XML Templates und CSS verschoben
- open_workshop_base bereinigt: keine POS-Abhängigkeit mehr
- open_workshop_base Version 18.0.1.0.4
- open_workshop_pos Version 18.0.1.0.0
- Kategorie von 'Point of Sale' zu 'Manufacturing' geändert
- Alle Template-Referenzen aktualisiert (open_workshop_base → open_workshop_pos)

Module getestet und erfolgreich installiert in hh18 Datenbank.
2025-12-07 16:43:51 +01:00
744b7b3234 Merge branch '18.0-MultiModulOpenWorkshop': Migration open_workshop → open_workshop_base 2025-12-07 16:01:51 +01:00
3619526af0 Migration: open_workshop → open_workshop_base
- Modul umbenannt von open_workshop zu open_workshop_base
- Alle Referenzen im Code aktualisiert (Templates, Views, Assets)
- SQL-Migrationsskript für automatische DB-Migration erstellt
- post_init_hook hinzugefügt
- Version auf 18.0.1.0.3 erhöht
- Vorbereitung für modulare Architektur (Base, POS, API)

Fixes für Gitea Action Integration:
- SQL-Skript in open_workshop_base/migrations/
- Alter open_workshop/ Ordner entfernt
- Migrations-Workflow getestet auf hh18
2025-12-07 15:45:03 +01:00
bb3d1bf7c9 removed unused files 2025-12-07 15:00:15 +01:00
bf605539fa final feature request 2 2025-12-07 14:53:06 +01:00
12d5902e3c final feature request 1 2025-12-07 14:50:36 +01:00
977aa2d1b3 final feature request 2025-12-07 14:46:09 +01:00
58c7e8f258 Open Workshop Multi Module Refactoring 2025-12-07 14:03:32 +01:00
7cd458b72f This now a multi modul odoo repository, open_workshop moved to open_workshop_base 2025-12-06 20:45:29 +01:00
dff2de1755 FIX: pos: wenn auf - gedrückt wird um eine Order zu entfernen, wurde nicht
die Order nicht gefunden. -> behoben
- IMP: "einfachere" Debug Möglichkeit mit odoo-18-dev container. README.md in .vscode
  hinzugefügt.
2025-10-25 14:10:25 +02:00
d56ae65b56 fixed missing dependency 2025-10-24 20:15:44 +02:00
bc0459ab9b IMP: added work_email to admin employee 2025-10-11 11:23:50 +02:00
7230bcb6f8 fix: anonymize_for_testsystem now supports archived admin account 2025-10-10 19:56:49 +02:00
f07f9dd8b3 Fix Odoo 18 compatibility: Enable notebook widget and fix context references
- Uncomment and activate notebook widget in machine form view
- Fix context reference from 'active_id' to 'id' for Odoo 18 compatibility
- Remove duplicate category_icon field in form group
- Clean up XML structure and indentation
- Enable "Nutzungsprodukte" and "Einweisungsprodukte" tabs with editable lists
- Resolve access rights inconsistency error for product_ids field

Fixes module upgrade error: "Field 'active_id' does not exist in model 'ows.machine'"
2025-10-09 22:17:09 +02:00
5c7fd4330d removed debugpy 2025-10-07 18:27:40 +02:00
ab696db035 Duplicate Warning Patch 2025-10-07 17:34:34 +02:00
1069630e86 FIX: Add VS Code debugging configuration and fix pos_order.py
- Added .vscode/launch.json with remote debugging setup for Odoo development
- Configured debugpy attachment on port 4338 with path mappings
- Added workspace settings for Python development with Odoo
- Fixed issues in pos_order.py for improved POS functionality
- Enhanced development workflow with breakpoint support for custom code and Odoo core
2025-10-06 19:54:24 +02:00
558dff276a debugpy option 2025-10-05 20:52:04 +02:00
e0a9205fea [fix] res.partner.remove.duplicate.bank.warning 2025-08-07 19:30:26 +02:00
24baff2a86 [fix] typo fehler 2025-08-07 19:26:37 +02:00
62dbf92b36 [fix] unknown res.partner.remove.duplicate.bank.warning 2025-08-07 19:21:34 +02:00
bbb5181a74 Merge pull request 'fix for duplicate_bank_partner_ids in account modul' (#9) from 18.0-target into 18.0
Reviewed-on: #9
2025-07-13 14:37:07 +02:00
05f9ef0990 fix for duplicate_bank_partner_ids in account modul 2025-07-13 14:32:50 +02:00
33db478c72 Merge pull request 'removeCurrentOrder() fix' (#8) from 18.0-target into 18.0
Reviewed-on: #8
2025-07-06 14:54:50 +02:00
7e8840f2a5 removeCurrentOrder() fix 2025-07-06 14:53:37 +02:00
d4a835f178 Merge pull request '18.0-target' (#7) from 18.0-target into 18.0
Reviewed-on: #7
2025-07-01 22:08:25 +02:00
0fe8417602 running POS ows machine access and customer sidebar 2025-07-01 21:57:51 +02:00
1f59e16b26 working with pos sidebar, but no content 2025-06-28 21:58:58 +02:00
59e4b19dee almost done for POS 2025-06-28 18:49:49 +02:00
021d01efe6 working merge with open_workshop 17.0. Missing Traings view in Machine Backend. No POS Machine sidebar. 2025-06-28 15:31:59 +02:00
f4216d790c added todo 2025-05-02 08:06:03 +02:00
eb17894a13 minimale Version von open workshop v16.0 für migration 2025-05-01 15:33:35 +02:00
201 changed files with 10711 additions and 4223 deletions

View File

@ -0,0 +1,15 @@
{
"name": "Open Workshop (Odoo Dev)",
"dockerComposeFile": ["${localWorkspaceFolder}/../../odoo/docker-compose.dev.yaml"],
"service": "odoo-dev",
"workspaceFolder": "/mnt/extra-addons/open_workshop",
"runServices": ["odoo-dev", "db"],
"shutdownAction": "stopCompose",
"remoteUser": "root",
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance"
],
"forwardPorts": [4338],
"postStartCommand": "echo 'Devcontainer started'"
}

8
.env
View File

@ -1,8 +0,0 @@
ODOO_VERSION=13.0
CONTAINER_NAME_EXTENSION=13_dev
ODOO_PORT=9013
DB_HOST=hobbyhimmel_odoo_13_dev_db
DB_PORT=5432
DB_USER=odoo
DB_PASSWORD=odoo
DB_NAME=hobbyhimmel

View File

@ -1,69 +0,0 @@
name: POS Test Feedback
about: Rückmeldung zu einem Test des POS-Systems geben
title: "[Feedback] "
labels: [feedback, test]
assignees: []
body:
- type: markdown
attributes:
value: |
## 🧪 POS-Test: Feedbackformular
Bitte gib uns Rückmeldung zu den einzelnen Funktionen. Beschreibe ggf. Probleme oder Auffälligkeiten.
- type: textarea
id: sonstiges
attributes:
label: Sonstiges Feedback oder Fehler
description: Alles andere, was dir beim Test aufgefallen ist (z.B. Layout, Ladezeiten, Fehlermeldungen).
- type: textarea
id: nutzerauswahl
attributes:
label: Nutzer auswählen
description: Funktioniert die Auswahl des Nutzers im POS wie erwartet?
placeholder: z.B. Nutzer nicht auffindbar, Anzeige langsam etc.
- type: textarea
id: haftung
attributes:
label: Haftungsausschluss prüfen
description: Wird der Haftungsausschluss korrekt angezeigt bzw. berücksichtigt?
- type: textarea
id: maschinenfreigabe
attributes:
label: Maschinenfreigabe prüfen
description: Wird korrekt angezeigt, ob der Nutzer eine Einweisung für eine Maschine hat?
- type: textarea
id: abrechnung
attributes:
label: Abrechnung (Bargeld / SumUp)
description: Funktioniert die Abrechnung für den Nutzer?
- type: textarea
id: coupon
attributes:
label: Coupons (erstellen / einlösen)
description: Funktionieren Erstellen und Einlösen von Coupons korrekt?
- type: textarea
id: nutzerdaten
attributes:
label: Nutzerdaten aktualisieren / Haftungsausschluss abwählen
description: Lassen sich Nutzerdaten wie RFID oder der Haftungsausschluss korrekt ändern?
- type: textarea
id: einweisung
attributes:
label: Einweisung verkaufen / prüfen
description: Lässt sich eine Einweisung verkaufen und wird sie korrekt zugewiesen?
- type: textarea
id: backend
attributes:
label: Backend-Funktionen
description: Können Maschinen und Einweisungen im Backend wie erwartet verwaltet werden?

7
.gitignore vendored
View File

@ -15,7 +15,7 @@ dist/
downloads/
eggs/
.eggs/
lib/
#lib/
lib64/
parts/
sdist/
@ -159,3 +159,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/

289
.vscode/.jslintrc vendored Normal file
View File

@ -0,0 +1,289 @@
{
"globals": {
"$": false,
"_": false,
"fuzzy": false,
"jQuery": false,
"moment": false,
"odoo": false,
"openerp": false,
"self": false
},
"env": {
"browser": true
},
"rules": {
"no-alert": "error",
"no-array-constructor": "error",
"no-bitwise": "off",
"no-caller": "error",
"no-case-declarations": "error",
"no-catch-shadow": "error",
"no-class-assign": "error",
"no-cond-assign": "error",
"no-confusing-arrow": "error",
"no-console": "error",
"no-const-assign": "error",
"no-constant-condition": "error",
"no-continue": "off",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-div-regex": "error",
"no-dupe-args": "error",
"no-dupe-class-members": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-duplicate-imports": "error",
"no-else-return": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-function": "error",
"no-empty-pattern": "error",
"no-eq-null": "error",
"no-eval": "error",
"no-ex-assign": "error",
"no-extend-native": "error",
"no-extra-bind": "error",
"no-extra-boolean-cast": "error",
"no-extra-label": "error",
"no-extra-parens": "error",
"no-extra-semi": "error",
"no-fallthrough": "error",
"no-floating-decimal": "error",
"no-func-assign": "error",
"no-implicit-coercion": ["error", {
"allow": ["~"]
}],
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-inline-comments": "error",
"no-inner-declarations": "error",
"no-invalid-regexp": "error",
"no-invalid-this": "off",
"no-irregular-whitespace": "error",
"no-iterator": "error",
"no-label-var": "error",
"no-labels": "error",
"no-lone-blocks": "error",
"no-lonely-if": "error",
"no-loop-func": "off",
"no-magic-numbers": "off",
"no-mixed-operators": "error",
"no-mixed-requires": "error",
"no-mixed-spaces-and-tabs": "error",
"no-multi-spaces": "error",
"no-multi-str": "error",
"no-multiple-empty-lines": "error",
"no-native-reassign": "error",
"no-negated-condition": "error",
"no-negated-in-lhs": "error",
"no-nested-ternary": "off",
"no-new": "error",
"no-new-func": "error",
"no-new-object": "error",
"no-new-require": "error",
"no-new-symbol": "error",
"no-new-wrappers": "error",
"no-obj-calls": "error",
"no-octal": "error",
"no-octal-escape": "error",
"no-param-reassign": "error",
"no-path-concat": "error",
"no-plusplus": "off",
"no-process-env": "error",
"no-process-exit": "error",
"no-proto": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-restricted-globals": "error",
"no-restricted-imports": "error",
"no-restricted-modules": "error",
"no-restricted-syntax": "error",
"no-return-assign": "error",
"no-script-url": "error",
"no-self-assign": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": "error",
"no-shadow-restricted-names": "error",
"no-whitespace-before-property": "error",
"no-spaced-func": "error",
"no-sparse-arrays": "error",
"no-sync": "error",
"no-tabs": "error",
"no-ternary": "off",
"no-trailing-spaces": "error",
"no-this-before-super": "error",
"no-throw-literal": "error",
"no-undef": "error",
"no-undef-init": "error",
"no-undefined": "off",
"no-unexpected-multiline": "error",
"no-underscore-dangle": "off",
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unused-expressions": "error",
"no-unused-labels": "error",
"no-unused-vars": "error",
"no-use-before-define": "error",
"no-useless-call": "error",
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-constructor": "error",
"no-useless-escape": "error",
"no-useless-rename": "error",
"no-void": "error",
"no-var": "off",
"no-warning-comments": "off",
"no-with": "error",
"array-bracket-spacing": "off",
"array-callback-return": "error",
"arrow-body-style": "error",
"arrow-parens": "error",
"arrow-spacing": "off",
"accessor-pairs": "error",
"block-scoped-var": "off",
"block-spacing": ["error", "always"],
"brace-style": "error",
"callback-return": "error",
"camelcase": "off",
"capitalized-comments": ["error", "always", {
"ignoreConsecutiveComments": true,
"ignoreInlineComments": true
}],
"comma-dangle": ["error", "always-multiline"],
"comma-spacing": ["error", {
"before": false,
"after": true
}],
"comma-style": "error",
"complexity": [
"error",
15
],
"computed-property-spacing": "off",
"consistent-return": "off",
"consistent-this": "off",
"constructor-super": "error",
"curly": "error",
"default-case": "off",
"dot-location": ["error", "property"],
"dot-notation": "error",
"eol-last": "error",
"eqeqeq": "error",
"func-names": "off",
"func-style": "off",
"generator-star-spacing": "off",
"global-require": "error",
"guard-for-in": "off",
"handle-callback-err": "error",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
"indent": "error",
"init-declarations": "error",
"jsx-quotes": "error",
"key-spacing": "off",
"keyword-spacing": "error",
"linebreak-style": [
"error",
"unix"
],
"lines-around-comment": "error",
"max-depth": "error",
"max-len": ["error", {
"code": 80,
"ignorePattern": "odoo\\.define\\(",
"tabWidth": 4
}],
"max-lines": "off",
"max-nested-callbacks": "error",
"max-params": "off",
"max-statements": "off",
"max-statements-per-line": "error",
"multiline-ternary": "off",
"new-cap": "off",
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
"newline-per-chained-call": "off",
"object-curly-newline": ["error", { "consistent": true }],
"object-curly-spacing": ["error", "never"],
"object-property-newline": ["error", {
"allowAllPropertiesOnSameLine": true
}],
"object-shorthand": "off",
"one-var": "off",
"one-var-declaration-per-line": "off",
"operator-assignment": "error",
"operator-linebreak": "error",
"padded-blocks": "off",
"prefer-arrow-callback": "off",
"prefer-const": "error",
"prefer-reflect": "off",
"prefer-rest-params": "off",
"prefer-spread": "off",
"prefer-template": "off",
"quote-props": "off",
"quotes": "off",
"radix": "error",
"require-yield": "error",
"rest-spread-spacing": "off",
"semi": [
"error",
"always"
],
"semi-spacing": "error",
"sort-imports": "error",
"sort-vars": "off",
"space-before-blocks": "error",
"space-before-function-paren": "error",
"space-in-parens": "off",
"space-infix-ops": "off",
"space-unary-ops": "off",
"spaced-comment": ["error", "always"],
"strict": ["error", "function"],
"template-curly-spacing": "off",
"unicode-bom": "error",
"use-isnan": "error",
"valid-jsdoc": ["error", {
"prefer": {
"arg": "param",
"argument": "param",
"augments": "extends",
"constructor": "class",
"exception": "throws",
"func": "function",
"method": "function",
"prop": "property",
"return": "returns",
"virtual": "abstract",
"yield": "yields"
},
"preferType": {
"array": "Array",
"bool": "Boolean",
"boolean": "Boolean",
"number": "Number",
"object": "Object",
"str": "String",
"string": "String"
},
"requireParamDescription": false,
"requireReturn": false,
"requireReturnDescription": false,
"requireReturnType": false
}],
"valid-typeof": "error",
"vars-on-top": "off",
"wrap-iife": "error",
"wrap-regex": "error",
"yield-star-spacing": "off",
"yoda": "error"
},
"parserOptions": {}
}

64
.vscode/README.md vendored Normal file
View File

@ -0,0 +1,64 @@
# Quickstart: Debugging für open_workshop
Diese Datei hilft dir, das `open_workshop` Addon schnell in VS Code zu öffnen und sowohl das Addon als auch (optional) den OdooCore zu debuggen.
Kurzfassung
- Öffne in VS Code den Ordner `extra-addons/open_workshop`.
- Starte die Development-Container/Composer Umgebung mit `./dev.sh` (Option 1 oder 2).
- Verwende die DevContainerFunktion oder die DebugKonfigurationen unten.
1) Container starten
Empfohlen: im Projektstamm (`odoo`) ausführen:
```bash
./dev.sh
# Option 1: Normal starten (ODOO_DEV=1)
# Option 2: Debug-Modus (ODOO_DEBUG=1) — der Container wartet auf eine Debug-Verbindung
```
2) VS Code als DevContainer verbinden (empfohlen)
- Öffne VS Code im lokalen Ordner `extra-addons/open_workshop`.
- Command Palette → `Dev Containers: Attach to Running Container...` → wähle `odoo-dev`.
- VS Code öffnet den Container als Arbeitsumgebung (remoteUser ist `odoo`).
- Öffne dann das WorkspaceVerzeichnis `/mnt/extra-addons/open_workshop`.
Vorteil: Du arbeitest direkt im Container (kein lokales Kopieren der CoreSourcen nötig) und Breakpoints funktionieren zuverlässig.
3) Debugging (Attach)
- Wenn du als DevContainer verbunden bist, verwende die DebugKonfiguration "Odoo Attach (container)" (port 5678).
- Wenn du lokal arbeitest und den HostPort benutzt, verwende "Odoo Attach (host)" (port 4338).
4) PfadMapping
- Die `launch.json` enthält Pfadabbildungen:
- `${workspaceFolder}``/mnt/extra-addons/open_workshop` (dein Addon)
- `${workspaceFolder}/../../odoo-source/odoo``/usr/lib/python3/dist-packages/odoo` (falls du lokal eine Kopie des OdooCores hast)
Hinweis: Lokale `odoo-source` ist nicht erforderlich, wenn du per DevContainer arbeitest, weil VS Code die Dateien direkt im Container liest.
5) Troubleshooting
- Debug-Verbindung schlägt fehl: prüfe, ob der Container im DebugModus läuft und der Port gemappt ist:
```bash
docker compose -f docker-compose.dev.yaml ps
docker compose -f docker-compose.dev.yaml logs -f odoo-dev
```
- VS Code meldet, dass Breakpoints nicht aufgelöst werden: vergewissere dich, dass die `pathMappings` korrekt sind und die lokalen Dateien existieren (oder nutze DevContainer).
6) Image/Compose aktualisieren
- Wenn du `Dockerfile.Dev` geändert hast: neu bauen (Option 3 in `./dev.sh`), dann Container neu starten (Option 5 oder down/up).
7) Kurze Checklist für Mitentwickler
- `./dev.sh` → Option 3 (einmalig) wenn du das DevImage bauen musst.
- `./dev.sh` → Option 1 oder 2 zum Starten.
- In VS Code: Öffne `extra-addons/open_workshop`, dann `Dev Containers: Attach to Running Container...`.
- Starte Debug mit "Odoo Attach (container)" oder "Odoo Attach (host)".

45
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,45 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Odoo Attach (host)",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "localhost",
"port": 4338
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/mnt/extra-addons/open_workshop"
},
{
"localRoot": "${workspaceFolder}/../../odoo-source/odoo",
"remoteRoot": "/usr/lib/python3/dist-packages/odoo"
}
],
"justMyCode": false
},
{
"name": "Odoo Attach (container)",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "localhost",
"port": 5678
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/mnt/extra-addons/open_workshop"
},
{
"localRoot": "${workspaceFolder}/../../odoo-source/odoo",
"remoteRoot": "/usr/lib/python3/dist-packages/odoo"
}
],
"justMyCode": false
}
]
}

30
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,30 @@
{
"python.pythonPath": "/usr/bin/python3",
"python.analysis.extraPaths": [
"${workspaceFolder}",
"${workspaceFolder}/../../odoo-source/odoo"
],
"python.analysis.typeCheckingMode": "off"
}
{
"editor.rulers": [80, 100, 120],
"files.eol": "\n",
"[python]": {
"editor.defaultFormatter": "ms-python.python",
"editor.insertSpaces": true,
"editor.tabSize": 4
},
"[javascript]": {
"editor.insertSpaces": true,
"editor.tabSize": 2
},
"python.analysis.extraPaths": [
"../../odoo-source/odoo",
],
"python.testing.pytestArgs": [
"--odoo-http",
"."
],
"python.testing.pytestEnabled": false,
"python.testing.unittestEnabled": false
}

68
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,68 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "start-odoo-dev",
"type": "shell",
"command": "docker",
"args": ["compose", "-f", "${workspaceFolder}/../../odoo/docker-compose.dev.yaml", "up", "-d"],
"options": {
"env": {
"ODOO_DEV": "1"
}
},
"group": { "kind": "build", "isDefault": true },
"presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared" },
"problemMatcher": []
},
{
"label": "start-odoo-debug",
"type": "shell",
"command": "docker",
"args": ["compose", "-f", "${workspaceFolder}/../../odoo/docker-compose.dev.yaml", "up", "-d"],
"options": {
"env": {
"ODOO_DEBUG": "1"
}
},
"group": "build",
"presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared" },
"problemMatcher": []
},
{
"label": "rebuild-odoo-dev",
"type": "shell",
"command": "docker",
"args": ["compose", "-f", "${workspaceFolder}/../../odoo/docker-compose.dev.yaml", "up", "--build", "-d"],
"group": "build",
"presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared" },
"problemMatcher": []
},
{
"label": "stop-odoo-dev",
"type": "shell",
"command": "docker",
"args": ["compose", "-f", "${workspaceFolder}/../..odoo/docker-compose.dev.yaml", "down"],
"group": "build",
"presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared" },
"problemMatcher": []
},
{
"label": "odoo-logs",
"type": "shell",
"command": "docker",
"args": ["compose", "-f", "${workspaceFolder}/../../odoo/docker-compose.dev.yaml", "logs", "-f", "odoo-dev"],
"group": "test",
"presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared" },
"problemMatcher": []
},
{
"label": "shell-odoo",
"type": "shell",
"command": "docker",
"args": ["compose", "-f", "${workspaceFolder}/../../odoo/docker-compose.dev.yaml", "exec", "odoo-dev", "bash"],
"presentation": { "echo": true, "reveal": "always", "focus": true, "panel": "shared" },
"problemMatcher": []
}
]
}

View File

@ -0,0 +1,178 @@
# Odoo vs WordPress vs Odoo.sh Architektur- & Strategie-Empfehlung
*Für HobbyHimmel / FabLab Traffic: ~1000 Besucher, 2000 Views pro Tag*
---
# 🚀 Zusammenfassung (TL;DR)
| Bereich | Empfehlung |
|--------|------------|
| Öffentliche Website | **WordPress behalten** |
| Odoo als öffentliche Website | **Nicht geeignet** (Performance + Security) |
| Odoo.sh | **Nicht geeignet** (Benutzerkosten, Einschränkungen) |
| Odoo self-hosted | **Optimal für interne Prozesse** |
| Integration | **WordPress ↔ Odoo via REST API** |
| Zielarchitektur | **WP Frontend + Odoo Backend** |
---
# 1. Zielsetzung
HobbyHimmel benötigt:
- Eine öffentliche Website (ca. 1000 Besucher / 2000 Views täglich)
- Interne Verwaltungsprozesse (Maschinen, Einweisungen, Kurse, POS)
- Möglichkeit, ausgewählte Daten (Maschinenstatus, Kurstermine…) öffentlich darzustellen
- Möglichst geringe Kosten
- Hohe Sicherheit (keine offene interne Datenbank)
---
# 2. Analyse der drei Optionen
## 2.1 WordPress (beste Lösung für öffentliche Webseite)
**Stärken:**
- Sehr schneller Seitenaufbau, Caching, CDN
- Perfekt für hohe Besucherzahlen
- Themes, SEO, Plug-ins
- Komplett getrennt vom internen FabLab-System
**Schwächen:**
- Kein direkter Zugriff auf Odoo-Daten (muss über API geschehen)
**Fazit:**
⭐⭐⭐⭐⭐ **Beste Lösung für alle öffentlichen Besucher.**
---
## 2.2 Odoo (self-hosted, intern)
**Stärken:**
- Ideal für Maschinenverwaltung, POS, Maintenance, Events
- Volle Freiheit, beliebige Module zu installieren
- Keine Lizenzkosten für Ehrenamtliche, solange sie keinen Login benötigen
- Perfekt für interne Daten & Prozesse
**Schwächen:**
- Als öffentliche Website ungeeignet (Performance, kein CDN)
- DSL-Upload wäre Flaschenhals
**Fazit:**
⭐⭐⭐⭐⭐ **Beste Lösung für interne Prozesse.
Nicht geeignet als öffentliche Website.**
---
## 2.3 Odoo.sh
**Vorteile:**
- Kein DSL-Problem
- Hosting, Deployment & Backups werden automatisiert
**ABER:**
- **sehr hohe Kosten** bei >10 Ehrenamtlichen (pro Benutzer Lizenzpflicht)
- **stark eingeschränkt** bei Custom-Code, Addons, APIs
- Performance besser, aber **immer noch weit unter WordPress**
- Website-Teil ist **nicht auf hohen Traffic optimiert**
**Fazit:**
⭐⭐ **Für HobbyHimmel ungeeignet.**
Zu teuer, zu eingeschränkt, keine Website-Performance wie WP.
---
# 3. Performance-Bewertung
| System | Performance für 1000 Besucher/Tag |
|--------|-----------------------------------|
| WordPress (mit CDN) | ⭐⭐⭐⭐⭐ |
| Odoo.sh Website | ⭐⭐ |
| Odoo self-hosted Website über DSL | ⭐ (Upload-Limit!) |
WordPress ist **um Größenordnungen** besser für Public Traffic.
---
# 4. Sicherheitsbewertung
| System | Risiko bei Public Exposure |
|--------|----------------------------|
| WordPress | gering (gefiltert, gehärtet, cached) |
| Odoo Website | hoch (direkter Zugriff auf Business-Daten) |
| Odoo API | gering (nur ausgewählte Daten, stark begrenzt) |
Odoo sollte **nicht** öffentlich zugänglich sein, außer über **stark eingeschränkte REST API**.
---
# 5. Kostenbewertung
| Option | Monatliche Kosten | Skalierbarkeit |
|--------|-------------------|----------------|
| WordPress gehostet | 520 € | sehr gut |
| Odoo self-hosted | ~020 € | sehr gut |
| **Odoo.sh** | **ab ~40 € pro Benutzer** | schlecht bei vielen Ehrenamtlichen |
**Odoo.sh wäre ruinös teuer für ein FabLab.**
---
# 6. Empfohlene Zielarchitektur
```
Internet
|
| 1000 Besucher/Tag
v
WordPress Website (öffentlich)
|
| REST-API (lesend!)
v
Odoo API Gateway (open_workshop_api)
|
| internal-only
v
Odoo Backend (POS, Maintenance, Trainings, Events)
```
**Vorteile:**
- Odoo bleibt geschützt im LAN oder VPN
- Kein DSL-Upload-Limit problematisch → API ist sehr leichtgewichtig
- WordPress ist ultraschnell & SEO-stark
- Odoo liefert Daten dynamisch an WP (z.B. Maschinenliste, Kurse)
- Keine Benutzerkosten für Ehrenamtliche
---
# 7. Fazit
Die beste Lösung für HobbyHimmel besteht aus einer Kombination:
# 🟩 **WordPress für öffentliche Website**
# 🟩 **Odoo (self-hosted) für Verwaltung & Prozesse**
# 🟩 **API-Modul für WordPress-Anbindung**
# 🟥 **Keine Odoo Website für Öffentlichkeit**
# 🟥 **Kein Odoo.sh wegen hoher Lizenzkosten**
Diese Architektur ist:
- Sicher
- Schnell
- Zukunftssicher
- Kosteneffizient
- Flexibel
- Perfekt für ein FabLab mit vielen Ehrenamtlichen
---
# 8. Nächste Schritte
Wenn gewünscht, kann ich liefern:
### ✔ Architekturdiagramm (grafisch)
### ✔ open_workshop_api Modul als Odoo-Modul-Skeleton
### ✔ WordPress-Shortcodes oder Plugin
### ✔ Sicherheitsempfehlungen (CORS, Token, Firewalls)
### ✔ Optimierungskonzept für den späteren Betrieb

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

@ -0,0 +1,538 @@
# 0. Migration: open_workshop → open_workshop_base ✅ ABGESCHLOSSEN
**Status: ERLEDIGT (08.12.2025)**
Die Migration wurde erfolgreich durchgeführt:
- ✅ SQL-Migration: Modul umbenannt in Datenbank
- ✅ Pre-Migration: maintenance.equipment Integration vorbereitet
- ✅ Post-Migration: 23 Maschinen zu maintenance.equipment migriert
- ✅ _inherits Pattern implementiert (ows.machine → maintenance.equipment)
- ✅ Automatische Migration in CI/CD Pipeline integriert
- ✅ UX verbessert: Equipment wird automatisch erstellt
- ✅ Location readonly und auto-sync von area
- ✅ Menü umbenannt zu "Ausrüstung"
**Technische Details:**
- Alte Felder entfernt: code, description, storage_location, purchase_price, purchase_date
- Delegation an maintenance.equipment: name, serial_no, location, cost, effective_date
- JSONB für mehrsprachige Namen (de_DE, en_US)
- Area → Location Synchronisation automatisch
---
# Open Workshop FEATURE PLAN
## Vollständige Modularisierung + WordPress REST API Integration (API = Pflicht)
Dieses Dokument ist die **finale, vollständig korrigierte Version** des Feature-Plans.
Es kombiniert alle Inhalte der vorherigen Dokumente, **konsolidiert, bereinigt und vereinheitlicht**,
und stellt klar:
# ⭐ Die API ist ein **Pflichtbestandteil** der finalen Architektur.
Das Dokument ist **eigenständig** und enthält alles, was ein Entwickler oder KI-Agent benötigt,
um das gesamte Open-Workshop-System korrekt modularisiert aufzubauen.
---
# 1. Gesamtziel
Das Projekt *Open Workshop* soll in eine moderne, klare und skalierbare Architektur überführt werden:
- **Odoo (intern)** als Verwaltungs-Backend für Maschinen, Einweisungen, POS, Maintenance, Events.
- **WordPress (extern)** als öffentliche Website mit hoher Performance und SEO.
- **REST API (Pflicht!)** als Kommunikationsschicht zwischen beiden Systemen.
Damit entsteht:
```
WordPress (öffentlich)
| REST API (nur lesend)
Odoo (intern, geschützt)
```
Diese Architektur vermeidet:
- Performance-Probleme durch DSL-Uplink
- Sicherheitsrisiken von Odoo im Internet
- Hohe Kosten für Odoo.sh Benutzerlizenzen
---
# 2. Aktuelle Modulstruktur (Stand: 08.12.2025)
```
open_workshop/
├── open_workshop/ # ✅ Alt-Modul (installable=False, Kompatibilität)
├── open_workshop_base/ # ✅ FERTIG Backend, _inherits maintenance.equipment
├── open_workshop_pos/ # ✅ FERTIG POS-Integration (JS, XML, UI)
├── open_workshop_dokuwiki/ # ⏳ IN ENTWICKLUNG DokuWiki-Integration
└── open_workshop_api/ # 💤 OPTIONAL REST API (zurückgestellt)
```
**Entfernt:** open_workshop_maintenance (verworfen - Funktionalität direkt in open_workshop_base integriert)
---
# 3. Modul: open_workshop_base ✅ FERTIG
**Status: PRODUKTIV (18.0.1.0.4)**
Enthält:
- ✅ `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)
- ✅ `ows.machine.access` (Zugriffsfreigaben)
- ✅ Automatische Equipment-Erstellung bei Maschinenanlage
- ✅ Area → Location Synchronisation
- ✅ Sicherheitsregeln (ir.model.access)
- ✅ Backend-UI: Form, Tree, Search/Filter
- ✅ Smart Button zu Equipment-Details
- ✅ Integration mit OCA maintenance Modulen
**Abhängigkeiten:**
- base, account, hr, product, sale, contacts, **maintenance**
**Maintenance Integration:**
- Statt separatem Modul: Direkte Integration via _inherits
- Equipment-Felder: name, serial_no, location, cost, effective_date
- OWS-Felder: category, area_id, product_ids, training_ids
---
# 4. Modul: open_workshop_pos ✅ FERTIG
**Status: PRODUKTIV**
Vollständig separiertes POS-Modul mit:
### JS-Module:
- ✅ ows_machine_access_list.js
- ✅ ows_pos_customer_sidebar.js
- ✅ ows_pos_sidebar.js
- ✅ ows_product_screen_template_patch.js
### XML-Templates:
- ✅ QWeb Templates für POS-UI
- ✅ Maschinenfreigaben-Widget
- ✅ Customer Sidebar mit Zugriffsstatus
### Assets:
```json
'point_of_sale.assets': [
'open_workshop_pos/static/src/js/**/*.js',
'open_workshop_pos/static/src/xml/**/*.xml',
'open_workshop_pos/static/src/css/**/*.css'
]
```
**Abhängigkeiten:**
- open_workshop_base
- point_of_sale
**Integration:**
- Zeigt Maschinenzugriffe im POS
- Filterung nach Bereichen
- Live-Status der Freigaben
---
# 5. Modul: open_workshop_dokuwiki ⏳ IN ENTWICKLUNG
**Status: IN ENTWICKLUNG (Branch: feature/dokuwiki-integration)**
Dieses Modul integriert **DokuWiki** als Dokumentationssystem für Equipment.
## 5.1 Ziele
- Automatische Wiki-Seiten für jedes Equipment
- **Invertierte Include-Architektur**:
- Odoo generiert geschützte Hauptseite mit Equipment-Daten
- Hauptseite inkludiert User-Dokumentation via Include Plugin
- User editieren separate `:doku` Unterseite
- **Klare Datentrennung**:
- Odoo = Master für strukturierte Daten (Hauptseite, read-only für User)
- User = Master für Freitext-Dokumentation (`:doku` Unterseite, editierbar)
- **Generisches ACL-System**: Eine Regel für alle Equipment
- Versionierung durch DokuWiki eingebaut
## 5.2 Architektur
```
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>
GET /api/v1/areas
```
**Hinweis:** Mit DokuWiki kann die öffentliche Dokumentation direkt über das Wiki erfolgen.
Eine separate REST API ist optional und wird nur bei Bedarf implementiert.
## 6.1 Warum DokuWiki statt WordPress?
- **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
## 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
Aktuell hat DokuWiki-Integration **Priorität**.
---
# 7. Finaler Architektur-Plan
```
Internet
|
DokuWiki (Dokumentation)
WordPress (optional: Präsentation)
|
XML-RPC / REST API
|
----------------------------
| Odoo Backend (LAN/VPN)
| open_workshop_dokuwiki
| maintenance.equipment
----------------------------
POS, Benutzer, Einweisungen
```
**Fokus:** Equipment-Dokumentation über DokuWiki, WordPress später optional.
---
# 8. Sicherheit
**DokuWiki ACL:**
- Namespace `ausruestung:*` lesbar für alle (@ALL = 1)
- Namespace `ausruestung:*` schreibbar nur für Odoo (@odoo = 8)
- Manuelle Edits durch Admins möglich
**Odoo:**
- DokuWiki-Credentials in ir.config_parameter
- XML-RPC über HTTPS
- Logging aller Wiki-Operationen
- Backup-System für Wiki-Dateien
**Optional (REST API):**
- Reverse Proxy sperrt alle Odoo-URLs außer `/api/v1/*`
- HTTPS erzwingen
- Rate-Limit + Firewall
- Token-Auth
---
# 9. Vorteile dieser Architektur
**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
**Modular, wartbar, zukunftssicher**
**Intelligente Sync** Nur strukturierte Daten werden überschrieben, Freitext bleibt erhalten
**Öffentlich lesbar** Anleitungen für alle verfügbar
**WordPress optional** Kann später für Marketing/SEO ergänzt werden
---
# 10. Implementierungsstand (13.12.2025)
| Schritt | Status | Details |
|---------|--------|---------|
| 1. open_workshop_base | ✅ **FERTIG** | Version 18.0.1.0.4, produktiv |
| 2. open_workshop_pos | ✅ **FERTIG** | POS-Integration komplett |
| 3. ~~open_workshop_maintenance~~ | ❌ **VERWORFEN** | Direkt in Base integriert |
| 4. Maintenance Integration | ✅ **FERTIG** | _inherits Pattern implementiert |
| 5. Equipment-View Integration | ✅ **FERTIG** | Related fields, Menu-Migration |
| 6. Migration Workflow | ✅ **FERTIG** | SQL + Python, CI/CD integriert |
| 7. open_workshop_dokuwiki | ⏳ **IN ENTWICKLUNG** | Branch: feature/dokuwiki-integration |
| 8. open_workshop_api | 💤 **OPTIONAL** | Zurückgestellt, bei Bedarf |
---
# 11. Nächste Schritte
1. **open_workshop_dokuwiki** entwickeln
- DokuWiki XML-RPC Client implementieren
- Template-System für Equipment-Seiten
- Sync-Mechanismus (Odoo → Wiki)
- Smart Button "Wiki öffnen"
- ACL-Konfiguration dokumentieren
2. **DokuWiki aufsetzen**
- Installation auf Server
- Namespace `ausruestung:` anlegen
- ACL konfigurieren (@odoo Gruppe)
- XML-RPC API aktivieren
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 vereint das Beste aus beiden Welten:
- ✅ **Technisch korrekt** _inherits Pattern statt separatem Modul
- ✅ **Performant** Maintenance.equipment als Single Source of Truth
- ✅ **Dokumentiert** DokuWiki für strukturierte Anleitungen
- ✅ **Versioniert** History-System eingebaut
- ✅ **Öffentlich zugänglich** Wiki lesbar für alle
- ✅ **Kostenoptimiert** Keine zusätzlichen Lizenzen
- ✅ **Langfristig erweiterbar** Modularer Aufbau
- ✅ **Produktiv im Einsatz** Migration erfolgreich abgeschlossen
**DokuWiki-Integration ist der nächste logische Schritt** für bessere Equipment-Dokumentation.
**Letztes Update: 13.12.2025**

View File

@ -0,0 +1,142 @@
# Empfohlene Modulstruktur für Open_Workshop + öffentliches Website-Modul
## 🎯 Ziel
Du möchtest:
1. Ein **öffentliches Website-Modul**, das Maschineninformationen aus dem Maintenance-Modul anzeigt.
2. Dein gesamtes `open_workshop`-Repository sinnvoll modularisieren.
Diese Antwort beschreibt die optimale Struktur und die Gründe dafür.
---
# ✅ Öffentliches Modul ist sinnvoll
Du möchtest:
- Maschinen anzeigen (Maintenance)
- Zustand sichtbar machen
- Bilder & PDFs anzeigen
- ohne Login (auth="public")
➡️ Dafür ist ein eigenes Modul **open_workshop_website** ideal.
Dieses Website-Modul sollte *nur Anzeige* machen, keine Logik verwalten.
---
# ⭐ Empfohlene Modulstruktur (Best Practice)
```
open_workshop/
├── open_workshop_pos/ # POS-Integration, Einweisungen, Freigaben
├── open_workshop_maintenance/ # Verbindung zu maintenance.equipment
│ # z. B. Modelle, Felder, Extensions
└── open_workshop_website/ # Öffentliche Darstellung, QR-Codes, Website
```
---
# 🧩 Gründe für diese modulare Struktur
## 1⃣ Trennung nach funktionalen Bereichen
Odoo trennt:
- POS
- Maintenance
- Website
Du solltest dasselbe tun.
So bleibt alles klar, logisch und update-sicher.
---
## 2⃣ Bessere Wartbarkeit
Wenn Odoo 19/20 Änderungen bringt:
- POS ändert sich → nur `open_workshop_pos`
- Maintenance ändert sich → nur `open_workshop_maintenance`
- Website ändert sich → nur `open_workshop_website`
Ein großes Sammelmodul wäre schwer zu warten.
---
## 3⃣ Nutzer können auswählen, was sie brauchen
Beispiel:
Ein Kunde will nur Maschinen + Website, aber kein POS.
Dann installiert er:
- `open_workshop_maintenance`
- `open_workshop_website`
🔧 Ohne unnötige POS-Abhängigkeit.
---
## 4⃣ Klare Modulabhängigkeiten
### `open_workshop_pos`
- depends: `point_of_sale`
### `open_workshop_maintenance`
- depends: `maintenance`
### `open_workshop_website`
- depends: `website`
- optional depends: `open_workshop_maintenance`
Alles sauber getrennt.
---
## 5⃣ Erweiterbarkeit bleibt einfach
Dein Projekt wächst schnell:
- QR-Codes
- Maschinenstatus
- API
- Dashboards
- Rollen mit Einweisungssichtbarkeit
Wenn alles in EINEM Modul wäre → unübersichtlich
Wenn modular → perfekt strukturiert
---
# ❌ Warum alles in *einem einzigen Modul* schlecht wäre
- Mischmasch aus POS, Website und Maintenance-Code
- schwerer zu debuggen
- schlechtere Wiederverwendbarkeit
- mehr Merge-Konflikte
- Odoo selbst macht das nie so
---
# 🎉 Fazit
Die modulare Struktur ist optimal und entspricht OCA/Odoo-Best-Practices:
| Modul | Zweck |
|-------|--------|
| **open_workshop_pos** | Einweisung, Freigaben, POS GUI |
| **open_workshop_maintenance** | Maschinenmodellierung, Maintenance-Anbindung |
| **open_workshop_website** | öffentliche Darstellung, QR-Codes |
---
# 📦 Optional: Nächster Schritt
Ich kann dir komplette Modul-Gerüste erstellen:
- Manifest-Dateien
- Controller
- Views
- Templates
- QR-Code-Integration
- Demo-Daten
Sag einfach Bescheid:
👉 *„Bitte erstelle mir das Grundgerüst.“*

View File

@ -0,0 +1,161 @@
# Maschinen- und Geräteverwaltung im FabLab mit Odoo Maintenance + Open_Workshop
## 🎯 Ausgangssituation
In der Werkstatt existieren viele verschiedene Maschinen und Geräte:
- stationär
- mobil
- elektrisch
- mechanisch
- verschiedene Sicherheits- und Einweisungsanforderungen
Der aktuelle Zustand, Bilder und Bedienungsanleitungen sind unstrukturiert im Wiki gespeichert.
Das Ziel ist:
- eine zentrale, strukturierte Verwaltung in **Odoo**
- Anzeige des Maschinenzustands
- Zugriff für Nutzer auf Bedienungsanleitungen
- ohne Backend-Zugang
- am besten über Website/Portal + QR-Codes
---
## 💡 Die perfekte Lösung: Maintenance-Modul + Open_Workshop + Website
Die optimale Architektur besteht aus drei Komponenten:
1. **Maintenance-Modul**
→ Zentrale Verwaltung aller Maschinen/Devices
2. **Open_Workshop-Modul**
→ Freigaben, Einweisungen, POS-Integration
3. **Website-/Portal-Modul (Custom)**
→ Anzeige für Nutzer: Maschine, Zustand, Anleitung, QR-Codes
Diese Kombination ist robust, klar strukturiert und update-sicher.
---
# ✅ Warum das Maintenance-Modul ideal ist
Das Modell `maintenance.equipment` bietet bereits alles, was du brauchst:
| Feld | Nutzen |
|------|--------|
| `name` | Maschinenname |
| `category_id` | Maschinenbereich |
| `maintenance_state` | Betriebszustand |
| `note` | Beschreibung |
| `image_1920` | Maschinenbild |
| `message_attachment_count` | PDFs & Dateien |
| Anhänge | Bedienungsanleitungen |
Diese Felder machen das Maintenance-Modul zur perfekten **Source of Truth**.
---
# 🧩 Wie du die Daten Nutzern sichtbar machst
Nicht das Backend öffnen.
Nicht `website=True` setzen.
Sondern:
### ✔ Eigener Website-/Portal-Controller
Beispiele:
### Maschinenliste
```
/workshop/machines
```
### Maschinendetailansicht
```
/workshop/machine/<id>
```
Dort sieht der Nutzer:
- Bild
- Betriebszustand
- Kategorie
- Beschreibung
- Bedienungsanleitung (PDF-Download)
- optional: Sicherheitsinfos
- optional: Einweisungsstatus (aus Open_Workshop)
Alles **read-only**, ohne Backend.
---
# 🔒 Zugriffsvarianten
Du entscheidest selbst:
## **Option A: Öffentlich**
- Keine Anmeldung nötig
- Zustand und Anleitung für alle sichtbar
- Ideal für Tablets im FabLab oder QR-Codes an Maschinen
## **Option B: Portal-Zugang**
- Nutzer müssen sich einloggen
- Anleitungen nur für registrierte Mitglieder sichtbar
## **Option C: Hybrid**
- Zustand öffentlich
- Anleitungen nur für eingeloggte Benutzer mit Einweisung
Diese Variante passt besonders gut zu deinem Open_Workshop-Modell.
---
# 🧰 Integration mit Open_Workshop
Die ideale Struktur ist:
```
maintenance.equipment ←→ ows.machine ←→ ows.machine.access
```
Das bedeutet:
- Maintenance = technische Daten
- Open_Workshop = Einweisungsprozesse
- Website = Darstellung
---
# 📲 QR-Codes an den Maschinen
Odoo kann automatisch QR-Codes generieren.
Du platzierst auf jeder Maschine einen QR-Code, z. B.:
```
/workshop/machine/42
```
Der Nutzer scannt ihn und sieht:
- Bild
- Zustand
- Anleitung
- ggf. "Einweisung vorhanden" / "Keine Einweisung"
---
# 🚀 Modularer Vorschlag für deine Architektur
### **1. Maintenance-Modul**
Zentrale Maschinenverwaltung
### **2. Open_Workshop-Modul**
Einweisung, Freigaben, POS
### **3. Custom Website-Modul**
- Maschinenliste
- Detailseiten
- PDF-Downloads
- Einweisungsstatus
- optional QR-Code-Automatik

View File

@ -1,5 +0,0 @@
from . import models
from . import controllers
from . import post_init_hook
# damit run_migration sichtbar ist:
run_migration = post_init_hook.run_migration

View File

@ -1,38 +0,0 @@
{
'name': 'POS Open Workshop',
'license': 'AGPL-3',
'version': '13.0.1.0.0',
'summary': 'Erstellt Maschinenfreigaben basierend auf POS-Einweisungsprodukten',
'depends': ['base','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',
'views/assets.xml',
'data/data.xml',
],
'qweb': [
'static/src/xml/ows_briefing_details.xml',
'static/src/xml/ows_briefing_details_edit.xml',
'static/src/xml/ows_pos_order_selector.xml',
'static/src/xml/ows_machine_sidebar.xml',
'static/src/xml/ows_pos_machine_access_view.xml',
],
'installable': True,
'assets': {
'point_of_sale.assets': [
'static/src/js/machine_access_sidebar.js',
'static/src/css/pos.css',
],
},
'post_init_hook': 'run_migration',
'description': """
Diese App erstellt Maschinenfreigaben basierend auf POS-Einweisungsprodukten.
Die App ist für den Einsatz in der Odoo-Version 13.0 konzipiert.
""",
}

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from odoo import api, SUPERUSER_ID
from . import models
from . import report
from . import wizard
# TODO: Generate Sequence For each company for Equipment
def pre_init_hook(env):
company_ids = env['res.company'].search([])
for company_id in company_ids:
sequence_id = env['ir.sequence'].search(
[('name', '=', 'Equipment Company Sequence'), ('company_id', '=', company_id.id)])
if not sequence_id:
env['ir.sequence'].create({
'name': 'Equipment Company Sequence',
'prefix': company_id.id,
'padding': 5,
'number_increment': 1,
'company_id': company_id.id
})

View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
###############################################################################
#
# Aspire Softserv Pvt. Ltd.
# Copyright (C) Aspire Softserv Pvt. Ltd.(<https://aspiresoftserv.com>).
#
###############################################################################
{
"name": "QR Code on Equipment",
'category': '',
"summary": "Add QR Code on equipment for managing equipment.",
"version": "18.0.0.1.0",
"license": "AGPL-3",
"price": 00.00,
'description': """
The Equipment Management Module generates unique QR codes for each asset, offering instant details and direct Odoo profile access for seamless management.
""",
"author": "Aspire Softserv Pvt. Ltd",
"website": "https://aspiresoftserv.com",
"depends": ['account','maintenance'],
"external_dependencies": {
'python': ['qrcode']
},
"data": [
'views/maintenance_equipment.xml',
'security/ir.model.access.csv',
'report/custom_qrcode.xml',
'wizard/equipment_label_layout_views.xml',
],
'pre_init_hook': 'pre_init_hook',
"application": True,
"installable": True,
"maintainer": "Aspire Softserv Pvt. Ltd",
"support": "odoo@aspiresoftserv.com",
'images': ['static/description/banner.gif'],
}

View File

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

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class MaintenanceEquipment(models.Model):
_inherit = 'maintenance.equipment'
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)
def action_print_qrcode_layout(self):
action = self.env['ir.actions.act_window']._for_xml_id('aspl_equipment_qrcode_generator.action_open_label_layout_equipment')
action['context'] = {'default_equipment_ids': self.ids}
return action
def generate_serial_no(self):
for equipment_id in self:
if not equipment_id.comp_serial_no:
company_id = equipment_id.company_id.id
sequence_id = self.env['ir.sequence'].search(
[('name', '=', 'Equipment Company Sequence'), ('company_id', '=', company_id)])
if sequence_id:
data = sequence_id._next()
equipment_id.write({
'comp_serial_no': data
})

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from odoo import models, api
class ResCompany(models.Model):
_inherit = 'res.company'
@api.model
def create(self, vals):
result = super(ResCompany, self).create(vals)
sequence_id = self.env['ir.sequence'].search(
[('name', '=', 'Equipment Company Sequence'), ('company_id', '=', result.id)])
if not sequence_id:
self.env['ir.sequence'].create({
'name': 'Equipment Company Sequence',
'prefix': result.id,
'padding': 5,
'number_increment': 1,
'company_id': result.id
})
return result

View File

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

View File

@ -0,0 +1,190 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="report_equipment_simple_label2x7">
<t t-set="qrcode_size" t-value="'height:14mm'"/>
<t t-set="table_style" t-value="'width:97mm;height:37.1mm;margin:inherit;' + table_style"/>
<td t-att-style="make_invisible and 'visibility:hidden;'" >
<div class="o_label_full" t-att-style="table_style">
<div class="o_label_name">
<strong t-field="equipment.name"/>
</div>
<div class="o_label_data">
<div class="text-center o_label_right_column">
<t t-if="equipment.qr_code">
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')" style="width: 130px;margin-top: -40px;"/>
</t>
</div>
<div class="text-left" style="line-height:normal;word-wrap: break-word;">
<span class="text-nowrap" t-field="equipment.serial_no"/>
<div class="o_label_extra_data">
<span t-field="equipment.comp_serial_no"/>
</div>
<t t-if="equipment.warranty_date">
<strong t-field="equipment.warranty_date"/>
</t>
</div>
<div class="o_label_clear"></div>
</div>
</div>
</td>
</template>
<template id="report_equipment_simple_label4x7">
<t t-set="barcode_size" t-value="'width:80px;'"/>
<t t-set="table_style" t-value="'width:47mm;height:37.1mm;margin:inherit;' + table_style"/>
<td t-att-style="make_invisible and 'visibility:hidden;'" >
<div class="o_label_full" t-att-style="table_style">
<div class="o_label_name">
<strong t-field="equipment.name"/>
</div>
<div class= "text-center o_label_right_column">
<t t-if="equipment.qr_code">
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')" style="width:95px;padding:0px;margin-top:-10px"/>
</t>
</div>
<div class="text-left o_label_left_column" style="line-height:normal;word-wrap: break-word;">
<div class="o_label_data">
<strong t-field="equipment.serial_no"/>
<span t-field="equipment.comp_serial_no"/>
<t t-if="equipment.warranty_date">
<strong t-field="equipment.warranty_date"/>
</t>
</div>
</div>
</div>
</td>
</template>
<template id="report_equipment_simple_label2x5">
<t t-set="barcode_size" t-value="'width:38mm;height:14mm;margin-top:50px;'"/>
<t t-set="table_style"
t-value="'width:97mm;height:50mm;font-size: 12px;border: 2px solid black;table-layout: fixed;'"/>
<td>
<table t-att-style="table_style">
<tr>
<td style="width:25%;">Name</td>
<td style="width:5%">:</td>
<td colspan="2" style="width:70%;word-wrap: break-word;">
<span t-field="equipment.name"/>
</td>
</tr>
<tr>
<td style="width:25%">Model</td>
<td style="width:5%">:</td>
<td style="width:35%;word-wrap: break-word;">
<span t-field="equipment.model"/>
</td>
<td rowspan="5" style="width:35%">
<t t-if="equipment.qr_code">
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')"
style="width:30mm;height:30mm;"/>
</t>
</td>
</tr>
<tr>
<td style="width:25%">Mfg Serial</td>
<td style="width:5%">:</td>
<td style="width:35%;font-size:10px;word-wrap: break-word;">
<span t-field="equipment.serial_no"/>
</td>
</tr>
<tr>
<td style="width:25%">Serial</td>
<td style="width:5%">:</td>
<td style="width:35%;word-wrap: break-word;">
<span t-field="equipment.comp_serial_no"/>
</td>
</tr>
<tr>
<td style="width:25%">Warranty Date</td>
<td style="width:5%">:</td>
<td style="width:35%;word-wrap: break-word;">
<span t-field="equipment.warranty_date"/>
</td>
</tr>
<tr>
<td style="width:25%"></td>
<td style="width:5%"></td>
<td style="width:35%;word-wrap: break-word;"></td>
</tr>
</table>
</td>
</template>
<template id="report_quipmentlabel">
<t t-call="web.html_container">
<t t-if="columns and rows">
<t t-if="columns == 2 and rows == 7">
<t t-set="padding_page" t-value="'padding: 14mm 3mm'"/>
<t t-set="report_to_call" t-value="'aspl_equipment_qrcode_generator.report_equipment_simple_label2x7'"/>
</t>
<t t-if="columns == 4 and rows == 7">
<t t-set="padding_page" t-value="'padding: 14mm 3mm'"/>
<t t-set="report_to_call" t-value="'aspl_equipment_qrcode_generator.report_equipment_simple_label4x7'"/>
</t>
<t t-if="columns == 2 and rows == 5">
<t t-set="padding_page" t-value="'padding: 14mm 3mm'"/>
<t t-set="report_to_call" t-value="'aspl_equipment_qrcode_generator.report_equipment_simple_label2x5'"/>
</t>
<t t-foreach="range(page_numbers)" t-as="page">
<div class="o_label_sheet" t-att-style="padding_page">
<table class="my-0 table table-sm table-borderless">
<t t-foreach="range(rows)" t-as="row">
<tr>
<t t-foreach="range(columns)" t-as="column">
<t t-if="equipment_data">
<t t-set="current_data" t-value="equipment_data.popitem()"/>
<t t-set="equipment" t-value="current_data[0]"/>
<t t-set="table_style" t-value="'border: 1px solid black'"/>
<t t-call="{{report_to_call}}"/>
</t>
</t>
</tr>
</t>
</table>
</div>
</t>
</t>
</t>
</template>
<template id="maintenance_quip">
<t t-call="web.basic_layout">
<div class="page">
<t t-call="aspl_equipment_qrcode_generator.report_quipmentlabel">
<t t-set="products" t-value="products"/>
</t>
</div>
</t>
</template>
<record id="paperformat_label_sheet_qrcode" model="report.paperformat">
<field name="name">A4 Label Sheet</field>
<field name="default" eval="True"/>
<field name="format">A4</field>
<field name="page_height">0</field>
<field name="page_width">0</field>
<field name="orientation">Portrait</field>
<field name="margin_top">0</field>
<field name="margin_bottom">0</field>
<field name="margin_left">0</field>
<field name="margin_right">0</field>
<field name="disable_shrinking" eval="True"/>
<field name="dpi">96</field>
</record>
<record id="report_equipment_label" model="ir.actions.report">
<field name="name">Equipment QR-code (PDF)</field>
<field name="model">maintenance.equipment</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">aspl_equipment_qrcode_generator.maintenance_quip</field>
<field name="report_file">aspl_equipment_qrcode_generator.maintenance_quip</field>
<field name="paperformat_id" ref="aspl_equipment_qrcode_generator.paperformat_label_sheet_qrcode"/>
<field name="print_report_name">'Products Labels - %s' % (object.name)</field>
<field name="binding_model_id" eval="False"/>
<field name="binding_type">report</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ReportProductTemplateLabel(models.AbstractModel):
_name = 'report.aspl_equipment_qrcode_generator.maintenance_quip'
_description = 'Equipment QR-code Report'
def _get_report_values(self, docids, data=None):
"""
QR-Code-Generierung erfolgt im Wizard (equipment_label_layout.py).
Dieser Report rendert nur die bereits generierten QR-Codes.
"""
if not data:
return {}
equipment_label_layout_id = self.env['equipment.label.layout'].browse(data.get('equipment_label_layout_id'))
equipment_dict = {}
for equipment in equipment_label_layout_id.equipment_ids:
equipment_dict[equipment] = 1
page_numbers = (len(equipment_label_layout_id.equipment_ids) - 1) // (
equipment_label_layout_id.rows * equipment_label_layout_id.columns
) + 1
return {
'rows': equipment_label_layout_id.rows,
'columns': equipment_label_layout_id.columns,
'page_numbers': page_numbers,
'equipment_data': equipment_dict
}

View File

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_equipment_label_layout,access.equipment_label_layout,model_equipment_label_layout,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_equipment_label_layout access.equipment_label_layout model_equipment_label_layout 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

View File

@ -0,0 +1,165 @@
<section class="oe_container" style="border-left:1px solid grey;border-right :1px solid grey;font-family: Urbanist ,'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;" >
<div class="oe_row oe_spaced">
<div style="display:flex;margin-top:5rem;padding : 0rem 5rem;">
<div><img src="./images/logo.png" style="width:200px;"/></div>
<div style="width: 100%;text-align: end;"><a href="https://hrms.aspiresoftserv.com/contactus" class="btn" target="_blank" style="background-color:#011D45;color:white;border-radius:5px;"><i class="fa fa-envelope" style="margin-right:0.5rem"></i>Request A Demo</a></div>
</div>
</div>
<div class="oe_row oe_spaced" style="margin-top:5rem;text-align:center;width:100%;">
<h3 style="color:#011D45;">Euipment Qr-code</h3>
<h6 style="color:#011D45;width: 50%;margin: auto;text-align: left;">
The Equipment Management Module streamlines the intricate process of equipment management by generating unique QR codes for each asset. Scanning these QR codes instantly provides comprehensive details about the equipment. Additionally, the QR code features a direct link to the equipment's profile in Odoo, facilitating seamless management and updates.</h6>
<br/>
<h4 style="color:#011D45;width: 50%;margin: auto;text-align: left;margin-bottom:2px;">Prerequisite:-</h4>
<h6 style="color:#011D45;width: 50%;margin: auto;text-align: left;">
pip install qrcode==6.1
</h6>
</div>
<div class="oe_row oe_spaced" style="display:flex;margin-top:3rem;">
<div style="width:750px;margin: auto;">
<img src="./banner.gif" style="width:100%">
</div>
</div>
<div class="oe_row oe_spaced" style="display:flex;">
<h3 style="color:#011D45;margin: auto;">Screenshots</h3>
</div>
<div class="oe_row oe_spaced" style="display:flex;">
<div style="margin: auto;">
<div style="width:800px;padding: 2rem;">
<div style="width:100%;text-align:left;">
<h4 style="color:#011D45;">Sequence Configuration</h4>
<h6 style="color:#011D45;">Once you install the module, It will automatically create maintenance sequence for available company.</h6>
</div>
<div style="width:80%;margin:auto;margin-top: 1rem;">
<img src="./screenshots/1_sequence.png" style="width:100%;">
</div>
</div>
<div style="width:800px;background-color:#F4F6F6;padding: 2rem;">
<div style="width:100%;text-align:left;">
<h4 style="color:#011D45;">Serial number Generation</h4>
<h6 style=" color:#011D45;">In the equipment, you'll find a "Generate Serial Number" button. When you click this button, it automatically creates a serial number based on the company's predefined sequence, which is set up when the module is installed (check above screenshot related to sequence configuration).
</h6>
</div>
<div style="width:80%;margin:auto;margin-top: 1rem;">
<img src="./screenshots/2_equipment_screen.png" style="width:100%">
</div>
</div>
<div style="width:800px;padding: 2rem;">
<div style="width:100%;text-align:left;">
<h4 style="color:#011D45;">Print QR-Code View</h4>
<h6 style="color:#011D45;">In the equipment tree view, When you select an item, It will display a "QR Code" button.</h6>
</div>
<div style="width:80%;margin:auto;margin-top: 1rem;">
<img src="./screenshots/3_equipment_tree.png" style="width:100%;">
</div>
</div>
<div style="width:800px;background-color:#F4F6F6;padding: 2rem;">
<div style="width:100%;text-align:left;">
<h4 style="color:#011D45;">QR Code Size Options</h4>
<h6 style="color:#011D45;">The module will display all available options for QR code sizes, allowing you to choose the appropriate dimensions for generating your QR codes.</h6>
</div>
<div style="width:80%;margin:auto;margin-top: 1rem;">
<img src="./screenshots/4_qrcode_size_selection.png" style="width:100%;">
</div>
</div>
</div>
</div>
<div class="oe_row oe_spaced" style="display:flex;margin-top:3rem;">
<h3 style="color:#011D45;margin: auto;">Suggested Apps</h3>
</div>
<div id="slides" class="row carousel slide mt64 mb32" style="width:800px;margin:auto;" data-ride="carousel">
<div class="carousel-inner">
<div class="carousel-item active" style="min-height: 0px;">
<div class="row no-lg-gutters">
<div class="col-6 col-sm-3 text-center">
<a class="d-block"
href="https://apps.odoo.com/apps/modules/15.0/aspl_project_timesheet_activity/"
style="text-decoration: none;" target="_blank">
<img alt="Project Timesheet Activity" src="./images/aspl_project_timesheet_activity.png" style="width:60%;">
<div class="card-body text-center p-0 mt-3 mb-3 mb-lg-0">
<h6 class="card-text m-0" style="font-size: 16px; color:#011D45;font-weight: 600">
Project Timesheet<br>Activity</h6>
</div>
</a>
</div>
<div class="col-6 col-sm-3 text-center">
<a class="d-block"
href="https://apps.odoo.com/apps/modules/15.0/aspl_save_view_to_favourite/"
style="text-decoration: none;" target="_blank">
<img alt="Project Timesheet Activity" src="./images/aspl_save_view_to_favourite.png" style="width:60%;">
<div class="card-body text-center p-0 mt-3 mb-3 mb-lg-0">
<h6 class="card-text m-0" style="font-size: 16px; color:#011D45;font-weight: 600">
Project Timesheet<br>Activity</h6>
</div>
</a>
</div>
<div class="col-6 col-sm-3 text-center">
<a class="d-block"
href="https://apps.odoo.com/apps/modules/15.0/aspl_select_multi_company/"
style="text-decoration: none;" target="_blank">
<img alt="Select Multi Company" src="./images/aspl_select_multi_company.png" style="width:60%;">
<div class="card-body text-center p-0 mt-3 mb-3 mb-lg-0">
<h6 class="card-text m-0" style="font-size: 16px; color:#011D45;font-weight: 600">
Select Multi<br>Company</h6>
</div>
</a>
</div>
<div class="col-6 col-sm-3 text-center">
<a class="d-block"
href="https://apps.odoo.com/apps/modules/15.0/aspl_project_task_milestone/"
style="text-decoration: none;" target="_blank">
<img alt="Project Task Milestone" src="./images/project_task_milestone.png" style="width:60%;">
<div class="card-body text-center p-0 mt-3 mb-3 mb-lg-0">
<h6 class="card-text m-0" style="font-size: 16px;color:#011D45; font-weight: 600">
Project Task <br>Milestone</h6>
</div>
</a>
</div>
<div class="col-6 col-sm-3 text-center">
<a class="d-block"
href="https://apps.odoo.com/apps/modules/15.0/aspl_equipment_qrcode_generator/"
style="text-decoration: none;" target="_blank">
<img alt="Qr Code" src="./icon.png" style="width:60%;">
<div class="card-body text-center p-0 mt-3 mb-3 mb-lg-0">
<h6 class="card-text m-0" style="font-size: 16px;color:#011D45; font-weight: 600">
Qr Code</h6>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
<div class="oe_row oe_spaced" style="display:flex;margin-top:1rem;">
<div style="width:750px;margin: auto;">
<img src="./images/service_banner.png" style="width:100%">
</div>
</div>
<div class="oe_row oe_spaced" style="display:flex;margin-top:4rem;">
<div style="width:800px;height:140px;margin: auto;">
<img src="./images/bg_image.png" width="100%;">
<div style="margin-left: 2rem;margin-top:-110px;display: flex;">
<div>
<h5 style="color:white;">Free Support</h5>
<p style="color:white;">We will provide free support for any issues,queries and bug fixing<br/>upto
90 days from the date of purchase of this application.</p>
</div>
<div style="margin-left: auto;margin-right: 2rem;margin-top: 2rem;">
<div style="background-color:white;color:#011D45;border-radius:5px;padding: 4px;width:205px;text-align:center;"><a
href="https://hrms.aspiresoftserv.com/contactus" style="text-decoration:none;color:#011D45;"
target="_blank">Request Free Support</a></div>
</div>
</div>
</div>
</div>
<div class="oe_row oe_spaced" style="display:flex;margin-top:-35px;">
<div style="width:600px;display:flex;margin: auto;">
<div style="display: flex;"><i class="fa fa-envelope" style="margin-right:0.4rem;margin-top:0.2rem;color:#D81A58;"></i><a href="mailto:sales@aspiresoftserv.com" style="text-decoration:none;color:#D81A58;">sales@aspiresoftserv.com</a></div>
<div style="display: flex;margin: 0 3rem;"><i class="fa fa-skype" style="margin-right:0.4rem;color:#D81A58;margin-top:0.2rem;"></i><a href="skype:aspire.software?chat" style="text-decoration:none;color:#D81A58;">aspire.software</a></div>
<div style="display: flex;"><i class="fa fa-phone" style="margin-right:0.4rem;color:#D81A58;margin-top:0.2rem;"></i><span style="text-decoration:none;color:#D81A58;">+916351895006</span></div>
</div>
</div>
</section>

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_extend_equipment_tree" model="ir.ui.view">
<field name="name">maintenance.equipment.tree.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="priority" eval="16"/>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<header>
<button string="Print QR-code" type="object" name="action_print_qrcode_layout"/>
</header>
</xpath>
</field>
</record>
<record id="view_extend_equipment_form" model="ir.ui.view">
<field name="name">maintenance.equipment.form.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="priority" eval="16"/>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='product_information']" position="inside">
<group>
<field name="comp_serial_no"/>
</group>
</xpath>
<xpath expr="//sheet" position="before">
<header>
<button string="Generate Serial Number" type="object" name="generate_serial_no" class="oe_highlight"/>
</header>
</xpath>
</field>
</record>
<record id="generate_serial_no_action" model="ir.actions.server">
<field name="name">Generate Serial Number</field>
<field name="model_id" ref="model_maintenance_equipment"/>
<field name="binding_model_id" ref="model_maintenance_equipment"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">action = records.generate_serial_no()</field>
</record>
<record id="generate_qrcode_no_action" model="ir.actions.server">
<field name="name">Print QR-Code</field>
<field name="model_id" ref="model_maintenance_equipment"/>
<field name="binding_model_id" ref="model_maintenance_equipment"/>
<field name="binding_view_types">form</field>
<field name="state">code</field>
<field name="code">action = records.action_print_qrcode_layout()</field>
</record>
</odoo>

View File

@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import equipment_label_layout

View File

@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
class EquipmentLabelLayout(models.TransientModel):
_name = 'equipment.label.layout'
_description = 'Choose the sheet layout to print the labels'
print_format = fields.Selection([
('2x5', '2 x 5'),
('2x7', '2 x 7'),
('4x7', '4 x 7')], string="Format", default='2x5', required=True)
equipment_ids = fields.Many2many('maintenance.equipment')
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:
if 'x' in wizard.print_format:
columns, rows = wizard.print_format.split('x')[:2]
wizard.columns = int(columns)
wizard.rows = int(rows)
else:
wizard.columns, wizard.rows = 1, 1
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
}
# 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

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="equipment_label_layout_form" model="ir.ui.view">
<field name="name">equipment.label.layout.form</field>
<field name="model">equipment.label.layout</field>
<field name="mode">primary</field>
<field name="arch" type="xml">
<form>
<group>
<field name="equipment_ids" invisible="1"/>
<group>
<field name="print_format" widget="radio"/>
</group>
</group>
<footer>
<button name="process_label" string="Confirm" type="object" class="btn-primary"/>
<button string="Discard" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_open_label_layout_equipment" model="ir.actions.act_window">
<field name="name">Choose Labels Layout</field>
<field name="res_model">equipment.label.layout</field>
<field name="view_ids"
eval="[(5, 0, 0),
(0, 0, {'view_mode': 'form', 'view_id': ref('equipment_label_layout_form')})]" />
<field name="target">new</field>
</record>
</odoo>

View File

@ -1,51 +0,0 @@
id,name,street,zip,city,phone,email,company_type,customer_rank,supplier_rank
res_partner_demo_1, AAAA Max Mustermann,Musterstraße 1,,,,,person,15,0
res_partner_demo_2, Benjamin Winter,,,,,,person,1,0
res_partner_demo_3, Martin Berthelon,Fabrikstr. 3,73728,Esslingen,,martin.berthelon@hotmail.fr,person,15,0
res_partner_demo_4,Aaron Christ,Hohewartstraße 46,70469,Stuttgart,,christ.aaron@web.de,person,14,0
res_partner_demo_5,Aaron Dörr,Riegeläckerstr. 60,71229,Leonberg,,aaron_doerr@web.de,person,33,0
res_partner_demo_6,Aaron Gale,Chopinstr. 20,70195,Stuttgart,015172165290,aarongale1@live.com,person,4,0
res_partner_demo_7,Aaron Zimmermann,Heinrichstr. 15,38106 ,Braunschweig,016091647469,,person,1,0
res_partner_demo_8,Abalrahman Alsadi,Bachstr. 29,70563,Stuttgart,,abdulrahman.m.saadi@gmail.com,person,1,0
res_partner_demo_9,Abdullah Zengin,Engelbertstr. 124,70499,Stuttgart,,,person,3,0
res_partner_demo_10,Abdussamed Korkmaz,Bertha-von-Suttner-Straße 1,74366,Kirchheim Am Neckar,,korkmaz.abdussamed@gmail.com,person,1,0
res_partner_demo_11,Achim Brendle,Oberwiesenstraße 45,70619,Stuttgart,7114797505,achim.brendle@web.de,person,2,0
res_partner_demo_12,Achim Jatkowski,Hummelstr. 38,70569,Stuttgart,017621512316,achim.jatkowski@gmail.com,person,1,0
res_partner_demo_13,Achim Jung,Kurt Tucholsky Str. 6,71254,Ditzingen,07156174013,acjung@web.de,person,1,0
res_partner_demo_14,Achim Kelbel,Vivaldiweg 6,70195,Stuttgart,,a.kelbel@t-online.de,person,2,0
res_partner_demo_15,Achim Kramer,Reinsburger 172,70197,Stuttgart,,achim@zibra.de,person,1,0
res_partner_demo_16,Adalbert Zeisl,Bachstr. 20,71364,Winnenden,07195-2092884,betz1000@gmx.de,person,2,0
res_partner_demo_17,Adalina Schäfer,Sancenbacherstr. 26,74538,Rosengarten,015778855550,lina_max_schaefer@gmx.de,person,1,0
res_partner_demo_18,Adam Riegel,Marabustr. 35 / 84,70378,Stuttgart,0711 532082,,person,1,0
res_partner_demo_19,Adam Swais,Obertürkheimerstr. 54,73733,Esslingen,,adamswais@web.de,person,1,0
res_partner_demo_20,Adela Spulber,Obere Bismarck Str. 97,70197,Stuttgart,,,person,1,0
res_partner_demo_21,Adem Uzun,Liesel-Bach-Str. 54,71034,Böblingen,015251690873,adem.uzun2@gmail.com,person,1,0
res_partner_demo_22,Adnan Djekic,Vesoulerstr. 33,70839,Gerlingen,01724227468,adnandjekic@alice-dsl.net,person,1,0
res_partner_demo_23,Adrian Berres,Bärgstadter Str. 90,63928,Gehenbühl,,a.berres@gmx.de,person,1,0
res_partner_demo_24,Adrian Lanksweirt,Heidestraße 6,70469,Stuttgart,,adrian.lanksweirt@gmail.com,person,1,0
res_partner_demo_25,Adrian Popov,Hallerstr. 42,90419,Nürnberg,+4915114305751,adrinuernberg@gmail.com,person,2,0
res_partner_demo_26,Agnes Krettek,Seyfferstr. 62,70187,Stuttgart,,agneskrettek@gmail.com,person,1,0
res_partner_demo_27,Ahmad Taijan,Rümelinstr 69,70191,Stuttgart,,,person,2,0
res_partner_demo_28,Aileen Becker,Eichendorffstr. 4,73630,Remshalden,015780645637,aileen.becker@gmx.de,person,87,0
res_partner_demo_29,Ailey Simpson,Eierstraße 44 A,70199,Stuttgart,,aileywsimpson@gmail.com,person,1,0
res_partner_demo_30,Akira Mitsu,Fritz-Ulrich-Weg 5,70567,Stuttgart,,mitsuakira0914@gmail.com,person,5,0
res_partner_demo_31,Aksel Özdemir,Rotebühlstraße 53,70178,Stuttgart,,aksel.oezdemir@gmx.de,person,2,0
res_partner_demo_32,Albert Ebenbichler,Am Backhaus 9,73666,Boltmannsweiler,01726101655,info@albert-ebenbichler.com,person,1,0
res_partner_demo_33,Albert Kaupp,Waldäckerstr. 10,70435,Stuttgart,0711 8263232,albert.kaupp@online.de,person,2,0
res_partner_demo_34,Albrecht Barth,Klopstockstr. 39,70193,Stuttgart,,albrecht.barth@web.de,person,3,0
res_partner_demo_35,Albrecht Schlayer,Im Netzbrunnen 17,70825,K-Münchingen,,aws1308@gmail.com,person,1,0
res_partner_demo_36,Alec Dobler,Kräherwald 251,70193,Stuttgart,,,person,1,0
res_partner_demo_37,Alejandro Cano Perez,Burgstallstraße 66,70199,Stuttgart,,cano.perez@gmx.de,person,2,0
res_partner_demo_38,Alejandro Rodriguez,Im Hirschwinkel 1,76297,Stutensee,015771409317,ralexei95@yahoo.de,person,1,0
res_partner_demo_39,Alejandro Zarza Aguado,Reinsburgstr. 152,70197,Stuttgart,017628401435,11alex96@gmail.com,person,1,0
res_partner_demo_40,Aleksandar Vasić,Lothringer Str. 5,70435,Stuttgart,,aleksvasic@web.de,person,3,0
res_partner_demo_41,Alen Minasyan,Kastanienallee 41/1,71638,Ludwigsburg,,bidilik@gmx.de,person,1,0
res_partner_demo_42,Alex Olenberg,Theodor-Rottschildstr. 25,73760,Stuttgart,,,person,26,0
res_partner_demo_43,Alex Schaut,Braunenbergweg 9,70806,Kornwestheim,07154 16530,aschaut@gmx.de,person,3,0
res_partner_demo_44,Alexander Adloff,Charlottenstraße 2,74074,Heilbronn,,alexadloff@gmx.de,person,3,0
res_partner_demo_45,Alexander Bauer,Im Himmel 20,70569,Stuttgart,071172237601,ab.312@icloud.com,person,1,0
res_partner_demo_46,Alexander Blendl,Neckarstr. 8,70736,Fellbach,,blendl.alex@gmail.com,person,4,0
res_partner_demo_47,Alexander Borshov,Schellingstraße 24,71277,Rutesheim,,aborshov@gmail.com,person,1,0
res_partner_demo_48,Alexander Bosch,Osterwiesenstr. 37,70794,Filderstadt,,bosch-alexander@web.de,person,1,0
res_partner_demo_49,Alexander Braig,Holzgrund Str. 25,70806,Kornwestheim,,a.braig84@gmx.de,person,17,0
res_partner_demo_50,Alexander Carolus,Kornbergstr. 23,70176,Stuttgart,,alexander.carolus,person,1,0
1 id name street zip city phone email company_type customer_rank supplier_rank
2 res_partner_demo_1 AAAA Max Mustermann Musterstraße 1 person 15 0
3 res_partner_demo_2 Benjamin Winter person 1 0
4 res_partner_demo_3 Martin Berthelon Fabrikstr. 3 73728 Esslingen martin.berthelon@hotmail.fr person 15 0
5 res_partner_demo_4 Aaron Christ Hohewartstraße 46 70469 Stuttgart christ.aaron@web.de person 14 0
6 res_partner_demo_5 Aaron Dörr Riegeläckerstr. 60 71229 Leonberg aaron_doerr@web.de person 33 0
7 res_partner_demo_6 Aaron Gale Chopinstr. 20 70195 Stuttgart 015172165290 aarongale1@live.com person 4 0
8 res_partner_demo_7 Aaron Zimmermann Heinrichstr. 15 38106 Braunschweig 016091647469 person 1 0
9 res_partner_demo_8 Abalrahman Alsadi Bachstr. 29 70563 Stuttgart abdulrahman.m.saadi@gmail.com person 1 0
10 res_partner_demo_9 Abdullah Zengin Engelbertstr. 124 70499 Stuttgart person 3 0
11 res_partner_demo_10 Abdussamed Korkmaz Bertha-von-Suttner-Straße 1 74366 Kirchheim Am Neckar korkmaz.abdussamed@gmail.com person 1 0
12 res_partner_demo_11 Achim Brendle Oberwiesenstraße 45 70619 Stuttgart 7114797505 achim.brendle@web.de person 2 0
13 res_partner_demo_12 Achim Jatkowski Hummelstr. 38 70569 Stuttgart 017621512316 achim.jatkowski@gmail.com person 1 0
14 res_partner_demo_13 Achim Jung Kurt Tucholsky Str. 6 71254 Ditzingen 07156174013 acjung@web.de person 1 0
15 res_partner_demo_14 Achim Kelbel Vivaldiweg 6 70195 Stuttgart a.kelbel@t-online.de person 2 0
16 res_partner_demo_15 Achim Kramer Reinsburger 172 70197 Stuttgart achim@zibra.de person 1 0
17 res_partner_demo_16 Adalbert Zeisl Bachstr. 20 71364 Winnenden 07195-2092884 betz1000@gmx.de person 2 0
18 res_partner_demo_17 Adalina Schäfer Sancenbacherstr. 26 74538 Rosengarten 015778855550 lina_max_schaefer@gmx.de person 1 0
19 res_partner_demo_18 Adam Riegel Marabustr. 35 / 84 70378 Stuttgart 0711 532082 person 1 0
20 res_partner_demo_19 Adam Swais Obertürkheimerstr. 54 73733 Esslingen adamswais@web.de person 1 0
21 res_partner_demo_20 Adela Spulber Obere Bismarck Str. 97 70197 Stuttgart person 1 0
22 res_partner_demo_21 Adem Uzun Liesel-Bach-Str. 54 71034 Böblingen 015251690873 adem.uzun2@gmail.com person 1 0
23 res_partner_demo_22 Adnan Djekic Vesoulerstr. 33 70839 Gerlingen 01724227468 adnandjekic@alice-dsl.net person 1 0
24 res_partner_demo_23 Adrian Berres Bärgstadter Str. 90 63928 Gehenbühl a.berres@gmx.de person 1 0
25 res_partner_demo_24 Adrian Lanksweirt Heidestraße 6 70469 Stuttgart adrian.lanksweirt@gmail.com person 1 0
26 res_partner_demo_25 Adrian Popov Hallerstr. 42 90419 Nürnberg +4915114305751 adrinuernberg@gmail.com person 2 0
27 res_partner_demo_26 Agnes Krettek Seyfferstr. 62 70187 Stuttgart agneskrettek@gmail.com person 1 0
28 res_partner_demo_27 Ahmad Taijan Rümelinstr 69 70191 Stuttgart person 2 0
29 res_partner_demo_28 Aileen Becker Eichendorffstr. 4 73630 Remshalden 015780645637 aileen.becker@gmx.de person 87 0
30 res_partner_demo_29 Ailey Simpson Eierstraße 44 A 70199 Stuttgart aileywsimpson@gmail.com person 1 0
31 res_partner_demo_30 Akira Mitsu Fritz-Ulrich-Weg 5 70567 Stuttgart mitsuakira0914@gmail.com person 5 0
32 res_partner_demo_31 Aksel Özdemir Rotebühlstraße 53 70178 Stuttgart aksel.oezdemir@gmx.de person 2 0
33 res_partner_demo_32 Albert Ebenbichler Am Backhaus 9 73666 Boltmannsweiler 01726101655 info@albert-ebenbichler.com person 1 0
34 res_partner_demo_33 Albert Kaupp Waldäckerstr. 10 70435 Stuttgart 0711 8263232 albert.kaupp@online.de person 2 0
35 res_partner_demo_34 Albrecht Barth Klopstockstr. 39 70193 Stuttgart albrecht.barth@web.de person 3 0
36 res_partner_demo_35 Albrecht Schlayer Im Netzbrunnen 17 70825 K-Münchingen aws1308@gmail.com person 1 0
37 res_partner_demo_36 Alec Dobler Kräherwald 251 70193 Stuttgart person 1 0
38 res_partner_demo_37 Alejandro Cano Perez Burgstallstraße 66 70199 Stuttgart cano.perez@gmx.de person 2 0
39 res_partner_demo_38 Alejandro Rodriguez Im Hirschwinkel 1 76297 Stutensee 015771409317 ralexei95@yahoo.de person 1 0
40 res_partner_demo_39 Alejandro Zarza Aguado Reinsburgstr. 152 70197 Stuttgart 017628401435 11alex96@gmail.com person 1 0
41 res_partner_demo_40 Aleksandar Vasić Lothringer Str. 5 70435 Stuttgart aleksvasic@web.de person 3 0
42 res_partner_demo_41 Alen Minasyan Kastanienallee 41/1 71638 Ludwigsburg bidilik@gmx.de person 1 0
43 res_partner_demo_42 Alex Olenberg Theodor-Rottschildstr. 25 73760 Stuttgart person 26 0
44 res_partner_demo_43 Alex Schaut Braunenbergweg 9 70806 Kornwestheim 07154 16530 aschaut@gmx.de person 3 0
45 res_partner_demo_44 Alexander Adloff Charlottenstraße 2 74074 Heilbronn alexadloff@gmx.de person 3 0
46 res_partner_demo_45 Alexander Bauer Im Himmel 20 70569 Stuttgart 071172237601 ab.312@icloud.com person 1 0
47 res_partner_demo_46 Alexander Blendl Neckarstr. 8 70736 Fellbach blendl.alex@gmail.com person 4 0
48 res_partner_demo_47 Alexander Borshov Schellingstraße 24 71277 Rutesheim aborshov@gmail.com person 1 0
49 res_partner_demo_48 Alexander Bosch Osterwiesenstr. 37 70794 Filderstadt bosch-alexander@web.de person 1 0
50 res_partner_demo_49 Alexander Braig Holzgrund Str. 25 70806 Kornwestheim a.braig84@gmx.de person 17 0
51 res_partner_demo_50 Alexander Carolus Kornbergstr. 23 70176 Stuttgart alexander.carolus person 1 0

View File

@ -1,2 +0,0 @@
/opt/odoo/odoo/odoo-bin shell -d hobbyhimmel < /home/odoo/custom_addons/open_workshop/demo/export_partner.py

View File

@ -1,38 +0,0 @@
import csv
import random
# Beispielsweise 50 Kunden mit Namen und E-Mail
partners = env['res.partner'].search(
[('customer_rank', '>', 0), ('is_company', '=', False)],
limit=50
)
with open('/home/odoo/custom_addons/open_workshop/demo/demo_partners.csv', 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow([
'id',
'name',
'street',
'zip',
'city',
'phone',
'email',
'company_type',
'customer_rank',
'supplier_rank'
])
for idx, partner in enumerate(partners, start=1):
partner_id = f'res_partner_demo_{idx}'
writer.writerow([
partner_id,
partner.name or '',
partner.street or '',
partner.zip or '',
partner.city or '',
partner.phone or '',
partner.email or '',
partner.company_type or 'person',
partner.customer_rank,
partner.supplier_rank,
])

View File

@ -1,31 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import SUPERUSER_ID
from odoo.api import Environment
import logging
_logger = logging.getLogger(__name__)
MISSING_PARTNERS = [
6534, 1594, 4700, 6557, 5392, 4960, 5226, 6535, 4666
]
def insert_missing_partners(cr, registry):
env = Environment(cr, SUPERUSER_ID, {})
for partner_id in MISSING_PARTNERS:
cr.execute("""
INSERT INTO res_partner (
id, name, customer_rank, create_uid, create_date, write_uid, write_date
)
VALUES (%s, %s, 1, %s, now(), %s, now())
ON CONFLICT (id) DO NOTHING;
""", (partner_id, f"Fehlender Partner {partner_id}", SUPERUSER_ID, SUPERUSER_ID))
cr.execute("SELECT setval('res_partner_id_seq', (SELECT MAX(id) FROM res_partner));")
_logger.info(f"[OWS Repair] {len(MISSING_PARTNERS)} fehlende Partner hinzugefügt.")
cr.commit()
# Automatischer Start in odoo-bin shell
if 'env' in globals():
insert_missing_partners(env.cr, env.registry)

View File

@ -1,177 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<record id="cat_einweisungen" model="product.category">
<field name="name">Einweisungen</field>
</record>
<record id="cat_maschinennutzung" model="product.category">
<field name="name">Maschinennutzung</field>
</record>
<record id="prod_3d_druck_30_minuten" model="product.product">
<field name="name">3D Druck (30 Minuten)</field>
<field name="default_code" />
<field name="list_price">0.25</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_bandschleifer_1_minute" model="product.product">
<field name="name">Bandschleifer (1 Minute)</field>
<field name="default_code" />
<field name="list_price">0.1</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_bandsäge_1_minute" model="product.product">
<field name="name">Bandsäge (1 Minute)</field>
<field name="default_code" />
<field name="list_price">0.1</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_cnc_fräse_1_minute" model="product.product">
<field name="name">CNC Fräse (1 Minute)</field>
<field name="default_code" />
<field name="list_price">0.1</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_cnc_sicherheitseinweisung" model="product.product">
<field name="name">CNC Sicherheitseinweisung</field>
<field name="default_code" />
<field name="list_price">25.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_einweisungen" name="categ_id" />
</record>
<record id="prod_drehbank_1_minute" model="product.product">
<field name="name">Drehbank (1 Minute)</field>
<field name="default_code" />
<field name="list_price">0.1</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_einweisung_3d_drucker_delta" model="product.product">
<field name="name">Einweisung 3D Drucker Delta</field>
<field name="default_code" />
<field name="list_price">15.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_einweisungen" name="categ_id" />
</record>
<record id="prod_einweisung_3d_drucker_prusa" model="product.product">
<field name="name">Einweisung 3D Drucker Prusa</field>
<field name="default_code" />
<field name="list_price">20.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_einweisungen" name="categ_id" />
</record>
<record id="prod_einweisung_bandsäge" model="product.product">
<field name="name">Einweisung Bandsäge</field>
<field name="default_code" />
<field name="list_price">15.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_einweisungen" name="categ_id" />
</record>
<record id="prod_einweisung_drehbank" model="product.product">
<field name="name">Einweisung Drehbank</field>
<field name="default_code" />
<field name="list_price">20.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_einweisungen" name="categ_id" />
</record>
<record id="prod_einweisung_fks" model="product.product">
<field name="name">Einweisung FKS</field>
<field name="default_code" />
<field name="list_price">20.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_einweisungen" name="categ_id" />
</record>
<record id="prod_einweisung_hobel" model="product.product">
<field name="name">Einweisung Hobel</field>
<field name="default_code" />
<field name="list_price">15.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_einweisungen" name="categ_id" />
</record>
<record id="prod_einweisung_laser" model="product.product">
<field name="name">Einweisung Laser</field>
<field name="default_code" />
<field name="list_price">15.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_einweisungen" name="categ_id" />
</record>
<record id="prod_einweisung_metallfräse" model="product.product">
<field name="name">Einweisung Metallfräse</field>
<field name="default_code" />
<field name="list_price">20.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_einweisungen" name="categ_id" />
</record>
<record id="prod_einweisung_schweißgerät" model="product.product">
<field name="name">Einweisung Schweißgerät</field>
<field name="default_code" />
<field name="list_price">10.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_einweisungen" name="categ_id" />
</record>
<record id="prod_einweisung_in_maschinelle_holzverbindungen" model="product.product">
<field name="name">Einweisung in maschinelle Holzverbindungen</field>
<field name="default_code" />
<field name="list_price">15.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_einweisungen" name="categ_id" />
</record>
<record id="prod_formatkreissäge_1_minute" model="product.product">
<field name="name">Formatkreissäge (1 Minute)</field>
<field name="default_code" />
<field name="list_price">0.1</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_fräse___deckel_1_minute" model="product.product">
<field name="name">Fräse - Deckel (1 Minute)</field>
<field name="default_code" />
<field name="list_price">0.1</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_hobel_1_minute" model="product.product">
<field name="name">Hobel (1 Minute)</field>
<field name="default_code" />
<field name="list_price">0.1</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_laser_aktivminute" model="product.product">
<field name="name">Laser (Aktivminute)</field>
<field name="default_code" />
<field name="list_price">0.7000000000000001</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_sandstrahlbox_1_minute" model="product.product">
<field name="name">Sandstrahlbox (1 Minute)</field>
<field name="default_code" />
<field name="list_price">0.2</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_schweißgerät_1_minute" model="product.product">
<field name="name">Schweißgerät (1 Minute)</field>
<field name="default_code" />
<field name="list_price">0.2</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_schweißkabine_eigenes_schweißgerät___1_minute" model="product.product">
<field name="name">Schweißkabine (eigenes Schweißgerät - 1 Minute)</field>
<field name="default_code" />
<field name="list_price">0.1</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
<record id="prod_sonstige_dienstleistungen_nutzung" model="product.product">
<field name="name">Sonstige Dienstleistungen/Nutzung</field>
<field name="default_code" />
<field name="list_price">1.0</field>
<field name="available_in_pos">True</field>
<field ref="cat_maschinennutzung" name="categ_id" />
</record>
</odoo>

View File

@ -1,2 +0,0 @@
/opt/odoo/odoo/odoo-bin shell -d hobbyhimmel < /home/odoo/custom_addons/open_workshop/data/export_products_and_categories.py

View File

@ -1,20 +0,0 @@
# /opt/odoo/odoo/odoo-bin shell -d <alte datebase> < export_categories.py
import csv
from odoo import api, SUPERUSER_ID
import os
categories = env['product.category'].search([('name', 'in', ['Einweisungen', 'Maschinennutzung'])])
file_path = os.path.join(os.getcwd(), 'product_category.csv')
with open(file_path, 'w', newline='') as csvfile:
writer = csv.writer(csvfile)
writer.writerow(['id', 'name', 'parent_id/id'])
for cat in categories:
xml_id = f"open_workshop.cat_{cat.name.lower().replace(' ', '_')}"
parent_id = cat.parent_id and f"base.{cat.parent_id.xml_id}" or ''
writer.writerow([xml_id, cat.name, parent_id])
# Aufruf in odoo shell z.B.:
# env = odoo.api.Environment(cr, SUPERUSER_ID, {})
# export_categories(env)

View File

@ -1,26 +0,0 @@
# /opt/odoo/odoo/odoo-bin shell -d <alte datebase> < export_products.py
import csv
from odoo import api, SUPERUSER_ID
import os
# Kategorien suchen
category_names = ['Einweisungen', 'Maschinennutzung']
categories = env['product.category'].search([('name', 'in', category_names)])
products = env['product.product'].search([('categ_id', 'in', categories.ids)])
file_path = os.path.join(os.getcwd(), 'product_product.csv')
with open(file_path, 'w', newline='') as csvfile:
writer = csv.writer(csvfile)
writer.writerow(['id', 'name', 'default_code', 'list_price', 'categ_id/id', 'available_in_pos'])
for prod in products:
cat_xml_id = f"open_workshop.cat_{prod.categ_id.name.lower().replace(' ', '_')}"
xml_id = f"open_workshop.prod_{prod.default_code or prod.name.lower().replace(' ', '_')}"
writer.writerow([
xml_id,
prod.name,
prod.default_code or '',
prod.list_price,
cat_xml_id,
'1' if prod.available_in_pos else '0'
])

View File

@ -1,47 +0,0 @@
# -*- coding: utf-8 -*-
# /opt/odoo/odoo/odoo-bin shell -d <alte datebase> < export_products_and_categories.py
import xml.etree.ElementTree as ET
def xml_safe_id(name):
return name.lower().replace(' ', '_').replace('/', '_').replace('(', '').replace(')', '').replace('-', '_')
def export_categories_and_products(env):
root = ET.Element("odoo")
# Export Kategorien
categories = env['product.category'].search([
('name', 'in', ['Einweisungen', 'Maschinennutzung'])
])
for cat in categories:
record = ET.SubElement(root, "record", {
"id": f"cat_{xml_safe_id(cat.name)}",
"model": "product.category"
})
ET.SubElement(record, "field", name="name").text = cat.name
if cat.parent_id:
ET.SubElement(record, "field", name="parent_id", attrib={"ref": f"cat_{xml_safe_id(cat.parent_id.name)}"})
# Export Produkte
products = env['product.product'].search([
('categ_id.name', 'in', ['Einweisungen', 'Maschinennutzung'])
])
for product in products:
record = ET.SubElement(root, "record", {
"id": f"prod_{xml_safe_id(product.name)}",
"model": "product.product"
})
ET.SubElement(record, "field", name="name").text = product.name or ''
ET.SubElement(record, "field", name="default_code").text = product.default_code or ''
ET.SubElement(record, "field", name="list_price").text = str(product.list_price or 0.0)
ET.SubElement(record, "field", name="available_in_pos").text = "True"
if product.categ_id:
ET.SubElement(record, "field", name="categ_id", attrib={"ref": f"cat_{xml_safe_id(product.categ_id.name)}"})
tree = ET.ElementTree(root)
tree.write("data_product_and_categories.xml", encoding="utf-8", xml_declaration=True)
print("✅ XML export saved to data_product_and_categories.xml")
# Automatischer Start in odoo-bin shell
if 'env' in globals():
export_categories_and_products(env)

View File

@ -1 +0,0 @@
/opt/odoo/odoo/odoo-bin -d hobbyhimmel13_dev -i open_workshop --stop-after-init

View File

@ -1,50 +0,0 @@
<odoo>
<record id="machine_prusa_training_prod_einweisung_3d_drucker_prusa" model="ows.machine.training">
<field name="machine_id" ref="machine_prusa"/>
<field name="training_id" ref="prod_einweisung_3d_drucker_prusa"/>
</record>
<record id="machine_formatkreissaege_usage_prod_formatkreissäge_1_minute" model="ows.machine.product">
<field name="machine_id" ref="machine_formatkreissaege"/>
<field name="product_id" ref="prod_formatkreissäge_1_minute"/>
</record>
<record id="machine_bandsaege_holz_usage_prod_bandsäge_1_minute" model="ows.machine.product">
<field name="machine_id" ref="machine_bandsaege_holz"/>
<field name="product_id" ref="prod_bandsäge_1_minute"/>
</record>
<record id="machine_bandsaege_holz_training_prod_einweisung_bandsäge" model="ows.machine.training">
<field name="machine_id" ref="machine_bandsaege_holz"/>
<field name="training_id" ref="prod_einweisung_bandsäge"/>
</record>
<record id="machine_kreissaege_metall_usage_prod_formatkreissäge_1_minute" model="ows.machine.product">
<field name="machine_id" ref="machine_kreissaege_metall"/>
<field name="product_id" ref="prod_formatkreissäge_1_minute"/>
</record>
<record id="machine_bandsaege_metall_usage_prod_bandsäge_1_minute" model="ows.machine.product">
<field name="machine_id" ref="machine_bandsaege_metall"/>
<field name="product_id" ref="prod_bandsäge_1_minute"/>
</record>
<record id="machine_bandsaege_metall_training_prod_einweisung_bandsäge" model="ows.machine.training">
<field name="machine_id" ref="machine_bandsaege_metall"/>
<field name="training_id" ref="prod_einweisung_bandsäge"/>
</record>
<record id="machine_drehbank_usage_prod_drehbank_1_minute" model="ows.machine.product">
<field name="machine_id" ref="machine_drehbank"/>
<field name="product_id" ref="prod_drehbank_1_minute"/>
</record>
<record id="machine_drehbank_training_prod_einweisung_drehbank" model="ows.machine.training">
<field name="machine_id" ref="machine_drehbank"/>
<field name="training_id" ref="prod_einweisung_drehbank"/>
</record>
<record id="machine_fraese_usage_prod_cnc_fräse_1_minute" model="ows.machine.product">
<field name="machine_id" ref="machine_fraese"/>
<field name="product_id" ref="prod_cnc_fräse_1_minute"/>
</record>
<record id="machine_fraese_usage_prod_fräse___deckel_1_minute" model="ows.machine.product">
<field name="machine_id" ref="machine_fraese"/>
<field name="product_id" ref="prod_fräse___deckel_1_minute"/>
</record>
<record id="machine_fraese_training_prod_einweisung_metallfräse" model="ows.machine.training">
<field name="machine_id" ref="machine_fraese"/>
<field name="training_id" ref="prod_einweisung_metallfräse"/>
</record>
</odoo>

View File

@ -1 +0,0 @@
/opt/odoo/odoo/odoo-bin -d hobbyhimmel --update=open_workshop --dev=all --stop-after-init

2
log/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

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

View File

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

View File

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

View File

@ -1,11 +1,14 @@
# Datei: controllers/pos_access.py
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, partner_id):
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

@ -15,22 +15,31 @@ class HREmployee(models.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})")
admin_employee = self.search([('user_id', '=', admin_user.id)], limit=1)
# 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': False,
'work_email': 'office@hobbyhimmel.de',
'work_phone': False,
'active': True, # Reaktivieren falls archiviert
})
_logger.info("[OWS] Admin-Angestellter wurde umbenannt.")
_logger.info(f"[OWS] Admin-Angestellter reaktiviert und umbenannt: {admin_employee.name} (ID: {admin_employee.id})")
else:
_logger.warning("[OWS] Kein Angestellter für user_admin gefunden.")
_logger.warning("[OWS] Kein Angestellter für Administrator gefunden.")
return
# Alle anderen Angestellten archivieren
other_employees = self.search([('id', '!=', admin_employee.id)])
# 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))
@ -168,24 +177,21 @@ class ResPartner(models.Model):
def _compute_machine_access_html(self):
areas = self.env['ows.machine.area'].search([], order="name")
for partner in self:
html = "<div class='tab-content'><div class='tab-pane active' id='machine_access_tab'>"
html += "<div class='o_group'>"
html = ""
for area in areas:
html += "<table class='o_group o_inner_group o_group_col_6'><tbody>"
# Bereichsüberschrift
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>
<td colspan="3" style="width: 100%;">
<div class="o_horizontal_separator">{area.name}</div>
</td>
</tr>
<tr>
<td class="o_td_label"><label class="o_form_label"></label></td>
<td class="o_td_label"><label class="o_form_label">Datum</label></td>
<td class="o_td_label"><label class="o_form_label">Gültig bis</label></td>
<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")
@ -195,25 +201,26 @@ class ResPartner(models.Model):
('partner_id', '=', partner.id),
('machine_id', '=', machine.id),
], limit=1)
icon = "<span class='fa fa-check text-success'></span>" if access else "<span class='fa fa-times text-danger'></span>"
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 class="o_td_label"><label class="o_form_label">{icon} {machine.name}</label></td>
<td class="o_td_field">{date_granted}</td>
<td class="o_td_field">{date_expiry}</td>
</tr>
<tr>
<td>{machine.name}</td>
<td>{icon}</td>
<td>{date_granted}</td>
<td>{date_expiry}</td>
</tr>
"""
html += "</tbody></table>"
html += "</tbody></table></div>"
html += "</div></div></div>"
partner.machine_access_html = html
@api.model
def migrate_existing_partners(self):
"""
@ -378,6 +385,7 @@ class OwsUser(models.Model):
('partner_unique', 'unique(partner_id)', 'Jeder Partner darf nur einen OWS-Datensatz haben.')
]
AVAILABLE_COLORS = [
('#000000', 'schwarz'),
('#ff0000', 'Rot'),
@ -389,7 +397,7 @@ AVAILABLE_COLORS = [
('#ffff00', 'Gelb'),
('#FF9800', 'Orange'),
('#795548', 'Braun'),
('#1f1f1f', 'Grau'),
('#ffffff', 'Weiss'),
]
class OwsMachineArea(models.Model):
@ -429,6 +437,8 @@ class OwsMachineArea(models.Model):
for rec in self:
rec.color_name = label_dict.get(rec.color_hex, 'Unbekannt')
class OwsMachine(models.Model):
_name = 'ows.machine'
_table = 'ows_machine'
@ -457,10 +467,10 @@ class OwsMachine(models.Model):
}
rec.category_icon = icon_map.get(rec.category, '')
description = fields.Text(string="Gerätebeschreibung", translate=True, help="Beschreibung der Maschine oder des Geräts.")
active = fields.Boolean(string="Aktive", default=True, help="Ist die Maschine oder das Gerät aktiv? Inaktive Maschinen werden nicht mehr in der POS-Ansicht angezeigt.")
area_id = fields.Many2one('ows.machine.area', string='Bereich', help="Bereich in der Werkstatt, in dem die Maschine oder das Gerät steht.")
product_ids = fields.One2many('ows.machine.product', 'machine_id', string="Nutzungsprodukte", help="Dies ist das zugehörige Produkt, falls die Maschine oder das Geräte eine zeitliche Nutzungsgebühr hat.")
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,)
@ -510,10 +520,10 @@ class OwsMachine(models.Model):
- '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")
@ -544,6 +554,7 @@ class OwsMachine(models.Model):
}
class OwsMachineAccess(models.Model):
_name = 'ows.machine.access'
_table = 'ows_machine_access'
@ -564,7 +575,7 @@ class OwsMachineAccess(models.Model):
class OwsMachineProduct(models.Model):
_name = 'ows.machine.product'
_table = 'ows_machine_product'
_description = 'OWS: Zurordnung Produkt der Nutzung zur die Maschine'
_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')
@ -572,7 +583,7 @@ class OwsMachineProduct(models.Model):
class OwsMachineTraining(models.Model):
_name = 'ows.machine.training'
_table = 'ows_machine_training'
_description = 'OWS: Zurordnung Produkt der Einweisung zur die Maschine'
_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

@ -5,18 +5,19 @@ from collections import defaultdict
import logging
_logger = logging.getLogger(__name__)
_logger.info("✅ pos_order.py geladen")
_logger.info("✅ pos_order.py geladen Test 2")
# debugpy.listen(("0.0.0.0", 5678))
# print("✅ debugpy wartet auf Verbindung (Port 5678) ...")
#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()
#debugpy.wait_for_client()
class PosOrder(models.Model):
_inherit = 'pos.order'
def _process_order(self, order, draft, existing_order):
pos_order_id = super(PosOrder, self)._process_order(order, draft, existing_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([])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@
<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">tree,form</field>
<field name="view_mode">list,form</field>
</record>
<!-- Menüpunkt unter Maschinen > Konfiguration -->
@ -15,10 +15,11 @@
<field name="name">ows.machine.area.tree</field>
<field name="model">ows.machine.area</field>
<field name="arch" type="xml">
<tree>
<list>
<field name="name"/>
<field name="color_hex" widget="color_picker"/>
</tree>
<field name="color_hex_value" string="Farbe (Hex)"/>
<field name="color_name" string="Farbname"/>
</list>
</field>
</record>
@ -30,7 +31,8 @@
<form string="Maschinenbereich">
<group>
<field name="name"/>
<field name="color_hex" widget="color_picker"/>
<field name="color_hex"/>
<field name="color_name" readonly="1"/>
</group>
</form>
</field>

View File

@ -4,10 +4,10 @@
<field name="name">ows.machine.product.tree</field>
<field name="model">ows.machine.product</field>
<field name="arch" type="xml">
<tree editable="bottom">
<list editable="bottom">
<field name="machine_id"/>
<field name="product_id" domain="[('categ_id.name', '=', 'Maschinennutzung')]"/>
</tree>
</list>
</field>
</record>
@ -16,10 +16,10 @@
<field name="name">ows.machine.training.tree</field>
<field name="model">ows.machine.training</field>
<field name="arch" type="xml">
<tree editable="bottom">
<list editable="bottom">
<field name="machine_id"/>
<field name="training_id" domain="[('categ_id.name', '=', 'Einweisungen')]"/>
</tree>
<field name="training_id" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]"/>
</list>
</field>
</record>
@ -27,7 +27,7 @@
<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">tree</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>
@ -38,7 +38,7 @@
<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">tree</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>

View File

@ -5,19 +5,20 @@
<field name="name">ows.machine.tree</field>
<field name="model">ows.machine</field>
<field name="arch" type="xml">
<tree>
<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="training_names"/>
<field name="storage_location"/>
<field name="purchase_price"/>
<field name="purchase_date"/>
<field name="purchase_date"/>
<field name="active"/>
</tree>
</list>
</field>
</record>
@ -29,15 +30,13 @@
<form string="Maschine">
<sheet>
<group>
<field name="category_icon" string="⚙" readonly="1"/>
<field name="name"/>
<field name="category"/>
<field name="category_icon" string="⚙" readonly="1"/>
<field name="code"/>
<field name="area_id"/>
</group>
<group>
<field name="description"/>
<field name="storage_location"/>
<field name="purchase_price"/>
@ -45,23 +44,23 @@
<field name="active"/>
</group>
<!-- Neue -->
<notebook>
<page string="Nutzungsprodukte">
<field name="product_ids" context="{'default_machine_id': active_id}">
<tree editable="bottom">
<field name="product_id" domain="[('categ_id.name', '=', 'Maschinennutzung')]" />
</tree>
</field>
</page>
<page string="Einweisungsprodukte">
<field name="training_ids" context="{'default_machine_id': active_id}">
<tree editable="bottom">
<field name="training_id" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]" />
</tree>
</field>
</page>
</notebook>
<!-- 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>

View File

@ -4,14 +4,14 @@
<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">tree,form</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">tree,form</field>
<field name="view_mode">list,form</field>
</record>
<!-- Menüstruktur -->
@ -59,10 +59,10 @@
<field name="name">ows.machine.product.tree</field>
<field name="model">ows.machine.product</field>
<field name="arch" type="xml">
<tree>
<list>
<field name="product_id"/>
<field name="machine_id"/>
</tree>
</list>
</field>
</record>

View File

@ -1,46 +1,62 @@
<odoo>
<!-- 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>
<!-- 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="base.view_partner_form"/>
<field name="priority" eval="10"/>
<field name="inherit_id" ref="account.view_partner_property_form"/>
<field name="priority" eval="99"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='sales_purchases']" position="before">
<xpath expr="//div[@name='warning_tax' and @class='alert alert-warning oe_edit_only']" position="replace"/>
</field>
</record>-->
<!-- Tab 1: HOBBYHIMMEL Basis -->
<page name="ows_basic" string="HOBBYHIMMEL Basis">
<group name="container_row_2">
<group string="Sicherheit">
<field name="security_briefing"/>
<field name="security_id"/>
</group>
<group string="Zugang">
<field name="rfid_card"/>
</group>
</group>
</page>
<!-- 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> -->
<!-- Tab 2: HOBBYHIMMEL Einweisungen (HTML) -->
<page name="ows_machine_access_html" string="HOBBYHIMMEL Einweisungen">
<field name="machine_access_html" readonly="1" widget="html"/>
</page>
<!-- Zentrale View für alle drei Tabs in garantierter Reihenfolge -->
<!-- Tab 3: Einweisungen (Liste) -->
<page name="ows_machine_access_list" string="Einweisungen (Liste)">
<field name="machine_access_ids">
<tree>
<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"/>
</tree>
</list>
</field>
</page>
</group>
</xpath>
</field>
</record>
<!-- Ü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">
@ -100,7 +116,7 @@
<!-- Optional: Kontakte-Action, falls gebraucht -->
<record id="contacts.action_contacts" model="ir.actions.act_window">
<field name="view_mode">tree,kanban,form,activity</field>
<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"/>

View File

@ -1,6 +1,6 @@
# Open Workshop (open_workshop ows)
Dieses Odoo v13.0 Modul erweitert das POS- und Kontakt-Modul um Funktionen für offene Werkstätten (FabLabs, Makerspaces etc.) und dient der Verwaltung von Maschinen, Naschinen Einweisungen Produkten, Maschinen Nutzungsprodukten und Zugangsberechtigungen zu den Maschinen.
Dieses Odoo v18.0 Modul erweitert das POS- und Kontakt-Modul um Funktionen für offene Werkstätten (FabLabs, Makerspaces etc.) und dient der Verwaltung von Maschinen, Naschinen Einweisungen Produkten, Maschinen Nutzungsprodukten und Zugangsberechtigungen zu den Maschinen.
## Funktionen
@ -28,40 +28,10 @@ Dieses Odoo v13.0 Modul erweitert das POS- und Kontakt-Modul um Funktionen für
## Installation
1. Dieses Modul in den Custom-Addons-Ordner kopieren
2. Vor der Installation von open_worshop muss vvow_pos deinstalliert werden. Die Funktionalität von vvow_pos wird durch open_workshop ersetzt und erweitert.
3. ggf. muss die alte Datenbank manuell migiriert werden, es gibt ca 9 gelöscht res.partner auf die Verweise aus POS bestehen. Diese res.parnter müssen wieder hergestellt werden. Dazu gibt es ein Skript unter
```folder
scripts/fix_missing_pos_partner.py
```
```bash
opt/odoo/odoo/odoo-bin shell -d hobbyhimmel < scrpts/fix_missing_pos_partner.py
```
4. Odoo starten mit:
```bash
odoo-bin -d deine_datenbank -u open_workshop
```
5. Alternativ im Backend unter Apps installieren
## Automatische Migrationen
Beim ersten Laden des Moduls werden folgende Migrationen durchgeführt:
- Bestehende `res.partner` erhalten automatisch `ows.user`-Eintrag (inkl. Übernahme alter Felder wie vvow_birthday, vvow_security, vvow_security_id, vvow_rfid.
- Alte Felder mit Maschinenfreigaben (`vvow_holz_*`, `vvow_metall_*`, `vvow_fablab_*`) werden in `ows.machine.access` übertragen
- inkl. Übernahme des Änderungsdatum aus `mail.message` wann der Nutzer die Einweisung erhalten hat (ist noch fehlerhaft)
2. Im Odoo Backend unter Apps installieren
## Entwicklerhinweise
### post_init_hook
Die Datei `post_init_hook.py` ruft automatisch nach der Installation folgende Methoden auf:
```python
res.partner.migrate_existing_partners()
res.partner.migrate_machine_access_from_old_fields()
```
### Datenimport
- Maschinenbereiche, Maschinen werden über `.xml`-Dateien in `data/` geladen
- Die Zuordnung von Maschine zu Einweisungsprodukten und Nutzungsprodukten muss derzeit noch manuell erstellt werden. Ein skript dafür folgt.
## ToDos
- Bearbeitung der Maschinenfreigaben im Backend
- Automatische Erstellung von `mail.message` bei manueller Freigabe

View File

@ -0,0 +1,56 @@
from . import models
from . import controllers
def post_init_hook(env):
"""
Migration Hook: open_workshop open_workshop_base
Wird automatisch nach der Installation/Update des Moduls ausgeführt
"""
cr = env.cr
# Prüfen ob altes Modul 'open_workshop' in DB existiert
cr.execute("""
SELECT id FROM ir_module_module
WHERE name = 'open_workshop' AND id != (
SELECT id FROM ir_module_module WHERE name = 'open_workshop_base'
)
""")
if cr.fetchone():
import logging
_logger = logging.getLogger(__name__)
_logger.info("=" * 70)
_logger.info("MIGRATION: Renaming module 'open_workshop' to 'open_workshop_base'")
_logger.info("=" * 70)
# Modulnamen aktualisieren
cr.execute("""
UPDATE ir_module_module
SET name = 'open_workshop_base'
WHERE name = 'open_workshop'
""")
_logger.info(f"✓ Updated ir_module_module: {cr.rowcount} row(s)")
# ir_model_data Referenzen aktualisieren
cr.execute("""
UPDATE ir_model_data
SET module = 'open_workshop_base'
WHERE module = 'open_workshop'
""")
_logger.info(f"✓ Updated ir_model_data: {cr.rowcount} row(s)")
# Abhängigkeiten aktualisieren
cr.execute("""
UPDATE ir_module_module_dependency
SET name = 'open_workshop_base'
WHERE name = 'open_workshop'
""")
if cr.rowcount > 0:
_logger.info(f"✓ Updated dependencies: {cr.rowcount} row(s)")
_logger.info("=" * 70)
_logger.info("MIGRATION COMPLETED")
_logger.info("=" * 70)

View File

@ -0,0 +1,44 @@
{
'name': 'Open Workshop Base',
'license': 'AGPL-3',
'version': '18.0.1.0.4', # Migration läuft bei 18.0.1.0.4
'summary': 'Kern-Modul für Maschinenfreigaben - vereinfachte Equipment-Integration',
'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance'],
'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',
'data/data.xml',
],
'installable': True,
'assets': {
'web.assets_backend': [
'open_workshop_base/static/src/css/category_color.css',
],
},
'description': """
Open Workshop Base - Kernmodul
================================
Dieses Modul stellt die Basis-Funktionalität für Open Workshop bereit:
* Maschinenmodelle (ows.machine) nutzt maintenance.equipment als Single Source of Truth
* Bereiche (ows.machine.area)
* Einweisungslogik und Freigaben
* Produktverknüpfungen für Einweisungen
* Backend-UI (Form, Tree, Kanban)
* Automatische Migration bestehender Maschinen zu maintenance.equipment
Für POS-Integration installiere: open_workshop_pos
Für API-Zugriff installiere: open_workshop_api (erforderlich für WordPress Frontend)
""",
'post_init_hook': 'post_init_hook',
}

View File

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

View File

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

View File

@ -0,0 +1,153 @@
<odoo>
<!-- Bereiche -->
<record id="area_fablab" model="ows.machine.area">
<field name="name">Fablab</field>
<field name="color_hex">#008000</field>
</record>
<record id="area_holz" model="ows.machine.area">
<field name="name">Holzbereich</field>
<field name="color_hex">#ff0000</field>
</record>
<record id="area_metall" model="ows.machine.area">
<field name="name">Metallbereich</field>
<field name="color_hex">#0000ff</field>
</record>
<record id="area_elektronik" model="ows.machine.area">
<field name="name">Elektronikbereich</field>
<field name="color_hex">#ffff00</field>
</record>
<!-- Maschinen im Fablab -->
<record id="machine_sabako_laser" model="ows.machine">
<field name="name">Sabako Laser</field>
<field name="serial_no">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="serial_no">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="serial_no">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="serial_no">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="serial_no">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="serial_no">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="serial_no">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="serial_no">dickenhobel</field>
<field name="area_id" ref="area_holz"/>
</record>
<record id="machine_drechselbank" model="ows.machine">
<field name="name">Drechselbank</field>
<field name="serial_no">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="serial_no">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="serial_no">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="serial_no">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="serial_no">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="serial_no">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="serial_no">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="serial_no">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="serial_no">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="serial_no">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="serial_no">fraese</field>
<field name="area_id" ref="area_metall"/>
</record>
<record id="machine_abkantbank" model="ows.machine">
<field name="name">Abkantbank</field>
<field name="serial_no">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="serial_no">loetkolben</field>
<field name="area_id" ref="area_elektronik"/>
</record>
</odoo>

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

@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
import logging
from datetime import date
from odoo import SUPERUSER_ID, api
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Post-Migration: Migriere bestehende ows.machine Daten zu maintenance.equipment
Area wird in equipment.location gespeichert (nicht mehr in category!)
"""
_logger.info("=== Post-Migration: Daten-Migration zu equipment ===")
env = api.Environment(cr, SUPERUSER_ID, {})
# 1. Zähle zu migrierende Maschinen
cr.execute("SELECT COUNT(*) FROM ows_machine WHERE equipment_id IS NULL")
machines_to_migrate = cr.fetchone()[0]
_logger.info(f"Found {machines_to_migrate} machines to migrate")
if machines_to_migrate == 0:
_logger.info("No machines to migrate - skipping")
return
_logger.info("Creating maintenance.equipment entries...")
# 2. Migriere alle Maschinen die noch kein equipment haben
cr.execute("""
SELECT
id, name, area_id,
active, create_uid, write_uid, create_date, write_date
FROM ows_machine
WHERE equipment_id IS NULL
""")
machines = cr.fetchall()
migrated_count = 0
# Default Company ermitteln
cr.execute("SELECT id FROM res_company ORDER BY id LIMIT 1")
default_company_id = cr.fetchone()[0] if cr.rowcount > 0 else None
for machine in machines:
(m_id, m_name_jsonb, m_area_id,
m_active, m_create_uid, m_write_uid, m_create_date, m_write_date) = machine
# Name aus JSONB extrahieren (de_DE oder en_US)
m_name = None
if m_name_jsonb:
m_name = m_name_jsonb.get('de_DE') or m_name_jsonb.get('en_US')
# Serial number generieren
m_serial_no = f"OWS-{m_id}"
# Location aus Area-Name ermitteln
location = None
if m_area_id:
# Hole Area-Name (JSONB!) und nutze als location
cr.execute("SELECT name FROM ows_machine_area WHERE id = %s", (m_area_id,))
area_result = cr.fetchone()
if area_result and area_result[0]:
area_name_jsonb = area_result[0]
if area_name_jsonb:
location = area_name_jsonb.get('de_DE') or area_name_jsonb.get('en_US')
# Equipment erstellen (name als JSONB!)
# Fallback: Wenn m_name leer, nutze serial_no
display_name = m_name if m_name and m_name.strip() else m_serial_no
# Verwende SQL direkt für korrektes JSONB handling
try:
cr.execute("""
INSERT INTO maintenance_equipment
(name, serial_no, location, cost, effective_date, equipment_assign_to,
company_id, active, create_uid, write_uid, create_date, write_date)
VALUES
(jsonb_build_object('de_DE', %s, 'en_US', %s), %s, %s, %s, %s, 'other',
%s, %s, %s, %s, %s, %s)
RETURNING id
""", (display_name, display_name, m_serial_no,
location, 0.0, date.today(),
default_company_id,
m_active if m_active is not None else True,
m_create_uid, m_write_uid, m_create_date, m_write_date))
equipment_id = cr.fetchone()[0]
# equipment_id in ows_machine setzen
cr.execute("""
UPDATE ows_machine
SET equipment_id = %s
WHERE id = %s
""", (equipment_id, m_id))
migrated_count += 1
except Exception as e:
_logger.error(f"Failed to migrate machine {m_id}: {e}")
# 3. Synchronisiere related fields von ows.machine → maintenance.equipment
# Nötig weil related fields mit store=True nicht automatisch bei SQL-INSERT synchronisiert werden
_logger.info("Synchronizing related fields (area_id, category) to maintenance.equipment...")
cr.execute("""
UPDATE maintenance_equipment me
SET ows_area_id = om.area_id
FROM ows_machine om
WHERE om.equipment_id = me.id
AND om.area_id IS NOT NULL
""")
synced_areas = cr.rowcount
_logger.info(f"✅ Synchronized {synced_areas} area_id values")
cr.execute("""
UPDATE maintenance_equipment me
SET ows_category = om.category
FROM ows_machine om
WHERE om.equipment_id = me.id
AND om.category IS NOT NULL
""")
synced_categories = cr.rowcount
_logger.info(f"✅ Synchronized {synced_categories} category values")
# 4. Setze Default-Status "In Betrieb" für migrierte Equipment ohne Status
# (Status-Records werden automatisch über XML-Datei erstellt)
_logger.info("Setting default status for equipment without status...")
# Prüfe ob Status "In Betrieb" existiert
cr.execute("SELECT id FROM maintenance_equipment_status WHERE name = 'In Betrieb' LIMIT 1")
status_result = cr.fetchone()
if status_result:
status_id = status_result[0]
cr.execute("""
UPDATE maintenance_equipment me
SET status_id = %s
WHERE me.status_id IS NULL
AND EXISTS (
SELECT 1 FROM ows_machine om
WHERE om.equipment_id = me.id
)
""", (status_id,))
status_set = cr.rowcount
_logger.info(f"✅ Set default status for {status_set} equipment records")
else:
_logger.warning("⚠️ Status 'In Betrieb' not found - skipping status assignment")
# 5. Validierung
cr.execute("SELECT COUNT(*) FROM ows_machine WHERE equipment_id IS NULL")
remaining = cr.fetchone()[0]
if remaining > 0:
_logger.warning(f"⚠️ {remaining} machines could not be migrated!")
else:
_logger.info(f"✅ Successfully migrated {migrated_count} machines to equipment")
_logger.info("Post-migration completed")

View File

@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
import logging
from odoo import SUPERUSER_ID, api
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Pre-Migration: Bereite equipment_id Spalte vor
Installiert maintenance-Modul falls nötig
"""
_logger.info("=== Pre-Migration: ows.machine → maintenance.equipment ===")
env = api.Environment(cr, SUPERUSER_ID, {})
# 1. Prüfe ob maintenance installiert ist, falls nicht: installieren
cr.execute("""
SELECT state FROM ir_module_module
WHERE name = 'maintenance'
""")
result = cr.fetchone()
if not result or result[0] != 'installed':
_logger.info("Installing maintenance module...")
maintenance_module = env['ir.module.module'].search([('name', '=', 'maintenance')])
if maintenance_module:
maintenance_module.button_immediate_install()
_logger.info("✅ Maintenance module installed")
else:
raise Exception("Maintenance module not found in addons_path!")
else:
_logger.info("✓ Maintenance module already installed")
# 2. Spalte equipment_id hinzufügen (falls nicht vorhanden)
cr.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'ows_machine' AND column_name = 'equipment_id'
""")
if not cr.fetchone():
_logger.info("Adding equipment_id column to ows_machine")
cr.execute("""
ALTER TABLE ows_machine
ADD COLUMN equipment_id INTEGER;
""")
cr.execute("""
ALTER TABLE ows_machine
ADD CONSTRAINT ows_machine_equipment_id_fkey
FOREIGN KEY (equipment_id)
REFERENCES maintenance_equipment(id)
ON DELETE CASCADE;
""")
_logger.info("✅ equipment_id column added")
else:
_logger.info("✓ equipment_id column already exists")
_logger.info("Pre-migration completed")

View File

@ -0,0 +1,105 @@
-- ============================================================================
-- Pre-Deployment Migration Script: open_workshop → open_workshop_base
-- ============================================================================
-- Dieses SQL-Skript MUSS vor dem Odoo-Update in der Gitea Action ausgeführt werden
--
-- Verwendung in Gitea Action:
-- docker exec <db-container> psql -U odoo -d <dbname> < migrate_open_workshop_to_base.sql
--
-- Oder als Deployment-Hook vor "odoo -u open_workshop_base"
-- ============================================================================
\echo '=========================================='
\echo 'Migration: open_workshop → open_workshop_base'
\echo '=========================================='
-- Prüfen ob Migration nötig ist
DO $$
DECLARE
old_module_exists BOOLEAN;
new_module_exists BOOLEAN;
BEGIN
-- Prüfe ob altes Modul existiert
SELECT EXISTS(
SELECT 1 FROM ir_module_module WHERE name = 'open_workshop'
) INTO old_module_exists;
-- Prüfe ob neues Modul bereits existiert
SELECT EXISTS(
SELECT 1 FROM ir_module_module WHERE name = 'open_workshop_base'
) INTO new_module_exists;
IF old_module_exists THEN
RAISE NOTICE '✓ Found module "open_workshop" - starting migration';
IF new_module_exists THEN
RAISE NOTICE '⚠ Module "open_workshop_base" already exists - cleaning up';
-- Aufräumen falls Testinstallation vorhanden
DELETE FROM ir_model_data WHERE module = 'base' AND name = 'module_open_workshop_base';
DELETE FROM ir_module_module WHERE name = 'open_workshop_base';
END IF;
-- ===== MIGRATION DURCHFÜHREN =====
-- 1. Modulnamen und Version aktualisieren
UPDATE ir_module_module
SET name = 'open_workshop_base',
latest_version = '18.0.1.0.3' -- Auf 18.0.1.0.3 setzen, damit 18.0.1.0.4 Migration läuft
WHERE name = 'open_workshop';
RAISE NOTICE '✓ Updated ir_module_module: % row(s)', (SELECT 1);
-- 2. Alle ir_model_data Referenzen aktualisieren
UPDATE ir_model_data
SET module = 'open_workshop_base'
WHERE module = 'open_workshop';
RAISE NOTICE '✓ Updated ir_model_data: % row(s)', (
SELECT COUNT(*) FROM ir_model_data WHERE module = 'open_workshop_base'
);
-- 3. Alten ir_model_data Eintrag für module_open_workshop löschen
DELETE FROM ir_model_data
WHERE module = 'base' AND name = 'module_open_workshop';
RAISE NOTICE '✓ Cleaned up old module_open_workshop entry';
-- 4. Abhängigkeiten in anderen Modulen aktualisieren (falls vorhanden)
UPDATE ir_module_module_dependency
SET name = 'open_workshop_base'
WHERE name = 'open_workshop';
IF FOUND THEN
RAISE NOTICE '✓ Updated dependencies in other modules';
END IF;
RAISE NOTICE '==========================================';
RAISE NOTICE '✓ MIGRATION COMPLETED SUCCESSFULLY';
RAISE NOTICE '==========================================';
RAISE NOTICE 'Next step: Run "odoo -u open_workshop_base -d <dbname>"';
ELSE
RAISE NOTICE ' Module "open_workshop" not found - migration not needed';
RAISE NOTICE 'This is normal for new installations';
END IF;
EXCEPTION
WHEN OTHERS THEN
RAISE EXCEPTION 'Migration failed: %', SQLERRM;
END
$$;
-- Prüfung: Zeige aktuellen Status
\echo ''
\echo 'Current module status:'
SELECT name, state, latest_version
FROM ir_module_module
WHERE name LIKE 'open_workshop%'
ORDER BY name;
\echo ''
\echo 'Model data count:'
SELECT module, COUNT(*) as entries
FROM ir_model_data
WHERE module LIKE 'open_workshop%'
GROUP BY module;

View File

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

View File

@ -0,0 +1,739 @@
# -*- 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 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
)
@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'
_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):
"""
Open Workshop Maschine - nutzt maintenance.equipment als Single Source of Truth.
Delegation Pattern (_inherits):
- Beim Erstellen wird automatisch ein maintenance.equipment erstellt
- Felder wie name, serial_no, cost etc. werden direkt von equipment übernommen
- Keine Datenduplizierung!
"""
_name = 'ows.machine'
_table = 'ows_machine'
_description = 'OWS: Maschine'
_inherits = {'maintenance.equipment': 'equipment_id'}
# PFLICHT: Verknüpfung zu maintenance.equipment (Single Source of Truth)
equipment_id = fields.Many2one(
'maintenance.equipment',
required=True,
ondelete='cascade',
string='Equipment',
help='Verknüpfung zum Maintenance Equipment (Single Source of Truth für name, code, Preis, etc.)'
)
# Delegierte Felder (kommen automatisch von equipment via _inherits):
# - name (equipment.name)
# - serial_no → wird als 'code' verwendet (siehe @api.depends unten)
# - cost → purchase_price
# - effective_date → purchase_date
# - location → storage_location (wird von area_id synchronisiert, readonly via View)
# - note → description
# - category_id → Wird mit area_id synchronisiert
# OWS-spezifische Felder (nur in ows.machine!)
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, '')
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,)
@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)
# Keine eigenen SQL Constraints - Equipment hat bereits unique constraint für serial_no
@api.model_create_multi
def create(self, vals_list):
"""
Beim Erstellen einer ows.machine:
1. Equipment nur erstellen wenn equipment_id NICHT angegeben wurde
2. Area Location synchronisieren
3. serial_no und name vom User übernehmen
"""
# Equipment nur erstellen wenn noch nicht vorhanden
for vals in vals_list:
# Wenn equipment_id bereits gesetzt ist (z.B. bei Import), KEIN neues Equipment erstellen!
if 'equipment_id' not in vals:
equipment_vals = {
'name': vals.get('name', 'Neue Maschine'),
'serial_no': vals.get('serial_no', False),
}
equipment = self.env['maintenance.equipment'].with_context(skip_ows_machine_creation=True).create(equipment_vals)
vals['equipment_id'] = equipment.id
return super(OwsMachine, self).create(vals_list)
def write(self, vals):
"""
Bei Updates:
1. Name/Serial_no Equipment synchronisieren
"""
# Name/Serial_no an Equipment weiterleiten
equipment_vals = {}
if 'name' in vals:
equipment_vals['name'] = vals['name']
if 'serial_no' in vals:
equipment_vals['serial_no'] = vals['serial_no']
if equipment_vals and self.equipment_id:
self.equipment_id.write(equipment_vals)
return super(OwsMachine, self).write(vals)
def name_get(self):
result = []
for rec in self:
name = rec.name or 'Unbenannt'
if rec.serial_no: # Kommt direkt von equipment via _inherits
name = f"[{rec.serial_no}] {name}"
result.append((rec.id, name))
return result
def action_open_equipment(self):
"""Smart Button: Öffnet die Equipment-Detailansicht"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Equipment Details',
'res_model': 'maintenance.equipment',
'res_id': self.equipment_id.id,
'view_mode': 'form',
'target': 'current',
}
@api.model
def get_access_list_grouped(self, partner_id):
"""
Gibt eine gruppierte Liste von Maschinenzugängen für einen bestimmten Partner zurück. Diese Funktion wird in
Odoo POS Frontend verwendet um die Ansicht zu erzeugen auf Welche Maschinen der Partner Zugriff hat.
Für einen gegebenen Partner (über die partner_id) werden alle Maschinenbereiche (areas) abgefragt.
Für jeden Bereich wird geprüft, auf welche Maschinen der Partner Zugriff hat. Das Ergebnis wird
als Liste von Bereichen mit jeweils zugehörigen Maschinen und Zugriffsstatus zurückgegeben.
Zusätzlich werden sicherheitsrelevante Informationen des Partners (wie Sicherheitsunterweisung,
Sicherheits-ID, RFID-Karte und Geburtstag) aus dem zugehörigen ows_user ermittelt und mitgeliefert.
Args:
partner_id (int): Die ID des Partners, für den die Zugriffsübersicht erstellt werden soll.
Returns:
dict: Ein Dictionary mit folgenden Schlüsseln:
- 'access_by_area': Liste von Bereichen mit Maschinen und Zugriffsstatus.
- 'security_briefing': Sicherheitsunterweisung des Nutzers (bool oder False).
- 'security_id': Sicherheits-ID des Nutzers (str oder '').
- 'rfid_card': RFID-Kartennummer des Nutzers (str oder '').
- 'birthday': Geburtstag des Nutzers (str oder '').
"""
partner = self.env['res.partner'].browse(partner_id)
areas = self.env['ows.machine.area'].search([], order="name")
_logger.info("Access RPC called with partner_id=%s", partner_id)
access_by_area = []
for area in areas:
machines = self.search([('area_id', '=', area.id), ('category', '=', 'red')], order="name")
machine_list = []
for machine in machines:
has_access = bool(self.env['ows.machine.access'].search([
('partner_id', '=', partner_id),
('machine_id', '=', machine.id),
], limit=1))
machine_list.append({
'name': machine.name,
'has_access': has_access,
})
if machine_list:
access_by_area.append({
'area': area.name,
'color_hex': area.color_hex or '#000000',
'machines': machine_list
})
user = partner.ows_user_id[:1]
return {
'access_by_area': access_by_area,
'security_briefing': user.security_briefing if user else False,
'security_id': user.security_id if user else '',
'rfid_card': user.rfid_card if user else '',
'birthday': user.birthday if user else '',
}
class OwsMachineAccess(models.Model):
_name = 'ows.machine.access'
_table = 'ows_machine_access'
_description = 'OWS: Maschinenfreigabe'
_order = 'partner_id, machine_id'
partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade')
machine_id = fields.Many2one('ows.machine', required=True)
date_granted = fields.Date(default=fields.Date.today)
date_expiry = fields.Date(string="Ablaufdatum")
granted_by_pos = fields.Boolean(default=True)
_sql_constraints = [
('partner_machine_unique', 'unique(partner_id, machine_id)', 'Der Kunde hat diese Freigabe bereits.')
]
class OwsMachineProduct(models.Model):
_name = 'ows.machine.product'
_table = 'ows_machine_product'
_description = 'OWS: Zuordnung Produkt der Nutzung zu der Maschine'
product_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
class OwsMachineTraining(models.Model):
_name = 'ows.machine.training'
_table = 'ows_machine_training'
_description = 'OWS: Zuordnung Produkt der Einweisung zu der Maschine'
training_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')

View File

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

View File

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

View File

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

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