Home
Py
Python
OWL
Modules
🤖
AI
📱
Mobile
API
EP
Hogeschool Cursus · 2026–2028

Odoo Integrator
& Senior Developer

Een tweejarige diepgaande opleiding voor de nieuwe generatie Odoo experts. Je bouwt TechniCool NV uit van een lokale speler naar een high-tech servicebedrijf en levert Vanventory op als commercieel eindproduct.

72
Weken
576u
Contacturen
7
Vakken
AI+AR
Specialisatie
Het Project
TechniCool NV & Vanventory

TechniCool NV is de marktleider in België voor het onderhoud van industriële keukens. Jouw opdracht is om hun volledige digitale workflow te hertekenen in Odoo. Het pronkstuk is Vanventory: een AI-gestuurde PWA waarmee technici hun bestelwageninrichting beheren via computer vision en augmented reality.

Certificering
Slaagvereisten
Niet onderhandelbaar: Om het diploma "Senior Odoo Integrator" te behalen, moet je aan 3 voorwaarden voldoen: (1) Gemiddelde van 60% op de vakken, (2) Eindproject draait in productie op HTTPS, (3) Positieve verdediging voor de externe jury van TechniCool NV.
  • Permanent Evaluatie — Elke 6 weken een demo van je voortgang.
  • Code Review — Al je code staat op GitHub en wordt wekelijks ge-reviewed op Odoo standaarden.
  • Productie-omgeving — Je applicatie wordt getest door échte technici op de baan.
Curriculum
Twee Jaar Groei
J1
Jaar 1: Odoo Fundamenten
// python, orm, owl, modules, addons
Wk 1–36

In het eerste jaar leggen we een ijzersterke technische basis. Je leert Odoo's backend (Python/ORM) en frontend (OWL) volledig beheersen. Je bouwt de techniecool_service module die de ruggengraat vormt van de operatie.

J2
Jaar 2: AI, Mobile & AR
// vision ai, pwa, webxr, api, eindproject
Wk 37–72

Het tweede jaar staat in het teken van innovatie. We integreren GPT-4 Vision voor automatische stockherkenning, bouwen een offline-first PWA en implementeren AR overlays over de bestelwageninrichting. Je eindigt met de verdediging van Vanventory.

Vak 1 · Weken 1–8 · 64 contacturen

Python &
Odoo ORM

Studenten leren Python vanuit nul, meteen toegepast op Odoo's ORM. Geen abstracte oefeningen — elke les bouwt direct aan TechniCool NV's datamodel.

8
Weken
64u
Les
Python
Basis
Cursusinhoud
Hoofdstukken
H1
Python Fundament voor Odoo
// syntax, types, classes, self, decorators
Week 1 · 8u

Python is de taal van de Odoo backend. Voor we in de complexiteit van het framework duiken, moeten we de fundamenten van de taal beheersen. Odoo maakt intensief gebruik van object-georiënteerd programmeren (OOP), decorators en specifieke data-structuren zoals dictionaries. Vandaag bouwen we de basislogica voor onze eerste TechniCool componenten en begrijpen we waarom 'self' de spil is van elk Odoo record.

1.1 Python Syntax & Variabelen

Bij TechniCool NV is het cruciaal dat we data van keukentoestellen gestructureerd opslaan in variabelen. Python gebruikt hiervoor een dynamische typering, wat betekent dat we het type niet vooraf hoeven te definiëren. Voor een Rational combi-steamer gebruiken we strings voor het merk en model, en integers of floats voor technische waarden zoals de temperatuur of het aantal bedrijfsuren. We leren hoe we deze variabelen correct benoemen volgens de Pythonic 'snake_case' conventie die ook binnen Odoo de standaard is.

# Definiëren van toestel data voor TechniCool
merk = "Rational"
model = "iCombi Pro 6-1/1"
serienummer = "E61S J2401001"
bedrijfsuren = 450
temperatuur_max = 300.5
is_actief = True

print(f"Toestel: {merk} {model} (SN: {serienummer})")</pre>
                  
✏ Mini-oefening

Maak variabelen aan voor een Electrolux blast chiller met model 'SkyLine' en een serienummer naar keuze. Voeg een float toe voor de koeltemperatuur (-20.5 graden).

👁 Toon oplossing
merk = "Electrolux" model = "SkyLine" serienummer = "ELX-9988-X" koeltemperatuur = -20.5
1.2 Lijsten & Dictionaries Geavanceerd

In de wereld van Odoo ontvangen we vaak data als een lijst van dictionaries, bijvoorbeeld bij het ophalen van een onderdelenlijst voor een herstelling bij TechniCool. Dictionaries laten ons toe om data te labelen met 'keys' zoals 'onderdeel_naam' en 'prijs', terwijl lijsten deze groeperen. We leren hoe we diep in deze structuren duiken om specifieke informatie te extraheren, zoals de totale waarde van de stock in een technieker-wagen. List comprehensions zijn hierbij een onmisbare tool om snel data te transformeren zonder lange for-loops.

# Onderdelenlijst voor een TechniCool herstelling
onderdelen = [
    {"id": 1, "naam": "Deurrubber", "prijs": 45.50},
    {"id": 2, "naam": "Ventilator motor", "prijs": 120.00},
    {"id": 3, "naam": "Temperatuursonde", "prijs": 12.75}
]

# Haal enkel de namen op via list comprehension
namen = [o["naam"] for o in onderdelen]
print(f"Benodigde onderdelen: {namen}")</pre>
                  
✏ Mini-oefening

Schrijf een list comprehension die enkel de prijzen uit de lijst `onderdelen` haalt en deze opslaat in een nieuwe lijst `prijzen`.

👁 Toon oplossing
prijzen = [o["prijs"] for o in onderdelen]
1.3 Control Flow & Logic

TechniCool dispatchers moeten vaak beslissingen automatiseren op basis van logica, zoals het checken van garantie. We gebruiken if, elif en else om de stroom van ons programma te sturen op basis van de ouderdom van een keukentoestel. Door datums te vergelijken kunnen we automatisch bepalen of een klant recht heeft op gratis support of dat de wisselstukken gefactureerd moeten worden. Logische operatoren zoals and, or en not helpen ons om complexe condities te bouwen voor TechniCool.

from datetime import date

installatie_datum = date(2023, 5, 10)
vandaag = date.today()
garantie_termijn = 730 # 2 jaar in dagen

dagen_oud = (vandaag - installatie_datum).days

if dagen_oud < 0:
    print("Fout: Toestel moet nog geïnstalleerd worden.")
elif dagen_oud < garantie_termijn:
    print("Status: TechniCool Premium Garantie (Actief)")
else:
    print("Status: Garantie verlopen. Factureer interventie.")</pre>
                  
✏ Mini-oefening

Schrijf een if-statement dat controleert of de variabele `temperatuur` (stel in op 210) hoger is dan 200. Zo ja, print "Gevaar: Oven te warm!".

👁 Toon oplossing
temperatuur = 210 if temperatuur > 200: print("Gevaar: Oven te warm!")
1.4 Functies & *args/**kwargs

Herbruikbaarheid is de sleutel tot schone code bij TechniCool NV, en functies stellen ons in staat om logica één keer te schrijven en overal te gebruiken. We leren hoe we argumenten doorgeven aan functies om berekeningen uit te voeren, zoals het bepalen van de voorrijkosten. Geavanceerde technieken zoals *args en **kwargs laten ons toe om een variabel aantal argumenten te accepteren, wat Odoo intern constant gebruikt voor de creatie van records via de API.

def bereken_factuur(uren, uurtarief=65, **onderdelen):
    totaal_uren = uren * uurtarief
    totaal_onderdelen = sum(onderdelen.values())
    return totaal_uren + totaal_onderdelen

# Aanroep met kwargs voor onderdelen
totaal = bereken_factuur(uren=2, filter=25, pakking=12)
print(f"Totaal te factureren aan TechniCool klant: €{totaal}")</pre>
                  
✏ Mini-oefening

Maak een functie `check_serienummer(sn)` die True teruggeeft als het serienummer start met "TC-" en anders False. Tip: gebruik `sn.startswith("TC-")`.

👁 Toon oplossing
def check_serienummer(sn): return sn.startswith("TC-")
1.5 Classes & Inheritance

Odoo is volledig gebouwd op Object-Georiënteerd Programmeren (OOP). Bij TechniCool definiëren we een basis Toestel klasse die algemene eigenschappen heeft, en gebruiken we overerving om specifieke sub-klasses zoals Oven te maken. Dit laat ons toe om algemene methoden één keer te definiëren, terwijl we specifieke logica enkel toevoegen aan de relevante sub-klasse. Dit concept is direct overdraagbaar naar hoe we Odoo modellen uitbreiden via _inherit.

class Toestel:
    def __init__(self, naam):
        self.naam = naam
    def rapport(self):
        print(f"Basis rapport voor {self.naam}")

class Oven(Toestel):
    def rapport(self):
        super().rapport()
        print("Inclusief temperatuur-log voor TechniCool keuring.")

mijn_oven = Oven("Rational iCombi")
mijn_oven.rapport()</pre>
                  
✏ Mini-oefening

Maak een subklasse `Koelkast` die overerft van `Toestel`. Voeg een methode `vries()` toe die "Koelen gestart..." print.

👁 Toon oplossing
class Koelkast(Toestel): def vries(self): print("Koelen gestart...")
1.6 Het 'self' concept en Scope

Het keyword self verwijst naar de specifieke instantie (het record) waar we op dat moment mee werken. Bij TechniCool, als we een methode aanroepen op een specifieke Rational oven, zorgt self ervoor dat we de data van die oven ophalen en niet van een ander toestel. We leren ook het verschil tussen instance variabelen (uniek per toestel) en class variabelen (gedeeld door alle techniekers). Begrip van scope voorkomt veelvoorkomende errors in Odoo logica.

class Technieker:
    bedrijf = "TechniCool NV" # Class variabele

    def __init__(self, naam):
        self.naam = naam # Instance variabele

    def voorstellen(self):
        print(f"Ik ben {self.naam} van {self.bedrijf}")

t1 = Technieker("Marc")
t1.voorstellen()</pre>
                  
✏ Mini-oefening

Voeg een variabele `self.specialisatie` toe aan de `__init__` van de klasse `Technieker`. Print deze specialisatie in de methode `voorstellen`.

👁 Toon oplossing
class Technieker: bedrijf = "TechniCool NV" def __init__(self, naam, specialisatie): self.naam = naam self.specialisatie = specialisatie def voorstellen(self): print(f"Ik ben {self.naam} van {self.bedrijf}, expert in {self.specialisatie}")
1.7 Decorators (@) in Detail

Decorators zien eruit als magie in Odoo (@api.depends), maar technisch zijn het gewoon Python functies die een andere functie 'wrappen' om extra gedrag toe te voegen. Bij TechniCool gebruiken we ze om bijvoorbeeld automatisch logging toe te voegen aan herstel-methoden. We leren hoe we zelf een eenvoudige decorator schrijven die de uitvoeringstijd meet. Dit inzicht zorgt ervoor dat je echt begrijpt hoe de Odoo server de dependency graph opbouwt.

def log_actie(func):
    def wrapper(*args, **kwargs):
        print(f"LOG: Start uitvoering van {func.__name__}")
        result = func(*args, **kwargs)
        print("LOG: Actie voltooid.")
        return result
    return wrapper

@log_actie
def herstel_oven():
    print("Oven wordt hersteld door TechniCool...")

herstel_oven()</pre>
                  
✏ Mini-oefening

Maak een decorator `check_admin` die enkel een print "Toegang verleend" doet voor hij de functie uitvoert.

👁 Toon oplossing
def check_admin(func): def wrapper(*args, **kwargs): print("Toegang verleend") return func(*args, **kwargs) return wrapper
1.8 Error Handling (Try/Except)

Niets is vervelender dan een TechniCool server die crasht omdat er een veld leeg is in de database. Met try/except blokken maken we onze code robuust tegen onverwachte situaties, zoals een ontbrekende prijs bij een onderdeel. We leren hoe we specifieke errors vangen en hoe we een zinvolle foutmelding teruggeven aan de dispatcher. Dit is cruciaal voor Odoo 'ValidationErrors' waarbij we de gebruiker professionele feedback geven.

# Error handling in de TechniCool database
onderdeel_data = {"naam": "Ventilator"} # Oeps, prijs ontbreekt!

try:
    prijs = onderdeel_data["prijs"]
    totaal = prijs * 1.21 # BTW berekening
    print(f"Totaal: {totaal}")
except KeyError:
    print("Fout: Prijs ontbreekt in TechniCool onderhouds-database.")
except Exception as e:
    print(f"Onverwachte fout: {e}")
finally:
    print("Check voltooid.")</pre>
                  
✏ Mini-oefening

Schrijf een try/except blok dat een `ZeroDivisionError` opvangt wanneer je `100 / 0` probeert te doen. Print "Kan niet delen door nul!".

👁 Toon oplossing
try: result = 100 / 0 except ZeroDivisionError: print("Kan niet delen door nul!")
1.9 Virtual Environments & Pip

Op de Hetzner-server van TechniCool NV werken we met virtuele omgevingen zodat elk project zijn eigen afhankelijkheden heeft zonder conflicten. Een venv isoleert de Python-installatie volledig. We installeren requests voor REST API-aanroepen naar Odoo en externe diensten, en pytz voor tijdzone-conversies bij het plannen van TechniCool-interventies. Het requirements.txt-bestand documenteert alle dependencies zodat elk teamlid exact dezelfde omgeving reproduceert met één commando.

# 1. Virtualenv aanmaken op de Hetzner dev-server
python3 -m venv techniecool-env

# 2. Omgeving activeren (Linux / Mac)
source techniecool-env/bin/activate

# 3. Libraries installeren die we later voor Odoo nodig hebben
pip install requests pytz python-dotenv

# 4. Bewaar exacte versies voor het team
pip freeze > requirements.txt

# requirements.txt (voorbeeld output):
# requests==2.31.0
# pytz==2024.1
# python-dotenv==1.0.0

# 5. Team-lid reproduceert de omgeving met:
pip install -r requirements.txt</pre>
                  
✏ Mini-oefening

Maak een requirements.txt voor een project dat de anthropic-library (Claude AI, gebruikt in Vak 4) en openpyxl (voor Excel-exports uit Odoo) nodig heeft. Waarom hoeft xmlrpc — die we gebruiken voor Odoo XML-RPC communicatie — niet in requirements.txt?

👁 Toon oplossing
anthropic==0.26.0 openpyxl==3.1.2 # xmlrpc is een INGEBOUWDE Python-module (standard library). # Je installeert hem niet via pip, hij is altijd beschikbaar. # Enkel externe third-party packages horen in requirements.txt.
1.10 Python Modules & Imports

Code verspreid over meerdere bestanden organiseren is exact wat Odoo ook doet. Een Python package is een map met een __init__.py-bestand dat de submodules laadt — precies de structuur die we terugzien in elke Odoo-module. Relatieve imports (from . import equipment) zijn identiek aan hoe Odoo zijn models-map organiseert. Als je straks een Odoo-module opent en from . import equipment ziet in models/__init__.py, herken je dit direct als het patroon dat je hier leert.

# Mapstructuur die EXACT lijkt op een Odoo-module:
# techniecool/
# ├── __init__.py       ← Maakt het een Python package
# ├── equipment.py      ← KeukenToestel klasse
# └── technician.py     ← Technieker klasse

# __init__.py — laadt sub-modules (zelfde als Odoo models/__init__.py)
from . import equipment
from . import technician

# equipment.py
class KeukenToestel:
    def __init__(self, brand, serial):
        self.brand = brand
        self.serial = serial

# main.py — gebruik het package vanuit buiten
from techniecool.equipment import KeukenToestel

steamer = KeukenToestel("Rational", "RA-2024-001")
print(steamer.brand)  # Output: Rational</pre>
                  
✏ Mini-oefening

Voeg een bestand utils.py toe aan het techniecool package met een functie formatteer_serienummer(sn) die het serienummer naar hoofdletters omzet. Importeer de functie in main.py en print formatteer_serienummer("ra-2024-001").

👁 Toon oplossing
# utils.py def formatteer_serienummer(sn): return sn.upper() # __init__.py — voeg toe: from . import utils # main.py from techniecool.utils import formatteer_serienummer print(formatteer_serienummer("ra-2024-001")) # RA-2024-001
🛠 Capstone — Alles Samen: KeukenToestel Module

Dit is de volledige equipment.py die alle H1-concepten combineert: class variabelen, instance variabelen, methods, error handling en JSON-export. Sla dit bestand op als techniecool/equipment.py in jouw virtualenv-project.

# TechniCool Equipment Logic - Uitgebreid Fundament
from datetime import date, timedelta
import json

class KeukenToestel:
    """Basis klasse voor alle TechniCool apparatuur."""
    GARANTIE_DAGEN = 730 # 2 Jaar

    def __init__(self, brand, model, serial_number, install_date):
        self.brand = brand
        self.model = model
        self.serial_number = serial_number
        self.install_date = install_date
        self.history = []

    def is_under_warranty(self):
        # Garantie berekening op basis van constante
        warranty_end = self.install_date + timedelta(days=self.GARANTIE_DAGEN)
        return date.today() < warranty_end

    def add_log(self, message):
        # Historiek bijhouden van het toestel
        timestamp = date.today().isoformat()
        self.history.append({"date": timestamp, "msg": message})

    def to_json(self):
        # Converteren naar JSON voor TechniCool API
        data = {
            'brand': self.brand,
            'model': self.model,
            'sn': self.serial_number,
            'warranty': self.is_under_warranty(),
            'logs': self.history
        }
        return json.dumps(data, indent=2)

# Instantie van een Rational Combi-Steamer
steamer = KeukenToestel(
    brand='Rational', 
    model='iCombi Pro 6-1/1', 
    serial_number='E61S J2401001',
    install_date=date(2024, 3, 9)
)

steamer.add_log("Installatie voltooid door technieker Marc.")
steamer.add_log("Eerste check-up uitgevoerd.")

print(f"--- Status Rapport: {steamer.serial_number} ---")
if steamer.is_under_warranty():
    print("Status: Onder garantie (TechniCool Premium Support)")
else:
    print("Status: Garantie verlopen. Factureer wisselstukken.")

print(\nJSON Export voor Vanventory App:")
print(steamer.to_json())</pre>
            
⚙ Praktijkopdracht

Schrijf een Python script dat een lijst van 5 KeukenToestel objecten aanmaakt voor TechniCool NV. Gebruik een loop om van elk toestel de garantie-status te printen. Voeg een methode needs_service() toe die True teruggeeft als de installatiedatum meer dan 1 jaar geleden is.

Theorie: Recordsets
In Odoo werk je zelden met één object. Je werkt met "recordsets". Een recordset is een collectie van records van hetzelfde model. In Python gedraagt dit zich als een lijst, maar met extra Odoo-magie.
H2
Odoo Development Environment
// source install, config, shell, logs
Week 2 · 8u

Een professionele Odoo developer werkt niet in de browser alleen. Je hebt een solide lokale of server-gebaseerde development stack nodig. We configureren Odoo 17 op onze Hetzner Ubuntu server, stellen PyCharm of VSCode in voor debugging, en leren de Odoo Shell gebruiken voor razendsnelle database interactie zonder GUI overhead.

2.1 Odoo 17 Source Install

Voor TechniCool NV is het essentieel dat we een stabiele en aanpasbare Odoo-omgeving hebben voor het beheer van onze Rational combi-steamers. Door Odoo vanuit de broncode te installeren via de officiële GitHub repository, behouden we de volledige controle over updates en bugfixes. We klonen de '17.0' branch om te profiteren van de nieuwste features terwijl we de stabiliteit garanderen die nodig is voor onze kritieke serviceprocessen. Dit stelt onze developers ook in staat om diep in de core-code te duiken wanneer er specifieke integraties nodig zijn voor de Electrolux koelsystemen.

# Clone Odoo 17 source van GitHub voor TechniCool
git clone https://www.github.com/odoo/odoo --depth 1 --branch 17.0 /opt/odoo/odoo

# Installeer systeem afhankelijkheden voor Python 3
sudo apt-get install -y python3-pip python3-dev libxml2-dev libxslt1-dev</pre>
                  
✏ Mini-oefening

Clone de Odoo 17 repository naar de map `/home/odoo/src` met een depth van 1.

👁 Toon oplossing
git clone https://www.github.com/odoo/odoo --depth 1 --branch 17.0 /home/odoo/src
2.2 Addons Path Geavanceerd

De kracht van Odoo bij TechniCool NV zit in de modulariteit, waarbij we de standaard functionaliteit uitbreiden met eigen addons. In het `odoo.conf` bestand configureren we het `addons_path` om een duidelijke scheiding te maken tussen de core-code en onze custom TechniCool modules. We voegen ook paden toe voor community modules van de OCA (Odoo Community Association) die we gebruiken voor fleet management van onze MKN service-wagens. Een correct geconfigureerd pad zorgt ervoor dat Odoo alle benodigde componenten vindt zonder prestatieverlies tijdens het laden van de server.

[options]
; Paden voor TechniCool: Core, Custom en OCA
addons_path = /opt/odoo/odoo/addons,/opt/odoo/custom/technicool,/opt/odoo/oca/field-service</pre>
                  
✏ Mini-oefening

Voeg een pad `/opt/odoo/enterprise` toe aan je bestaande `addons_path` configuratie.

👁 Toon oplossing
addons_path = /opt/odoo/odoo/addons,/opt/odoo/enterprise
2.3 Server Debug Mode

Tijdens de ontwikkeling van de Vanventory app voor TechniCool technici willen we wijzigingen direct in de browser zien zonder de server telkens te herstarten. Door de vlag `--dev=all` te gebruiken bij het opstarten van de server, activeert Odoo een krachtige debug-modus die XML-views en Python-bestanden live herlaadt. Dit versnelt het ontwikkelproces van onze Rational onderhoudsschermen aanzienlijk, omdat we onmiddellijk visuele feedback krijgen. Het bespaart onze developers tijd en vermindert de frustratie bij het finetunen van complexe UI-componenten voor de mobiele interface van de techniekers.

# Start Odoo in volledige dev modus voor snelle iteratie
./odoo-bin -c odoo.conf --dev=all -d technicool_dev</pre>
                  
✏ Mini-oefening

Welke specifieke vlag gebruik je om enkel de XML-views live te herladen tijdens development?

👁 Toon oplossing
--dev=xml
2.4 IDE Setup & Linting

Bij TechniCool NV hanteren we strikte kwaliteitsnormen voor onze code, net zoals we dat doen voor het onderhoud van MKN kooklijnen. We configureren VSCode of PyCharm mit specifieke Odoo-extensies en linters om ervoor te zorgen dat elke regel code voldoet aan de 'PEP 8' en Odoo richtlijnen. Door automatisch te controleren op syntaxfouten en stijlproblemen, voorkomen we bugs in de berekeningen van koelmiddelen voor Electrolux blast chillers. Een goed ingestelde IDE biedt ook autocompletion voor de Odoo API, wat cruciaal is voor het snel bouwen van complexe business logica.

# Installeer de Odoo linter via pip voor kwaliteitscontrole
pip3 install pylint-odoo

# Voer de linter uit op de TechniCool service module
pylint --load-plugins=pylint_odoo /opt/odoo/custom/technicool_service</pre>
                  
✏ Mini-oefening

Installeer de `pylint-odoo` plugin in je lokale Python omgeving via de terminal.

👁 Toon oplossing
pip3 install pylint-odoo
2.5 Odoo Shell (De Power Tool)

De Odoo Shell is de favoriete tool van onze senior developers bij TechniCool voor het uitvoeren van snelle data-operaties en debugging. In plaats van via de GUI te navigeren, kunnen we direct Python-commando's uitvoeren tegen de database om bijvoorbeeld de status van alle Rational ovens te controleren. We kunnen records opzoeken, velden aanpassen en complexe ORM-methoden testen in een interactieve console. Dit is onmisbaar bij het migreren van data of het snel valideren van nieuwe logica voor de Vanventory voorraadbeheer module.

# Open de interactieve Odoo Shell voor TechniCool
./odoo-bin shell -c odoo.conf -d technicool_prod

# In de shell: tel alle klanten in de database
>>> env['res.partner'].search_count([])</pre>
                  
✏ Mini-oefening

Schrijf het shell commando om het aantal records in het model `res.users` te tellen.

👁 Toon oplossing
env['res.users'].search_count([])
2.6 Database Management CLI

Voor het veilig testen van nieuwe updates aan de Electrolux service-workflow bij TechniCool NV maken we vaak kopieën van onze database via de command line. Odoo biedt krachtige CLI-tools om databases aan te maken, te dupliceren of te verwijderen zonder de webinterface te gebruiken. We leren ook hoe we databases kunnen 'neutraliseren' zodat we geen echte e-mails naar klanten sturen tijdens het testen van de Vanventory app. Dit proces garandeert dat onze productie-omgeving altijd stabiel blijft terwijl we volop innoveren in onze staging-omgevingen op Hetzner.

# Maak een nieuwe database aan via de PostgreSQL CLI
createdb -U odoo technicool_test

# Initialiseer Odoo op de nieuwe DB met onze custom module
./odoo-bin -d technicool_test -i technicool_service --stop-after-init</pre>
                  
✏ Mini-oefening

Hoe maak je een nieuwe database aan met de naam `training_db` via PostgreSQL commando's?

👁 Toon oplossing
createdb training_db
2.7 PostgreSQL voor Odoo

Odoo vertrouwt volledig op PostgreSQL voor het opslaan van alle data van onze keukentoestellen en interventies bij TechniCool. Het is essentieel om te begrijpen hoe Odoo verbinding maakt met de database en hoe we de prestaties kunnen monitoren via de Postgres logs. We leren hoe we trage queries kunnen identificeren die de laadtijd van de Rational service-historiek beïnvloeden. Door de database correct te indexeren en te onderhouden, zorgen we ervoor dat de Vanventory applicatie razendsnel blijft reageren, zelfs wanneer we duizenden records aan onderdelen beheren.

# Bekijk de actieve connecties in PostgreSQL for monitoring
sudo -u postgres psql -c "SELECT * FROM pg_stat_activity;"

# Check database grootte van de TechniCool productie DB
sudo -u postgres psql -c "SELECT pg_size_pretty(pg_database_size('technicool_prod'));"</pre>
                  
✏ Mini-oefening

Welke psql vlag of commando gebruik je om de lijst van alle databases in de terminal te tonen?

👁 Toon oplossing
psql -l of sudo -u postgres psql -l
2.8 Log Files Analyseren

Bij TechniCool NV is de Odoo log-file de eerste plek waar we kijken als er iets misloopt bij de synchronisatie van de Vanventory app. We leren hoe we de log-levels kunnen aanpassen naar 'debug' om gedetailleerde informatie te krijgen over elke actie die de server uitvoert. Het begrijpen van foutmeldingen zoals 'AccessError' helpt ons om snel security-problemen op te lossen voor onze dispatchers. Door de logs effectief te filteren en te analyseren, kunnen we proactief reageren op technische issues voordat de techniekers op de baan er last van ondervinden bij hun Electrolux interventies.

# Volg de Odoo log in real-time op de TechniCool server
tail -f /var/log/odoo/odoo-server.log | grep "ERROR"

# Zoek naar specifieke validatie fouten in de log geschiedenis
grep -C 5 "ValidationError" /var/log/odoo/odoo-server.log</pre>
                  
✏ Mini-oefening

Welk Linux commando gebruik je om enkel de laatste 100 regels van de Odoo log te bekijken?

👁 Toon oplossing
tail -n 100 /var/log/odoo/odoo-server.log
# Voorbeeld odoo.conf voor TechniCool Development
[options]
; Paden naar alle relevante addon mappen
addons_path = /opt/odoo/addons,/opt/odoo/odoo/addons,/mnt/data/vanventory/addons
; Database connectie parameters
db_host = localhost
db_port = 5432
db_user = odoo
db_password = technicool_secret
; Server poort en development modus
http_port = 8069
dev_mode = all
; Logging configuratie
logfile = /var/log/odoo/odoo-server.log
log_level = debug

# Starten via de terminal op Hetzner:
# ./odoo-bin -c odoo.conf -d technicool_dev --stop-after-init

# Krachtige commando's in de Odoo Shell:
from odoo import api
# Zoek alle klanten met een Gmail adres
partners = env['res.partner'].search([('email', 'ilike', '@gmail.com')])
for p in partners:
    print(f"Klant: {p.name} - Email: {p.email}")

# Totaal aantal TechniCool toestellen tellen
count = env['techniecool.equipment'].search_count([])
print(f"Totaal toestellen in beheer: {count}")</pre>
            
⚙ Praktijkopdracht

Zet een volledige Odoo development omgeving op. Maak een database technicool_test aan. Open de Odoo shell en voer 10 verschillende zoekopdrachten uit op het model res.partner. Zoek bijvoorbeeld alle klanten in een specifieke stad of met een specifiek e-mail domein.

H3
Models & Fields — Het Hart van Odoo
// datamodel, relations, attributes, constraints
Week 3 · 8u

Alles in Odoo draait om data. In dit hoofdstuk bouwen we de datastructuur voor TechniCool NV. We definiëren de modellen voor onze keukentoestellen, technici en locaties. Je leert hoe je relaties legt tussen verschillende tabellen en welke veldtypes het meest geschikt zijn voor specifieke data zoals koelmiddel-hoeveelheden of installatie-datums. Het datamodel is het fundament waarop Vanventory rust.

3.1 Modellen Definiëren (ORM Basis)

Elk keukentoestel van TechniCool NV begint als een Python-klasse die erft van models.Model. Odoo vertaalt deze klasse automatisch naar een PostgreSQL-tabel. TransientModel gebruik je voor tijdelijke wizards, zoals een diagnoseformulier. AbstractModel dient als mixin-template zonder eigen tabel. Het attribuut _name is de unieke identifier; _description geeft een leesbare naam in de interface en in foutmeldingen.

from odoo import models, fields

class TechniCoolEquipment(models.Model):
    _name = 'techniecool.equipment'
    _description = 'Professioneel Keukentoestel'
    _order = 'install_date desc'

    name = fields.Char(string='Naam', required=True)</pre>
✏ Mini-oefening

Maak een klasse TechniCoolTechnician aan met _name = 'techniecool.technician' en een verplicht Char-veld name.

👁 Toon oplossing
class TechniCoolTechnician(models.Model): _name = 'techniecool.technician' _description = 'Technieker' name = fields.Char(string='Naam', required=True)
3.2 Basis Field Types

Odoo biedt een rijke set veldtypes die elk naar een specifiek SQL-type mappen. Voor TechniCool gebruiken we Char voor serienummers, Text voor probleemomschrijvingen, en Float voor gasdruk of stroomverbruik. Boolean slaat garantie-status op, terwijl Date installatiedatums bijhoudt. De keuze van het juiste veldtype heeft directe impact op validatie, weergave en performantie.

name = fields.Char(string='Naam', required=True)
serial = fields.Char(string='Serienummer', copy=False)
power_kw = fields.Float(string='Vermogen (kW)', digits=(16, 2))
install_date = fields.Date(string='Installatiedatum')
is_active = fields.Boolean(string='Actief', default=True)
notes = fields.Text(string='Technische Opmerkingen')</pre>
✏ Mini-oefening

Voeg aan het equipment-model een Float-veld gas_pressure toe voor de gemeten gasdruk in mBar.

👁 Toon oplossing
gas_pressure = fields.Float(string='Gasdruk (mBar)', digits=(16, 2))
3.3 Selection & Date Fields

Het Selection-veld definieert een vaste lijst keuzes, ideaal voor het merk van een keukentoestel. Odoo slaat de technische key op maar toont de leesbare label. Date werkt met Python date-objecten. Datetime is tijdzone-bewust en wordt intern als UTC opgeslagen — cruciaal bij planning van TechniCool-interventies over meerdere Belgische vestigingen. Gebruik default=fields.Date.today om automatisch de huidige datum in te vullen.

brand = fields.Selection([
    ('rational', 'Rational'),
    ('electrolux', 'Electrolux'),
    ('mkn', 'MKN')
], string='Merk', default='rational')
install_date = fields.Date(
    string='Installatiedatum',
    default=fields.Date.today
)</pre>
✏ Mini-oefening

Voeg een Selection-veld refrigerant_type toe met opties R404A, R134a en R290.

👁 Toon oplossing
refrigerant_type = fields.Selection([ ('r404a', 'R404A'), ('r134a', 'R134a'), ('r290', 'R290') ], string='Koelmiddel')
3.4 Many2one Relaties

Een Many2one-veld legt een koppeling naar een enkel record in een andere tabel. Voor TechniCool koppelen we elk toestel aan een klant via res.partner. Het attribuut ondelete bepaalt wat er met het equipment-record gebeurt als de klant verwijderd wordt: restrict blokkeert dit, cascade verwijdert mee, en set null laat het veld leeg. Het domain-attribuut filtert de keuzelijst in de UI.

owner_id = fields.Many2one(
    'res.partner',
    string='Eigenaar',
    ondelete='restrict'
)
location_id = fields.Many2one(
    'res.partner',
    string='Locatie',
    domain="[('type','=','delivery')]"
)</pre>
✏ Mini-oefening

Voeg een Many2one-veld assigned_technician_id toe dat linkt naar hr.employee.

👁 Toon oplossing
assigned_technician_id = fields.Many2one( 'hr.employee', string='Toegewezen Technieker' )
3.5 One2many Relaties

Een One2many-veld heeft geen eigen databasekolom; het is de inverse kant van een Many2one in het gekoppelde model. Voor TechniCool toont het alle interventies van één toestel op de detailpagina. Je geeft altijd de naam van het bijhorende Many2one-veld op als derde argument. Zo navigeer je vanuit het toestel naar alle bijhorende werkorders zonder extra database-queries.

intervention_ids = fields.One2many(
    'fsm.order',
    'equipment_id',
    string='Interventies'
)
part_ids = fields.One2many(
    'techniecool.part.usage',
    'equipment_id',
    string='Gebruikte Onderdelen'
)</pre>
✏ Mini-oefening

Definieer een One2many-veld photo_ids dat verwijst naar techniecool.equipment.photo via het veld equipment_id.

👁 Toon oplossing
photo_ids = fields.One2many( 'techniecool.equipment.photo', 'equipment_id', string="Foto's" )
3.6 Many2many Relaties

Een Many2many-veld verbindt twee modellen waarbij records aan beide kanten meerdere records aan de andere kant kunnen hebben. Odoo maakt automatisch een hulptabel aan in de database. Voor TechniCool gebruiken we dit voor technicercertificeringen: een technieker heeft meerdere certificaten en een certificaat kan door meerdere technici behaald zijn. Je kunt de naam van de hulptabel en kolomnamen zelf opgeven voor extra controle.

certification_ids = fields.Many2many(
    'techniecool.certification',
    'tech_cert_rel',
    'technician_id',
    'certification_id',
    string='Certificaties'
)</pre>
✏ Mini-oefening

Voeg een Many2many-veld tag_ids toe aan het equipment-model dat verwijst naar techniecool.tag.

👁 Toon oplossing
tag_ids = fields.Many2many( 'techniecool.tag', string='Labels' )
3.7 Field Attributen & Validatie

Velden in Odoo hebben attributen die gedrag en validatie sturen. required=True verplicht invulling, readonly=True maakt het veld niet bewerkbaar, en index=True voegt een database-index toe voor snellere zoekopdrachten. copy=False zorgt dat het veld niet meegekopieerd wordt bij duplicatie — essentieel voor unieke serienummers van Rational-stoomovens. groups beperkt zichtbaarheid tot specifieke Odoo-gebruikersgroepen.

serial_number = fields.Char(
    string='Serienummer',
    required=True,
    copy=False,
    index=True
)
purchase_price = fields.Float(
    string='Aankoopprijs',
    groups='techniecool_base.group_manager'
)</pre>
✏ Mini-oefening

Maak een Char-veld qr_code aan dat niet kopieerbaar is, verplicht is en geïndexeerd.

👁 Toon oplossing
qr_code = fields.Char( string='QR Code', required=True, copy=False, index=True )
3.8 SQL Constraints

Naast Python-validatie kun je ook database-level constraints toevoegen via _sql_constraints. Dit garandeert data-integriteit zelfs bij directe SQL-commando's. Voor TechniCool is het cruciaal dat elk serienummer uniek is — twee Electrolux blast chillers met hetzelfde serienummer zou de stock en garantiebeheer volledig verstoren. De constraint bestaat uit een naam, een PostgreSQL-expressie en een foutmelding voor de gebruiker.

_sql_constraints = [
    (
        'serial_unique',
        'UNIQUE(serial_number)',
        'Het serienummer moet uniek zijn!'
    ),
    (
        'power_positive',
        'CHECK(power_kw > 0)',
        'Vermogen moet positief zijn!'
    )
]</pre>
✏ Mini-oefening

Voeg een SQL constraint toe die garandeert dat power_kw groter dan 0 is.

👁 Toon oplossing
_sql_constraints = [ ('power_positive', 'CHECK(power_kw > 0)', 'Vermogen moet positief zijn!') ]
# TechniCool Equipment Datamodel - Vak 1 H3
from odoo import models, fields

class TechniCoolEquipmentCategory(models.Model):
    _name = 'techniecool.equipment.category'
    _description = 'Toestel Categorie'
    name = fields.Char(string='Categorie Naam', required=True)

class TechniCoolEquipment(models.Model):
    _name = 'techniecool.equipment'
    _description = 'Professioneel Keukentoestel'
    _order = 'install_date desc, name'

    name = fields.Char(string='Naam', required=True)
    category_id = fields.Many2one('techniecool.equipment.category', string='Categorie')
    model_name = fields.Char(string='Model')
    serial_number = fields.Char(string='Serienummer', copy=False)
    install_date = fields.Date(string='Installatiedatum', default=fields.Date.today)
    
    brand = fields.Selection([
        ('rational', 'Rational'),
        ('electrolux', 'Electrolux'),
        ('mkn', 'MKN')
    ], string='Merk', default='rational')

    owner_id = fields.Many2one('res.partner', string='Eigenaar', ondelete='restrict')
    intervention_ids = fields.One2many(
        'fsm.order', 
        'equipment_id', 
        string='Interventies'
    )
    
    power_kw = fields.Float(string='Vermogen (kW)', digits=(16, 2))
    is_active = fields.Boolean(string='Actief', default=True)

    _sql_constraints = [
        ('serial_unique', 'unique(serial_number)', 'Het serienummer moet uniek zijn!')
    ]</pre>
            
⚙ Praktijkopdracht

Bouw het techniecool.equipment model volledig uit in een nieuwe Odoo module. Voeg velden toe voor koelmiddel-type (Selection), vermogen in kW (Float) en een HTML-veld voor technische opmerkingen. Zorg dat het serienummer verplicht is en niet gekopieerd kan worden bij het dupliceren van een record.

H4
Computed Fields & Business Logic
// @api.depends, onchange, constrains, validation
Week 4 · 8u

Statische data is niet genoeg. We hebben dynamische berekeningen nodig voor TechniCool's workflows. In dit hoofdstuk leren we hoe we automatische berekeningen maken die reageren op wijzigingen in andere velden (zoals de garantietermijn), en hoe we voorkomen dat technici foutieve data invoeren via Python constraints.

4.1 @api.depends — Computed Fields

Een computed field berekent zijn waarde automatisch op basis van andere velden. Odoo weet wanneer hij opnieuw moet berekenen dankzij de @api.depends-decorator. Voor TechniCool berekenen we automatisch de garantievervaldatum op basis van de installatiedatum van een Rational combi-steamer. Je moet altijd over self itereren omdat methoden op een recordset werken — ook als er maar één record is. Vergeet je dit, dan falen bulk-operaties zoals CSV-import.

@api.depends('install_date')
def _compute_warranty_expiry(self):
    for rec in self:
        if rec.install_date:
            rec.warranty_expiry = rec.install_date + timedelta(days=730)
        else:
            rec.warranty_expiry = False</pre>
✏ Mini-oefening

Schrijf een computed field age_years dat het aantal jaar berekent dat een toestel in gebruik is.

👁 Toon oplossing
@api.depends('install_date') def _compute_age_years(self): for rec in self: if rec.install_date: rec.age_years = (date.today() - rec.install_date).days // 365 else: rec.age_years = 0
4.2 Stored vs Non-stored

Een computed field kan je opslaan in de database (store=True) of enkel berekenen bij opvragen. Stored fields zijn zoekbaar en bruikbaar in lijstweergaven, maar vereisen meer schrijfoperaties. Non-stored fields zijn lichter maar niet doorzoekbaar via domain-filters. Voor de garantiestatus van TechniCool-toestellen gebruiken we store=True zodat dispatch snel kan filteren op 'garantie vervallen' zonder berekeningen te herhalen.

warranty_expiry = fields.Date(
    string='Garantie Vervalt',
    compute='_compute_warranty_expiry',
    store=True  # opgeslagen, zoekbaar
)
warranty_label = fields.Char(
    string='Garantie Label',
    compute='_compute_warranty_label'
    # store=False: enkel weergave
)</pre>
✏ Mini-oefening

Wanneer kies je voor store=True? Schrijf twee redenen als commentaar in je code.

👁 Toon oplossing
# store=True gebruiken als: # 1. Het veld doorzocht moet worden via domain-filter # 2. Het veld getoond wordt in list view met sortering
4.3 @api.onchange — UI Reactiviteit

@api.onchange vuurt direct in de browsersessie zodra een gebruiker een veld aanpast, zonder de database te raken. Dit is ideaal voor het suggereren van standaardwaarden. Bij TechniCool: als een dispatcher het merk wijzigt naar 'Electrolux', vult onchange automatisch de standaard koeltemperatuur in op -18°C. Opgelet: onchange werkt enkel in de form view, niet bij import of server-acties.

@api.onchange('brand')
def _onchange_brand(self):
    if self.brand == 'electrolux':
        self.default_temp = -18.0
        return {'warning': {
            'title': 'Electrolux geselecteerd',
            'message': 'Standaard koeltemperatuur: -18°C'
        }}</pre>
✏ Mini-oefening

Schrijf een @api.onchange('serial_number') die een warning toont als het serienummer minder dan 6 tekens heeft.

👁 Toon oplossing
@api.onchange('serial_number') def _onchange_serial(self): if self.serial_number and len(self.serial_number) < 6: return {'warning': { 'title': 'Kort serienummer', 'message': 'Minstens 6 tekens vereist.' }}
4.4 @api.constrains — Server Validatie

@api.constrains voert validatie uit op de server, na het opslaan maar binnen dezelfde transactie. Als de validatie faalt, wordt de transactie teruggedraaid. Dit is de veiligste manier om businessregels af te dwingen bij TechniCool, want het werkt ook bij import, API-calls en geautomatiseerde acties — in tegenstelling tot onchange. Je gooit een ValidationError als de data ongeldig is.

@api.constrains('install_date')
def _check_install_date(self):
    for rec in self:
        if rec.install_date and rec.install_date > date.today():
            raise ValidationError(
                'Installatiedatum kan niet in de toekomst liggen!'
            )</pre>
✏ Mini-oefening

Schrijf een constraint die verhindert dat power_kw negatief is.

👁 Toon oplossing
@api.constrains('power_kw') def _check_power(self): for rec in self: if rec.power_kw < 0: raise ValidationError('Vermogen mag niet negatief zijn!')
4.5 Inverse Methods

Standaard zijn computed fields read-only. Met een inverse-methode maak je ze bewerkbaar: de inverse-methode vertaalt de nieuwe waarde terug naar de bronvelden. Voor TechniCool: als een dispatcher de garantievervaldatum rechtstreeks aanpast, berekent de inverse-methode de bijhorende installatiedatum. Dit is een elegant patroon voor meer gebruiksvriendelijke formulieren zonder extra velden.

warranty_years = fields.Integer(
    string='Garantiejaren',
    compute='_compute_warranty_years',
    inverse='_set_warranty_years'
)

def _set_warranty_years(self):
    for rec in self:
        rec.install_date = date.today() - timedelta(
            days=rec.warranty_years * 365
        )</pre>
✏ Mini-oefening

Leg uit in commentaar wat er gebeurt als een gebruiker de waarde van een computed field met inverse wijzigt.

👁 Toon oplossing
# De inverse-methode wordt aangeroepen # die de ingevoerde waarde terugvertaalt # naar de onderliggende bronvelden en opslaat.
4.6 Recordsets in Compute

Compute-methoden ontvangen altijd een recordset, ook bij één record. Itereer dus altijd expliciet over self. Als je vergeet te itereren en direct self.field = ... schrijft, werkt het toevallig voor één record maar faalt het bij bulk-operaties zoals importeren of een geplande taak die honderden TechniCool-toestellen tegelijk bijwerkt. Dit is een veelgemaakte beginnersfout in Odoo-ontwikkeling.

@api.depends('intervention_ids.cost')
def _compute_total_cost(self):
    for rec in self:  # altijd itereren!
        rec.total_cost = sum(
            rec.intervention_ids.mapped('cost')
        )</pre>
✏ Mini-oefening

Wat is de fout in: def _compute_x(self): self.x = self.a + self.b?

👁 Toon oplossing
# Fout: geen iteratie over self. # Correct: # def _compute_x(self): # for rec in self: # rec.x = rec.a + rec.b
4.7 sudo() & Security Context

Soms moet een compute-methode data ophalen waarvoor de huidige gebruiker geen rechten heeft. Met self.env['model'].sudo() voer je de operatie uit als superuser. Voor TechniCool: een technieker heeft geen toegang tot loondata, maar de compute-methode voor het teamrapport heeft die wel nodig. Gebruik sudo() spaarzaam en enkel voor legitieme systeemtaken, nooit om beveiligingsregels structureel te omzeilen.

# sudo() voor systeemtaken
def _compute_team_cost(self):
    for rec in self:
        orders = self.env['fsm.order'].sudo().search([
            ('equipment_id', '=', rec.id)
        ])
        rec.team_cost = sum(orders.mapped('cost'))</pre>
✏ Mini-oefening

Wanneer is sudo() gevaarlijk? Geef één concreet voorbeeld.

👁 Toon oplossing
# sudo() is gevaarlijk als het gebruikt wordt # om prijsdata te tonen aan externe gebruikers # of klanten die die data niet mogen zien.
4.8 UserError vs ValidationError

Odoo heeft twee exception-types voor gebruikersfeedback. UserError gebruik je voor businesslogica-fouten die de gebruiker zelf kan oplossen, zoals een ontbrekend serienummer bij TechniCool. ValidationError gebruik je specifiek bij @api.constrains-checks. Beide tonen een popup in de interface. In technische server-acties gebruik je UserError; in constraint-methoden altijd ValidationError.

from odoo.exceptions import UserError, ValidationError

def action_send_to_repair(self):
    for rec in self:
        if not rec.serial_number:
            raise UserError(
                'Serienummer vereist voor herstelling.'
            )</pre>
✏ Mini-oefening

Schrijf een action-methode die een UserError gooit als het toestel geen eigenaar heeft.

👁 Toon oplossing
def action_validate(self): for rec in self: if not rec.owner_id: raise UserError( 'Toestel heeft geen eigenaar. Wijs eerst een klant toe.' )
from odoo import models, fields, api
from odoo.exceptions import ValidationError, UserError
from datetime import date, timedelta

class TechniCoolEquipment(models.Model):
    _inherit = 'techniecool.equipment'

    warranty_expiration = fields.Date(string='Garantie Vervaldatum', store=True, compute='_compute_warranty')
    warranty_status = fields.Selection([
        ('valid', 'Geldig'),
        ('expired', 'Vervallen')
    ], string='Garantie Status', compute='_compute_warranty_status')
    
    @api.depends('install_date')
    def _compute_warranty(self):
        for record in self:
            if record.install_date:
                record.warranty_expiration = record.install_date + timedelta(days=730)
            else:
                record.warranty_expiration = False

    @api.depends('warranty_expiration')
    def _compute_warranty_status(self):
        today = date.today()
        for record in self:
            if record.warranty_expiration and record.warranty_expiration < today:
                record.warranty_status = 'expired'
            else:
                record.warranty_status = 'valid'

    @api.constrains('install_date')
    def _check_install_date(self):
        for record in self:
            if record.install_date and record.install_date > date.today():
                raise ValidationError("Installatiedatum kan niet in de toekomst liggen!")</pre>
            
⚙ Praktijkopdracht

Voeg een computed field total_intervention_cost toe aan het equipment model. Dit veld moet de som berekenen van alle gekoppelde interventies. Zorg dat het veld herberekent telkens wanneer een interventie wordt toegevoegd of gewijzigd.

H5
ORM Methods — CRUD & Search
// search, browse, create, write, mapped
Week 5 · 8u

Hoe halen we data op uit de database? Odoo's ORM methoden zijn krachtiger dan simpele SQL queries. We leren hoe we complexe zoekopdrachten (domains) schrijven om bijvoorbeeld alle technici met een specifieke certificering te vinden die vandaag beschikbaar zijn. Ook kijken we naar de performantie: hoe vermijden we het beruchte N+1 probleem?

5.1 Domain Syntax

Odoo gebruikt een query-taal gebaseerd op Poolse prefix-notatie voor het filteren van records. Een domain is een lijst van condities met optionele logische operatoren & (AND) en | (OR). Voor TechniCool zoeken we alle Rational stoomovens die geïnstalleerd zijn vóór 2022 en momenteel in garantie. Zonder de Poolse notatie te begrijpen schrijf je filters die verkeerde resultaten teruggeven.

domain = [
    '&',
    ('brand', '=', 'rational'),
    ('warranty_expiry', '>=', fields.Date.today())
]
equipment = self.env['techniecool.equipment'].search(domain)</pre>
✏ Mini-oefening

Schrijf een domain dat alle Electrolux-toestellen zoekt met een vermogen boven 5 kW.

👁 Toon oplossing
domain = [ '&', ('brand', '=', 'electrolux'), ('power_kw', '>', 5.0) ]
5.2 search() en search_read()

search() retourneert een recordset van volledige ORM-objecten. search_read() combineert zoeken en ophalen van specifieke velden in één database-query. Voor TechniCool dispatch: als we enkel naam en serienummer nodig hebben van 500 toestellen, is search_read() veel sneller omdat Odoo niet de volledige recordset hoeft te laden. Gebruik limit en offset voor paginering.

# Volledige recordset
recs = self.env['techniecool.equipment'].search(
    [('brand', '=', 'rational')], limit=100
)

# Enkel naam + serienummer: sneller
data = self.env['techniecool.equipment'].search_read(
    [('brand', '=', 'rational')],
    ['name', 'serial_number']
)</pre>
✏ Mini-oefening

Gebruik search_read() om naam en install_date op te halen van alle Electrolux-toestellen.

👁 Toon oplossing
data = self.env['techniecool.equipment'].search_read( [('brand', '=', 'electrolux')], ['name', 'install_date'] )
5.3 create(), write(), unlink()

De drie basis-schrijfoperaties van het Odoo ORM. create() maakt een nieuw record aan via een dictionary, write() updatet een recordset met gedeelde waarden, en unlink() verwijdert records. Je kunt deze methoden overriden om extra logica toe te voegen — zoals het automatisch aanmaken van een onboarding-taak wanneer een nieuw TechniCool-toestel geregistreerd wordt.

class TechniCoolEquipment(models.Model):
    _inherit = 'techniecool.equipment'

    @api.model
    def create(self, vals):
        record = super().create(vals)
        record._create_onboarding_task()
        return record

    def _create_onboarding_task(self):
        self.env['fsm.order'].create({
            'name': f'Inbedrijfstelling {self.name}',
            'equipment_id': self.id
        })</pre>
✏ Mini-oefening

Override de write() methode zodat bij elke wijziging een logbericht gepost wordt op de chatter.

👁 Toon oplossing
def write(self, vals): res = super().write(vals) self.message_post(body='Record bijgewerkt') return res
5.4 mapped(), filtered(), sorted()

Deze drie methoden zijn krachtige Python-side hulpmiddelen om recordsets te bewerken zonder extra database-queries. mapped() haalt een lijst van veldwaarden op of past een functie toe. filtered() filtert records op basis van een lambda. sorted() sorteert. Voor TechniCool: bereken de totale kostprijs van alle afgesloten interventies op een toestel in één expressie.

interventions = equipment.intervention_ids

# Enkel afgesloten orders
done = interventions.filtered(lambda r: r.state == 'done')

# Gesorteerd op datum
recent = done.sorted('date_done', reverse=True)

# Totale kost
total = sum(done.mapped('cost'))</pre>
✏ Mini-oefening

Gebruik filtered() om alle interventies te selecteren met een kost boven 500 euro.

👁 Toon oplossing
expensive = equipment.intervention_ids.filtered( lambda r: r.cost > 500.0 )
5.5 read_group() — Aggregatie

read_group() is het equivalent van SQL GROUP BY in Odoo. Voor TechniCool gebruiken we het om het totaal aantal interventies en de gemiddelde kostprijs per merk te berekenen. Dit is de basis voor het management-dashboard dat directie gebruikt om te beslissen welke toestellen de meeste onderhoudskost genereren. Het resultaat is een lijst van dictionaries met geaggregeerde waarden.

result = self.env['techniecool.equipment'].read_group(
    domain=[],
    fields=['brand', 'intervention_count:count(id)'],
    groupby=['brand']
)

for row in result:
    print(f"Merk: {row['brand']} — {row['intervention_count']} interventies")</pre>
✏ Mini-oefening

Schrijf een read_group() die het totale vermogen (som van power_kw) per merk berekent.

👁 Toon oplossing
result = self.env['techniecool.equipment'].read_group( domain=[], fields=['brand', 'power_kw:sum'], groupby=['brand'] )
5.6 Prefetching & Performance

Odoo laadt veldwaarden automatisch in batches via prefetching. Als je een recordset doorloopt en een veld opvraagt, haalt Odoo alle records in de recordset tegelijk op. Dit vermijdt het N+1-probleem. Maar als je te veel relaties doorloopt, genereer je toch zware queries. Voor TechniCool bulk-operaties gebruik je search_read() of with_prefetch() om dit te optimaliseren.

# Slecht: potentieel N+1 queries
for eq in equipment_list:
    name = eq.owner_id.name  # extra query per record

# Goed: prefetch via search_read
data = self.env['techniecool.equipment'].search_read(
    [], ['name', 'owner_id']
)</pre>
✏ Mini-oefening

Leg in commentaar uit waarom search_read() sneller is dan search() gevolgd door een veld-loop.

👁 Toon oplossing
# search_read() haalt enkel de gevraagde velden # op in één enkele SQL-query. # search() laadt volledige records en triggert # extra queries bij elke veldtoegang.
5.7 with_context()

Het context-woordenboek in Odoo stuurt gedrag van methoden zonder hun signatuur te veranderen. Via with_context() geef je opties mee zoals de actieve taal of een custom vlag. Voor TechniCool: we schakelen via context de automatische e-mailnotificatie uit bij bulk-import van toestellen om de mailbox van klanten niet te overspoelen. Context-waarden worden doorgegeven doorheen de volledige call-stack.

# Import zonder e-mailnotificaties
env_no_mail = self.env['techniecool.equipment'].with_context(
    mail_notrack=True,
    no_recompute=True
)
env_no_mail.create(vals_list)</pre>
✏ Mini-oefening

Gebruik with_context(lang='nl_BE') om een record op te halen in het Nederlands.

👁 Toon oplossing
rec = self.env['techniecool.equipment'].with_context( lang='nl_BE' ).browse(equipment_id)
5.8 sudo() & Rechten

sudo() laat je een ORM-operatie uitvoeren als superuser, voorbij de normale toegangsrechten. Dit is essentieel voor server-side taken zoals geplande jobs of inter-model updates. Bij TechniCool gebruiken we sudo() in de planningstool om technicienagenda's te raadplegen vanuit de context van een fieldservice-order waarbij de huidige gebruiker geen HR-rechten heeft. Gebruik het spaarzaam — te veel sudo() is een beveiligingsrisico.

# Systeemtaak: update vervallen garanties
def _cron_update_warranty_states(self):
    expired = self.env['techniecool.equipment'].sudo().search([
        ('warranty_expiry', '<', fields.Date.today()),
        ('warranty_status', '!=', 'expired')
    ])
    expired.sudo().write({'warranty_status': 'expired'})</pre>
✏ Mini-oefening

Schrijf een methode die sudo() gebruikt om het aantal records in hr.employee te tellen.

👁 Toon oplossing
def count_employees(self): return self.env['hr.employee'].sudo().search_count([])
# Geavanceerde ORM acties voor TechniCool Dispatch

def get_available_technicians(self, equipment_brand):
    # Zoek alle technici met ervaring in dit merk
    domain = [
        ('experience_ids.brand', '=', equipment_brand),
        ('is_available', '=', True)
    ]
    return self.env['hr.employee'].search(domain)

# Voorbeeld van mapped() en filtered()
interventions = steamer.intervention_ids
total_hours = sum(interventions.filtered(
    lambda r: r.state == 'done'
).mapped('duration_hours'))

# Overschrijven van create() voor volgnummer
@api.model
def create(self, vals):
    if vals.get('serial_number', 'New') == 'New':
        vals['serial_number'] = self.env['ir.sequence'].next_by_code(
            'techniecool.equipment'
        ) or 'New'
    return super(TechniCoolEquipment, self).create(vals)</pre>
            
⚙ Praktijkopdracht

Schrijf een methode in de Odoo shell die: 1. Alle technici zoekt met meer dan 10 afgeronde interventies. 2. Voor deze technici de totale herstelkost berekent van alle Rational toestellen die ze behandeld hebben. 3. De resultaten print als een geordende lijst van hoogste naar laagste kost.

H6
Inheritance & Extension
// _inherit, _inherits, view xpath
Week 6 · 8u

We hoeven niet alles zelf te bouwen. Odoo is gebouwd op overerving. We kunnen bestaande modellen zoals `fsm.order` (van de OCA) uitbreiden met TechniCool velden zonder de broncode van de originele module te wijzigen. Dit is cruciaal voor onderhoudbaarheid en updates. We leren klassieke inheritance, delegatie en de kracht van XPath in views.

6.1 Class Inheritance (_inherit)

Met _inherit breidt je een bestaand Odoo-model uit zonder de originele code te wijzigen. Odoo slaat alles op in dezelfde databasetabel. Dit is het meest gebruikte overerving-patroon bij TechniCool: we voegen onze eigen velden toe aan het standaard fsm.order-model van de OCA Field Service module. Zo profiteren we van alle OCA-functionaliteit terwijl we onze specifieke velden bewaren.

class FsmOrderTechniCool(models.Model):
    _inherit = 'fsm.order'

    equipment_id = fields.Many2one(
        'techniecool.equipment',
        string='Toestel'
    )
    fault_code = fields.Char(string='Foutcode')
    gas_pressure = fields.Float(string='Gasdruk (mBar)')</pre>
✏ Mini-oefening

Voeg een Boolean-veld is_emergency toe aan fsm.order via class inheritance.

👁 Toon oplossing
class FsmOrderTechniCool(models.Model): _inherit = 'fsm.order' is_emergency = fields.Boolean(string='Dringend')
6.2 Prototype Inheritance

Als je zowel _inherit als _name opgeeft, maak je een nieuw model dat alle velden en methoden van het origineel kopieert maar in een aparte tabel leeft. Dit is handig bij TechniCool als we een apart model willen voor gearchiveerde toestellen met dezelfde structuur maar los van de actieve stock. Het originele model wordt niet gewijzigd en beide modellen evolueren onafhankelijk.

class TechniCoolEquipmentArchive(models.Model):
    _name = 'techniecool.equipment.archive'
    _inherit = 'techniecool.equipment'
    _description = 'Gearchiveerd Keukentoestel'

    archive_reason = fields.Text(string='Reden Archivering')</pre>
✏ Mini-oefening

Leg in commentaar uit wanneer je prototype inheritance kiest boven class inheritance.

👁 Toon oplossing
# Prototype inheritance kies je als: # - Je een aparte databasetabel wil # - De twee modellen onafhankelijk moeten evolueren # - Je het originele model niet wil aanpassen
6.3 Delegation Inheritance (_inherits)

_inherits is delegatie-overerving: het nieuwe model bevat een Many2one naar het bovenliggende model en delegeert alle veldtoegang eraan. Dit lijkt op compositie in OOP. Bij TechniCool gebruik je dit als je res.partner-functionaliteit wil inbedden in een eigen model zoals techniecool.client, zonder alle partnervelden zelf te herdefiniëren. Handig voor contactbeheer gecombineerd met TechniCool-specifieke data.

class TechniCoolClient(models.Model):
    _name = 'techniecool.client'
    _inherits = {'res.partner': 'partner_id'}

    partner_id = fields.Many2one(
        'res.partner',
        required=True,
        ondelete='cascade'
    )
    client_code = fields.Char(string='Klantnummer')</pre>
✏ Mini-oefening

Wat is het verschil tussen _inherit en _inherits? Geef één zin per type.

👁 Toon oplossing
# _inherit: breidt hetzelfde model/tabel uit # _inherits: maakt een nieuw model dat delegeert # aan een ander model via Many2one
6.4 View Inheritance (XPath)

Views worden ook overgeërfd via XML. Je verwijst naar de originele view met inherit_id en gebruikt <xpath>-expressies om elementen toe te voegen, te verwijderen of te vervangen. Voor TechniCool voegen we het veld equipment_id toe aan het standaard FSM-orderformulier zonder de originele module te wijzigen. position-attributen (before, after, replace, inside) bepalen waar de aanpassing komt.

<!-- Voeg equipment toe aan FSM form -->
<record id="fsm_order_form_inherit" model="ir.ui.view">
  <field name="inherit_id" ref="fieldservice.fsm_order_form"/>
  <field name="arch" type="xml">
    <xpath expr="//field[@name='name']" position="after">
      <field name="equipment_id"/>
    </xpath>
  </field>
</record></pre>
✏ Mini-oefening

Schrijf een XPath die een knop 'Urgente Interventie' toevoegt vóór de knop met name='action_confirm'.

👁 Toon oplossing
6.5 super() & Method Inheritance

Wanneer je een methode overridet, roep je altijd super() aan om de originele logica te bewaren. Vergeet je dit, dan verbreek je de functionaliteit van de parent-module. Bij TechniCool roepen we bij het afsluiten van een werkorder eerst de OCA Field Service logica aan, en voegen daarna onze eigen TechniCool-stap toe. De volgorde (super() voor of na eigen logica) bepaalt de prioriteit.

class FsmOrderTechniCool(models.Model):
    _inherit = 'fsm.order'

    def action_complete(self):
        res = super().action_complete()  # OCA logica eerst
        for rec in self:
            rec.equipment_id.last_service_date = fields.Date.today()
        return res</pre>
✏ Mini-oefening

Waarom is het gevaarlijk om super() weg te laten bij het overriden van create()?

👁 Toon oplossing
# Zonder super() sla je de ORM-logica over: # geen audit trail, geen mail.thread tracking, # geen sequence-generatie van parent-modules.
6.6 mail.thread & Chatter Mixin

Door mail.thread en mail.activity.mixin toe te voegen aan je model activeer je de Odoo chatter, activiteiten en tracking. Veld-tracking configureer je via tracking=True op individuele velden. Voor TechniCool logt dit automatisch elke statuswijziging of garantie-update op het toestelrecord, zodat de servicemanager de volledige geschiedenis kan raadplegen en audits kan uitvoeren.

class TechniCoolEquipment(models.Model):
    _name = 'techniecool.equipment'
    _inherit = ['mail.thread', 'mail.activity.mixin']

    state = fields.Selection([
        ('active', 'Actief'),
        ('repair', 'In Herstelling')
    ], tracking=True)</pre>
✏ Mini-oefening

Hoe post je een chatter-bericht vanuit Python-code?

👁 Toon oplossing
self.message_post( body='Toestel overgedragen naar nieuwe klant.', message_type='comment' )
6.7 Abstract Models (Mixins)

Een AbstractModel heeft geen eigen databasetabel maar dient als herbruikbare basis. Dit is het Odoo-equivalent van een mixin-klasse in Python. Bij TechniCool maken we een TechniCoolServiceMixin met gemeenschappelijke servicevelden (garantieduur, serviceniveau) die we hergebruiken in zowel het equipment- als het contractmodel. Dit vermijdt duplicatie en centraliseert businesslogica.

class TechniCoolServiceMixin(models.AbstractModel):
    _name = 'techniecool.service.mixin'

    warranty_years = fields.Integer(default=2)
    service_level = fields.Selection([
        ('basic', 'Basis'),
        ('premium', 'Premium')
    ])

class Equipment(models.Model):
    _name = 'techniecool.equipment'
    _inherit = ['techniecool.service.mixin']</pre>
✏ Mini-oefening

Maak een AbstractModel techniecool.timestamped.mixin met een veld last_checked (Datetime).

👁 Toon oplossing
class TechniCoolTimestampedMixin(models.AbstractModel): _name = 'techniecool.timestamped.mixin' last_checked = fields.Datetime(string='Laatste Check')
6.8 _order, _rec_name & Modelattributen

Je kunt modelattributen van een parent overschrijven via class inheritance. _order verandert de standaardsortering, _rec_name bepaalt welk veld als displaynaam gebruikt wordt in Many2one-dropdowns. Voor TechniCool willen we dat toestellen gesorteerd worden op installatiedatum (recentste eerst) en dat het serienummer de naam is in dropdowns. De methode name_get() laat je een volledig aangepaste weergave definiëren.

class TechniCoolEquipment(models.Model):
    _inherit = 'techniecool.equipment'
    _order = 'install_date desc, name'
    _rec_name = 'serial_number'

    def name_get(self):
        result = []
        for rec in self:
            result.append((rec.id, f'[{rec.serial_number}] {rec.name}'))
        return result</pre>
✏ Mini-oefening

Overschrijf _order zodat toestellen gesorteerd worden op merknaam en dan op naam.

👁 Toon oplossing
class TechniCoolEquipment(models.Model): _inherit = 'techniecool.equipment' _order = 'brand, name'
# Uitbreiden van OCA Field Service Order
class FsmOrder(models.Model):
    _inherit = 'fsm.order'

    equipment_id = fields.Many2one(
        'techniecool.equipment', 
        string='Toestel'
    )
    refrigerant_added = fields.Float(string='Koelmiddel toegevoegd (kg)')

    def action_complete(self):
        # Eigen logica vóór afronden
        if self.refrigerant_added > 5.0:
            self.message_post(body="Let op: Grote hoeveelheid koelmiddel toegevoegd!")
        
        # Roep originele OCA methode aan
        return super(FsmOrder, self).action_complete()</pre>
            
⚙ Praktijkopdracht

Extend het model res.partner met een boolean veld is_technicool_customer. Pas de partner view aan via een XML inheritance (XPath) zodat dit veld zichtbaar is onder het BTW nummer. Zorg dat de chatter (historiek) ook zichtbaar is op je eigen techniecool.equipment model.

H7
Wizards, Actions & Sequences
// TransientModel, sequences, server actions
Week 7 · 8u

Gebruikersvriendelijkheid is key. Een technieker wil niet 20 velden manueel invullen. We bouwen "Wizards" (tijdelijke vensters) om complexe acties zoals "Maak Interventie" te vereenvoudigen. Ook leren we hoe Odoo unieke nummers genereert (WO/2024/001) en hoe we geplande taken (Crons) instellen om bijvoorbeeld elke ochtend de stock in de bestelwagens te checken.

7.1 TransientModel — Wizard Basis

Een wizard is een tijdelijk formulier voor complexe acties. Wizards erven van models.TransientModel: records worden automatisch verwijderd na een configureerbare tijd. Voor TechniCool bouwen we een 'Bulk Garantie Update'-wizard waarmee de dispatcher in één stap de garantietermijn kan verlengen voor alle toestellen van een klant na een service-contract. De wizard-methode voert de actie uit en kan daarna een action retourneren.

class TechniCoolWarrantyWizard(models.TransientModel):
    _name = 'techniecool.warranty.wizard'
    _description = 'Garantie Update Wizard'

    partner_id = fields.Many2one('res.partner', required=True)
    new_expiry = fields.Date(string='Nieuwe Vervaldatum', required=True)

    def action_apply(self):
        equipment = self.env['techniecool.equipment'].search([
            ('owner_id', '=', self.partner_id.id)
        ])
        equipment.write({'warranty_expiry': self.new_expiry})</pre>
✏ Mini-oefening

Maak een wizard techniecool.assign.wizard met een Many2one naar hr.employee.

👁 Toon oplossing
class TechniCoolAssignWizard(models.TransientModel): _name = 'techniecool.assign.wizard' technician_id = fields.Many2one('hr.employee', required=True) def action_assign(self): pass # implementatie hier
7.2 Window Actions

Een ir.actions.act_window-actie opent een Odoo-view (form, list, kanban). Je definieert welk model, welke view en welk domain geladen wordt. Bij TechniCool openen we via een knop in de klantenfiche een gefilterde lijst van alle toestellen van die klant. Context-variabelen zoals default_partner_id vullen automatisch velden in op nieuwe records die aangemaakt worden via die view.

<record id="action_equipment_by_partner" model="ir.actions.act_window">
  <field name="name">Toestellen Klant</field>
  <field name="res_model">techniecool.equipment</field>
  <field name="view_mode">list,form</field>
  <field name="domain">[('owner_id','=',active_id)]</field>
  <field name="context">{"default_owner_id": active_id}</field>
</record></pre>
✏ Mini-oefening

Definieer een act_window die de kanban-weergave van techniecool.equipment opent gefilterd op actieve toestellen.

👁 Toon oplossing
Actieve Toestellen techniecool.equipment kanban,list,form [('is_active', '=', True)]
7.3 Server Actions

Een ir.actions.server voert Python-code uit vanuit de interface. Ideaal voor knoppen in de lijst die een batch-operatie uitvoeren. Bij TechniCool gebruiken we een server action om alle geselecteerde toestellen tegelijk in 'In Herstelling'-staat te zetten. Server actions kun je toewijzen aan de actie-dropdown in list views of koppelen aan geplande taken.

<record id="action_set_repair" model="ir.actions.server">
  <field name="name">Zet op In Herstelling</field>
  <field name="model_id" ref="model_techniecool_equipment"/>
  <field name="binding_model_id" ref="model_techniecool_equipment"/>
  <field name="state">code</field>
  <field name="code">records.write({'state': 'repair'})</field>
</record></pre>
✏ Mini-oefening

Schrijf een server action die alle geselecteerde toestellen markeert als actief.

👁 Toon oplossing
Activeer Toestellen code records.write({'is_active': True})
7.4 ir.sequence — Volgnummers

Odoo's ir.sequence-model genereert automatisch oplopende nummers met een configureerbaar prefix en padding. Voor TechniCool gebruiken we sequences voor unieke werkordernummers (WO/2024/00042). De sequence wordt geconfigureerd in XML en opgeroepen via self.env['ir.sequence'].next_by_code() in de create()-override. Het jaar-placeholder %(year)s wordt automatisch ingevuld door Odoo.

<record id="seq_techniecool_workorder" model="ir.sequence">
  <field name="name">TechniCool Werkorder</field>
  <field name="code">techniecool.workorder</field>
  <field name="prefix">WO/%(year)s/</field>
  <field name="padding">5</field>
</record></pre>
✏ Mini-oefening

Roep de sequence techniecool.workorder aan in een create()-methode.

👁 Toon oplossing
def create(self, vals): vals['name'] = self.env['ir.sequence'].next_by_code( 'techniecool.workorder' ) or '/' return super().create(vals)
7.5 Scheduled Actions (Cron)

Geplande acties voeren automatisch Python-code uit op vaste tijdstippen. Bij TechniCool gebruiken we een dagelijkse cron job die alle toestellen controleert waarvan de garantie deze week verloopt en een activiteit aanmaakt voor de accountmanager om de klant te contacteren voor een onderhoudscontract. De cron wordt geconfigureerd in XML met interval_number en interval_type.

<record id="cron_warranty_check" model="ir.cron">
  <field name="name">TechniCool: Garantie Check</field>
  <field name="model_id" ref="model_techniecool_equipment"/>
  <field name="state">code</field>
  <field name="code">model._cron_check_warranties()</field>
  <field name="interval_type">days</field>
</record></pre>
✏ Mini-oefening

Schrijf de Python-methode _cron_check_warranties die toestellen zoekt waarvan garantie binnen 7 dagen verloopt.

👁 Toon oplossing
def _cron_check_warranties(self): deadline = fields.Date.today() + timedelta(days=7) expiring = self.search([ ('warranty_expiry', '<=', deadline), ('warranty_expiry', '>=', fields.Date.today()) ]) for eq in expiring: eq.activity_schedule('mail.mail_activity_data_todo', note='Garantie verloopt binnenkort!')
7.6 URL Actions & Client Actions

Een ir.actions.act_url opent een externe URL vanuit de Odoo-interface. Een ir.actions.client laadt een OWL-component als zelfstandige view. Voor TechniCool gebruikt de technieker een URL action om rechtstreeks naar de Rational serviceportal te gaan met het serienummer als URL-parameter, en een client action voor het Vanventory camera-dashboard. Beide action-types worden geretourneerd als een dictionary vanuit een Python-methode.

def action_open_manufacturer_portal(self):
    self.ensure_one()
    url = f'https://portal.rational.com/service?sn={self.serial_number}'
    return {
        'type': 'ir.actions.act_url',
        'url': url,
        'target': 'new'
    }</pre>
✏ Mini-oefening

Schrijf een method die een act_url retourneert naar de Electrolux documentatiesite met het modelnummer als query parameter.

👁 Toon oplossing
def action_open_electrolux_docs(self): self.ensure_one() url = f'https://docs.electrolux-professional.com/?model={self.model_name}' return {'type': 'ir.actions.act_url', 'url': url, 'target': 'new'}
7.7 Wizard Returnwaarde

Een wizard-methode retourneert een action-dictionary om na het uitvoeren iets te openen. Bij TechniCool opent de garantie-wizard na het updaten een overzichtslijst van alle bijgewerkte toestellen zodat de dispatcher direct kan controleren wat er gewijzigd is. Je kunt ook een wizard retourneren die een bevestigingsdialoog toont. Het type ir.actions.act_window_close sluit het huidige venster zonder iets te openen.

def action_apply_and_show(self):
    equipment = self.env['techniecool.equipment'].search([
        ('owner_id', '=', self.partner_id.id)
    ])
    equipment.write({'warranty_expiry': self.new_expiry})
    return {
        'type': 'ir.actions.act_window',
        'res_model': 'techniecool.equipment',
        'view_mode': 'list',
        'domain': [('owner_id', '=', self.partner_id.id)]
    }</pre>
✏ Mini-oefening

Schrijf een wizard-methode die na uitvoering de form-view toont van het aangemaakte record.

👁 Toon oplossing
def action_create_and_open(self): rec = self.env['techniecool.equipment'].create({'name': self.name}) return { 'type': 'ir.actions.act_window', 'res_model': 'techniecool.equipment', 'res_id': rec.id, 'view_mode': 'form' }
7.8 Default Values & Context

Je kunt wizards en forms pre-invullen via context-variabelen die beginnen met default_. Dit maakt acties contextueel: wanneer een dispatcher de wizard opent vanuit een klantenfiche, wordt de partner automatisch ingevuld. Bij TechniCool vullen we ook de installatiedatum standaard in op vandaag en het merk op basis van de context. Context-waarden lees je uit via self.env.context.get().

# In de action-definitie
'context': {
    'default_partner_id': partner_id,
    'default_install_date': fields.Date.today(),
    'default_brand': 'rational'
}

# In het wizard model
brand = fields.Selection(..., default=lambda self:
    self.env.context.get('default_brand', 'rational'))</pre>
✏ Mini-oefening

Hoe lees je een context-variabele active_id uit in een wizard-methode?

👁 Toon oplossing
active_id = self.env.context.get('active_id') equipment = self.env['techniecool.equipment'].browse(active_id)
# Wizard voor snelle interventie creatie
class CreateInterventionWizard(models.TransientModel):
    _name = 'techniecool.intervention.wizard'

    equipment_id = fields.Many2one('techniecool.equipment', string='Toestel')
    problem_description = fields.Text(string='Probleem')
    priority = fields.Selection([('0','Lid'), ('1','Dringend')], default='0')

    def action_create(self):
        self.env['fsm.order'].create({
            'equipment_id': self.equipment_id.id,
            'description': self.problem_description,
            'priority': self.priority,
            'location_id': self.equipment_id.location_id.id
        })
        return {'type': 'ir.actions.act_window_close'}</pre>
            
⚙ Praktijkopdracht

Bouw een wizard "Snelle Onderhoud Check" die op een toestel record geopend kan worden. De wizard vraagt om de huidige urenteller van de machine en maakt automatisch een 'Maintenance' order aan in Odoo met de juiste parameters. Zorg dat de urenteller ook bewaard wordt op het toestel zelf.

H8
Security, Access & Multi-company
// access rights, record rules, groups
Week 8 · 8u

Niet iedereen mag alles zien. Een technieker mag zijn eigen planning zien, maar niet de facturatie van andere klanten. Een dispatcher moet alles kunnen beheren. We implementeren een robuust security systeem voor TechniCool NV met model-level access (CSV) en record-level rules (Domain filtering). Ook kijken we naar multi-company support voor als TechniCool uitbreidt naar het buitenland.

8.1 Access Control Lists (ACL)

Toegangsrechten in Odoo worden beheerd via ACL-records in security/ir.model.access.csv. Elk record geeft een gebruikersgroep lees-, schrijf-, aanmaak- en verwijderrechten op een model. Voor TechniCool heeft de groep 'Technieker' leesrechten op equipment maar geen verwijderrechten, terwijl de 'Manager' volledige CRUD-rechten heeft op alle TechniCool-modellen. Zonder ACL-records blokkeer je per ongeluk alle toegang.

# ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_equip_user,equip.user,model_techniecool_equipment,group_technician,1,1,0,0
access_equip_manager,equip.manager,model_techniecool_equipment,group_manager,1,1,1,1</pre>
✏ Mini-oefening

Voeg een ACL-regel toe die de groep 'group_dispatcher' leesrechten geeft op techniecool.equipment.

👁 Toon oplossing
access_equip_dispatcher,equip.disp,model_techniecool_equipment,group_dispatcher,1,0,0,0
8.2 Record Rules

Record rules filteren welke records een gebruiker kan zien, aanvullend op ACL's. Ze worden gedefinieerd als ir.rule-records met een domain. Voor TechniCool ziet een technieker enkel de werkorders die aan hem zijn toegewezen, terwijl een dispatcher alle orders van zijn regio ziet. Record rules worden gecombineerd met AND voor dezelfde gebruiker en hetzelfde model.

<record id="rule_technician_own_orders" model="ir.rule">
  <field name="name">Eigen Werkorders</field>
  <field name="model_id" ref="model_fsm_order"/>
  <field name="domain_force">[("user_id","=",user.id)]</field>
  <field name="groups" eval="[(4, ref('group_technician'))]"/>
</record></pre>
✏ Mini-oefening

Schrijf een record rule die 'group_dispatcher' enkel toestellen toont van het eigen bedrijf.

👁 Toon oplossing
Eigen Bedrijf Toestellen [('company_id','=',user.company_id.id)]
8.3 Gebruikersgroepen Definiëren

Groepen worden gedefinieerd als res.groups-records in XML. Je kunt groepen hiërarchisch opbouwen via implied_ids: de Manager-groep impliceert de Dispatcher-groep, die op zijn beurt de Technieker-groep impliceert. Voor TechniCool definiëren we drie groepen: Technieker, Dispatcher en Manager, elk met oplopende rechten voor de Vanventory-applicatie. Implicaties worden recursief toegepast.

<record id="group_technician" model="res.groups">
  <field name="name">Technieker</field>
</record>
<record id="group_manager" model="res.groups">
  <field name="name">Manager</field>
  <field name="implied_ids" eval="[(4, ref('group_technician'))]"/>
</record></pre>
✏ Mini-oefening

Wat betekent eval="[(4, ref('group_technician'))]" in de implied_ids definitie?

👁 Toon oplossing
# Tuple (4, id) in Odoo Many2many betekent: # 'Voeg het record met dit id toe zonder de rest te verwijderen' # Dus: Manager-groep erft rechten van Technieker-groep
8.4 Multi-company & company_id

Odoo ondersteunt multi-company waarbij records toegewezen zijn aan een specifiek bedrijf via company_id. Voor TechniCool NV, met meerdere Belgische vestigingen, willen we dat technici in Gent enkel de toestellen van hun vestiging zien. Het veld company_id combineer je met een record rule op company_id in user.company_ids voor multi-company-bewuste filtering.

company_id = fields.Many2one(
    'res.company',
    string='Bedrijf',
    default=lambda self: self.env.company,
    required=True
)

# company_dependent: per bedrijf andere waarde
service_level = fields.Char(
    company_dependent=True
)</pre>
✏ Mini-oefening

Voeg een company_id-veld toe met de huidige company als standaardwaarde.

👁 Toon oplossing
company_id = fields.Many2one( 'res.company', string='Bedrijf', default=lambda self: self.env.company )
8.5 Field-niveau Toegang

Je kunt de zichtbaarheid van velden beperken via het groups-attribuut op veldniveau. Dit werkt zowel in het model als in de view. Voor TechniCool is de aankoopprijs van toestellen enkel zichtbaar voor managers. In de view gebruik je groups="techniecool_base.group_manager" op het field-element. Dit is een tweelaagse beveiliging: het model-attribuut blokkeert API-toegang; de view verbergt het UI-element.

# Model niveau
purchase_price = fields.Float(
    string='Aankoopprijs',
    groups='techniecool_base.group_manager'
)

<!-- View niveau -->
<field name="purchase_price"
       groups="techniecool_base.group_manager"/></pre>
✏ Mini-oefening

Hoe beperk je de knop 'Verwijder' in een form view tot enkel de groep 'group_manager'?

👁 Toon oplossing
8.6 ir.config_parameter

Systeemparameters in ir.config_parameter bewaren globale configuratiewaarden. Voor TechniCool bewaren we hier de API-sleutel voor de Rational serviceportal, de standaard garantieduur in maanden, en de e-mailadressen voor escalaties. Deze waarden zijn aanpasbaar door admins via het Technical-menu zonder code-aanpassingen. Lees altijd met sudo() want gewone gebruikers hebben geen rechten op dit model.

# Lezen van systeemparameter
api_key = self.env['ir.config_parameter'].sudo().get_param(
    'techniecool.rational_api_key', default=''
)

# Schrijven van systeemparameter
self.env['ir.config_parameter'].sudo().set_param(
    'techniecool.default_warranty_months', '24'
)</pre>
✏ Mini-oefening

Lees de parameter techniecool.max_equipment_per_client op met 50 als standaardwaarde.

👁 Toon oplossing
max_eq = int(self.env['ir.config_parameter'].sudo().get_param( 'techniecool.max_equipment_per_client', default='50' ))
8.7 Portaal Toegang

Klanten van TechniCool kunnen via het Odoo-portaal hun eigen toestellen en interventiegeschiedenis raadplegen. Dit vereist portaaltoegang via de portal.mixin en de juiste record rules voor de portaalgebruikersgroep. De methode _compute_access_url genereert de publieke URL voor elk toestelrecord. Een access token zorgt voor veilige toegang zonder dat de klant hoeft in te loggen.

class TechniCoolEquipment(models.Model):
    _name = 'techniecool.equipment'
    _inherit = ['mail.thread', 'portal.mixin']

    def _compute_access_url(self):
        super()._compute_access_url()
        for rec in self:
            rec.access_url = f'/my/equipment/{rec.id}'</pre>
✏ Mini-oefening

Welke mixin gebruik je om portaaltoegang toe te voegen aan een Odoo-model?

👁 Toon oplossing
# portal.mixin - voeg toe aan _inherit: # _inherit = ['mijn.model', 'portal.mixin'] # Dit voegt access_url, access_token en # share_url toe aan het model.
8.8 Audit Trail & Logging

Voor compliance is een audit trail cruciaal bij TechniCool. Odoo logt automatisch wijzigingen via mail.thread met tracking=True op velden. De ORM schrijft altijd write_date en write_uid bij elke update. Voor kritische acties als garantieverlenging logt de dispatcher ook een manueel chatter-bericht via message_post() zodat er een volledige geschiedenis beschikbaar is voor audits.

def action_extend_warranty(self):
    old_expiry = self.warranty_expiry
    self.write({'warranty_expiry': self.new_expiry})
    self.message_post(
        body=f'Garantie verlengd van {old_expiry} naar {self.new_expiry}',
        message_type='comment',
        subtype_xmlid='mail.mt_note'
    )</pre>
✏ Mini-oefening

Post een chatter-bericht 'Toestel geactiveerd' op een equipment-record via Python.

👁 Toon oplossing
equipment.message_post( body='Toestel geactiveerd', message_type='comment', subtype_xmlid='mail.mt_note' )
# Record Rule voorbeeld in XML
<record id="rule_technician_own_orders" model="ir.rule">
    <field name="name">Technici zien enkel eigen orders</field>
    <field name="model_id" ref="model_fsm_order"/>
    <field name="domain_force">[('person_id.user_id', '=', user.id)]</field>
    <field name="groups" eval="[(4, ref('techniecool.group_technician'))]"/>
</record>

# CSV Access voorbeeld:
# id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
# access_tc_equip_tech,equip_tech,model_techniecool_equipment,group_technician,1,1,0,0
# access_tc_equip_disp,equip_disp,model_techniecool_equipment,group_dispatcher,1,1,1,1</pre>
            
Vak 2 · Weken 9–16 · 64 contacturen

Odoo Frontend
(OWL)

Odoo 17 gebruikt OWL (Odoo Web Library) als frontend framework. Je leert componenten bouwen die naadloos integreren met de Odoo UI en real-time data ophalen.

8
Weken
64u
Les
OWL
React-like
Cursusinhoud
Hoofdstukken
H1
OWL Fundament
// components, setup, hooks, state
Week 9 · 8u

OWL is Odoo's eigen frontend framework. Het lijkt op React, maar is speciaal ontworpen voor de architectuur van Odoo. We leren hoe we componenten bouwen met een `setup()` methode, hoe we `useState` gebruiken voor reactiviteit en hoe we XML templates schrijven voor de rendering. Dit is de basis voor de Vanventory dashboards.

1.1 OWL Component Structuur

OWL (Odoo Web Library) is het moderne JavaScript-framework van Odoo, geïnspireerd door React en Vue. Elk OWL-component bestaat uit een klasse die erft van Component, een XML-template en optionele CSS. Voor TechniCool bouwen we een dashboard-component dat de status van alle toestellen toont. OWL gebruikt een virtuele DOM voor efficiënte renders en ondersteunt TypeScript.

import { Component, xml } from "@odoo/owl";

export class EquipmentCard extends Component {
    static template = xml`
      <div class='eq-card'>
        <h3><t t-esc='props.name'/></h3>
        <span><t t-esc='props.serial'/></span>
      </div>
    `;
    static props = {
        name: String,
        serial: String,
    };
}</pre>
✏ Mini-oefening

Maak een OWL-component TechnicianBadge dat de naam en het ID van een technieker toont via props.

👁 Toon oplossing
import { Component, xml } from '@odoo/owl'; export class TechnicianBadge extends Component { static template = xml`
(#)
`; static props = { name: String, id: Number }; }
1.2 Reactieve State met useState

OWL's useState hook maakt objecten reactief: elke wijziging triggert automatisch een re-render. Dit is het equivalent van ref in Vue of useState in React. Voor TechniCool gebruiken we dit om de geselecteerde filterstatus van het toestellen-dashboard bij te houden. Reactieve state is component-lokaal en wordt niet gedeeld met andere componenten tenzij expliciet doorgegeven.

import { Component, useState, xml } from "@odoo/owl";

export class FilterPanel extends Component {
    state = useState({
        activeBrand: "all",
        showExpired: false,
    });

    setBrand(brand) {
        this.state.activeBrand = brand;
    }
}</pre>
✏ Mini-oefening

Voeg een searchQuery toe aan de state van FilterPanel met lege string als standaard.

👁 Toon oplossing
state = useState({ activeBrand: 'all', showExpired: false, searchQuery: '', // nieuw });
1.3 Props & Events

Props zijn de invoerparameters van een OWL-component — read-only data die van buiten inkomt. Events sturen data terug omhoog naar de parent via this.trigger() of een callback-prop. Voor TechniCool stuurt de EquipmentCard een event naar de parent wanneer de technicus op een toestel klikt om het te selecteren voor een nieuwe werkorder.

export class EquipmentCard extends Component {
    static props = {
        equipment: Object,
        onSelect: Function,
    };

    onClickSelect() {
        this.props.onSelect(this.props.equipment.id);
    }
}</pre>
✏ Mini-oefening

Schrijf een prop-definitie voor een component dat een lijst van strings en een optionele boolean accepteert.

👁 Toon oplossing
static props = { items: Array, showAll: { type: Boolean, optional: true }, };
1.4 Lifecycle Hooks

OWL biedt lifecycle hooks om code uit te voeren op specifieke momenten. onWillStart wacht op een Promise vóór de eerste render — ideaal om data op te laden. onMounted vuurt na de eerste DOM-render. onWillUnmount ruimt op (event listeners verwijderen). Voor TechniCool laadt onWillStart de toestellen-lijst van de Odoo RPC-API.

import { Component, onWillStart, onMounted } from "@odoo/owl";

export class EquipmentDashboard extends Component {
    setup() {
        onWillStart(async () => {
            this.equipment = await this.loadEquipment();
        });
        onMounted(() => console.log("Dashboard geladen"));
    }
}</pre>
✏ Mini-oefening

Gebruik onWillUnmount om een setInterval te stoppen wanneer het component verwijderd wordt.

👁 Toon oplossing
setup() { this.intervalId = null; onMounted(() => { this.intervalId = setInterval(() => this.refresh(), 30000); }); onWillUnmount(() => clearInterval(this.intervalId)); }
1.5 QWeb Directives in OWL

OWL gebruikt QWeb-templates met directives zoals t-if, t-foreach en t-esc. t-esc escapet de output automatisch, t-out rendert HTML. t-foreach vereist altijd een t-as attribuut. Voor TechniCool renderen we een kaart per toestel in de vlootoverzicht-view met een conditie voor de garantiestatus.

static template = xml`
  <div>
    <t t-foreach='equipment' t-as='eq'>
      <div class='card'>
        <t t-esc='eq.name'/>
        <t t-if='eq.warranty_expired'>
          <span class='badge-red'>Vervallen</span>
        </t>
      </div>
    </t>
  </div>
`;</pre>
✏ Mini-oefening

Schrijf een t-foreach die een lijst technicians doorloopt en per item de naam toont.

👁 Toon oplossing
  • 1.6 Services & Dependency Injection

    OWL's service-systeem laat componenten gedeelde functionaliteit injecteren zonder directe imports. De useService hook haalt een geregistreerde service op. Voor TechniCool gebruiken we de orm-service voor database-calls, de notification-service voor popups, en de router-service voor navigatie binnen de Odoo-web-client.

    import { useService } from "@web/core/utils/hooks";
    
    export class EquipmentDashboard extends Component {
        setup() {
            this.orm = useService("orm");
            this.notification = useService("notification");
        }
    
        async loadData() {
            return await this.orm.searchRead(
                "techniecool.equipment",
                [],
                ["name", "serial_number"]
            );
        }
    }</pre>
    ✏ Mini-oefening

    Inject de dialog-service in een OWL-component via useService.

    👁 Toon oplossing
    setup() { this.dialog = useService('dialog'); }
    1.7 useRef & DOM Toegang

    useRef geeft directe toegang tot een DOM-element vanuit JavaScript. Dit is noodzakelijk voor operaties die buiten de reactieve template vallen, zoals het activeren van de camera-stream of het initialiseren van een canvas voor de SVG-mapper. Bij TechniCool gebruiken we useRef om de video-feed van de camera te verbinden met de Vanventory-scanner in het mobile dashboard.

    import { Component, useRef, onMounted } from "@odoo/owl";
    
    export class CameraView extends Component {
        videoRef = useRef("videoEl");
    
        setup() {
            onMounted(async () => {
                const stream = await navigator.mediaDevices.getUserMedia(
                    { video: true }
                );
                this.videoRef.el.srcObject = stream;
            });
        }
    }</pre>
    ✏ Mini-oefening

    Schrijf een useRef die naar een canvas-element verwijst en er na mounting een 2D-context uit haalt.

    👁 Toon oplossing
    canvasRef = useRef('myCanvas'); setup() { onMounted(() => { const ctx = this.canvasRef.el.getContext('2d'); }); }
    1.8 OWL Registratie in Odoo

    Om een OWL-component beschikbaar te maken in Odoo moet je het registreren in de juiste registry. registry.category('views').add() voor views, registry.category('actions').add() voor client actions. Dit koppelsysteem laat Odoo's web client componenten dynamisch laden. Voor TechniCool registreren we het Vanventory-dashboard als client action zodat het via een menuitem bereikbaar is.

    import { registry } from "@web/core/registry";
    import { VanventoryDashboard } from "./dashboard";
    
    // Registreer als client action
    registry.category("actions").add(
        "vanventory_dashboard",
        VanventoryDashboard
    );</pre>
    ✏ Mini-oefening

    Hoe registreer je een custom OWL-view in de 'views'-category?

    👁 Toon oplossing
    registry.category('views').add('my_custom_view', MyCustomView);
    /** @odoo-module **/
    import { Component, useState, onMounted } from "@odoo/owl";
    
    export class TechnicianStatusCard extends Component {
        static template = "techniecool.TechnicianStatusCard";
        static props = {
            name: { type: String },
            initialStatus: { type: String, optional: true }
        };
    
        setup() {
            // De state is het hart van reactiviteit in TechniCool
            this.state = useState({
                status: this.props.initialStatus || "available",
                lastUpdate: new Date().toLocaleTimeString(),
                clickCount: 0
            });
    
            onMounted(() => {
                console.log(f"Card voor {this.props.name} is geladen.");
            });
        }
    
        toggleStatus() {
            this.state.status = this.state.status === "available" ? "busy" : "available";
            this.state.lastUpdate = new Date().toLocaleTimeString();
            this.state.clickCount++;
            
            // Trigger een notificatie in de Odoo UI
            this.env.services.notification.add(f"Status gewijzigd naar {this.state.status}", {
                type: "info",
                sticky: false
            });
        }
    
        get statusClass() {
            return this.state.status === "available" ? "bg-success" : "bg-danger";
        }
    }</pre>
                
    ⚙ Praktijkopdracht

    Bouw een OWL component TechnicianStatusCard die de naam, de huidige status (kleurcode) en de laatst voltooide order van een technieker toont. Voeg een knop toe die de status wisselt tussen 'Beschikbaar' en 'In Interventie'. De kleur van de kaart moet automatisch veranderen op basis van de state.

    H2
    OWL & Odoo RPC
    // orm service, useService, loading states
    Week 10 · 8u

    Een frontend zonder data is nutteloos. We leren hoe we vanuit OWL praten met de Odoo backend via de `orm` service. We halen live interventies op, filteren ze in JavaScript en zorgen voor een vloeiende gebruikerservaring met loading indicators. Ook leren we hoe we foutmeldingen van de server op een nette manier tonen aan de dispatcher.

    2.1 orm Service — searchRead

    De orm-service in Odoo's web client is de officiële manier om database-calls te maken vanuit OWL. searchRead(model, domain, fields) combineert filteren en ophalen in één call. Voor TechniCool laadt het dashboard alle actieve Rational-toestellen met naam, serienummer en garantiedatum in één efficiënte RPC-call. Altijd async/await gebruiken want elke ORM-call is een netwerkrequest.

    async loadEquipment() {
        return await this.orm.searchRead(
            "techniecool.equipment",
            [["brand", "=", "rational"]],
            ["name", "serial_number", "warranty_expiry"],
            { limit: 50 }
        );
    }</pre>
    ✏ Mini-oefening

    Schrijf een searchRead-call die alle technici ophaalt met naam en telefoonnummer.

    👁 Toon oplossing
    const technicians = await this.orm.searchRead( 'hr.employee', [], ['name', 'work_phone'] );
    2.2 orm Service — call() voor Custom Methods

    this.orm.call(model, method, args, kwargs) roept een aangepaste Python-methode op. Dit is de manier om server-side businesslogica te triggeren vanuit de frontend. Voor TechniCool roept de dispatcher via een knop de methode action_create_workorder aan op een geselecteerd toestel, zodat de server-side logica (sequencegeneratie, mailing) correct uitgevoerd wordt.

    async createWorkorder(equipmentId) {
        const result = await this.orm.call(
            "techniecool.equipment",
            "action_create_workorder",
            [[equipmentId]],
            { priority: "urgent" }
        );
        return result;
    }</pre>
    ✏ Mini-oefening

    Roep de methode action_extend_warranty aan op equipment-record met id 42.

    👁 Toon oplossing
    await this.orm.call( 'techniecool.equipment', 'action_extend_warranty', [[42]] );
    2.3 useOwnedDialogs & Notificaties

    De notification-service toont toast-meldingen in de Odoo-interface. this.notification.add() accepteert een bericht, type (success/danger/warning) en een duur. Voor TechniCool tonen we een groene notificatie na het succesvol aanmaken van een werkorder, en een rode waarschuwing als de ORM-call mislukt door een ontbrekend serienummer.

    async onCreateOrder() {
        try {
            await this.createWorkorder(this.state.selectedId);
            this.notification.add(
                "Werkorder aangemaakt!",
                { type: "success" }
            );
        } catch (e) {
            this.notification.add(
                "Fout: " + e.message,
                { type: "danger" }
            );
        }
    }</pre>
    ✏ Mini-oefening

    Toon een waarschuwings-notificatie met tekst 'Camera niet beschikbaar' als de camera API faalt.

    👁 Toon oplossing
    this.notification.add( 'Camera niet beschikbaar', { type: 'warning', sticky: false } );
    2.4 JSON-RPC Direct

    Naast de orm-service kun je ook directe JSON-RPC calls maken via fetch naar /web/dataset/call_kw. Dit is nuttig voor complexe calls of wanneer je buiten de Odoo web-client werkt, zoals in de Vanventory PWA die offline-first is. Je moet de sessie-cookie meesturen voor authenticatie. Odoo's JSON-RPC v2-protocol verwacht een specifieke request-structuur.

    async function rpcCall(model, method, args) {
        const response = await fetch("/ web/dataset/call_kw", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                jsonrpc: "2.0", method: "call",
                params: { model, method, args, kwargs: {} }
            })
        });
        const data = await response.json();
        return data.result;
    }</pre>
    ✏ Mini-oefening

    Pas de rpcCall-functie aan zodat kwargs ook meegegeven kan worden als parameter.

    👁 Toon oplossing
    async function rpcCall(model, method, args, kwargs = {}) { // ... body identiek, maar kwargs niet leeg hardcoded body: JSON.stringify({ params: { model, method, args, kwargs } }) }
    2.5 Reactive Data Loading Pattern

    Het beste patroon voor data laden in OWL combineert useState voor loading-state, onWillStart voor initieel laden, en een refresh-methode voor updates. Voor TechniCool dashboard: bij laden tonen we een spinner, na succes de toestellen-kaarten, en bij fout een foutbericht. Dit voorkomt lege of gebroken weergaven tijdens trage netwerkcalls.

    state = useState({ loading: true, items: [], error: null });
    
    setup() {
        onWillStart(() => this.loadItems());
    }
    
    async loadItems() {
        this.state.loading = true;
        try {
            this.state.items = await this.orm.searchRead(
                "techniecool.equipment", [], ["name"]
            );
        } catch (e) {
            this.state.error = e.message;
        } finally {
            this.state.loading = false;
        }
    }</pre>
    ✏ Mini-oefening

    Voeg een t-if toe in de template die een spinner toont zolang state.loading true is.

    👁 Toon oplossing
    Laden...
    2.6 Domain Builder in JavaScript

    Voor dynamische filters bouw je domains op als JavaScript-arrays. Je kunt de Odoo domain-helper functies gebruiken of zelf arrays samenstellen. Voor TechniCool bouwt de dispatcher een filter op basis van merk, regio en garantiestatus die gecombineerd worden tot een valide Odoo-domain voor de searchRead-call.

    buildDomain() {
        const domain = [];
        if (this.state.brand !== "all") {
            domain.push(["brand", "=", this.state.brand]);
        }
        if (this.state.showExpired) {
            domain.push(["warranty_status", "=", "expired"]);
        }
        return domain;
    }</pre>
    ✏ Mini-oefening

    Bouw een domain dat filtert op merk 'electrolux' EN vermogen groter dan 10 kW.

    👁 Toon oplossing
    const domain = [ '&', ['brand', '=', 'electrolux'], ['power_kw', '>', 10] ];
    2.7 File Upload via RPC

    Bestanden uploaden naar Odoo vanuit OWL gaat via de /web/binary/upload_attachment-route of via een base64-encoded string in een ORM-call. Voor TechniCool uploadt de technieker foto's van een defect toestel via de Vanventory-app, die als ir.attachment worden opgeslagen en gelinkt aan de werkorder. Gebruik FileReader om het bestand naar base64 te converteren.

    async uploadPhoto(file, equipmentId) {
        const reader = new FileReader();
        reader.readAsDataURL(file);
        await new Promise(r => reader.onload = r);
        const base64 = reader.result.split(",")[1];
        await this.orm.call("ir.attachment", "create", [{
            name: file.name,
            datas: base64,
            res_model: "techniecool.equipment",
            res_id: equipmentId
        }]);
    }</pre>
    ✏ Mini-oefening

    Leg in commentaar uit waarom je FileReader gebruikt in plaats van directe upload.

    👁 Toon oplossing
    // FileReader converteert het bestand naar base64 // zodat het als tekst via JSON-RPC verzonden // kan worden. Directe upload vereist multipart/form-data.
    2.8 Error Handling & User Feedback

    RPC-calls kunnen mislukken door netwerkproblemen of server-side validatiefouten. Odoo gooit een RPCError met een data-object dat de server-foutmelding bevat. Voor TechniCool tonen we de foutmelding in de interface op een gebruiksvriendelijke manier, niet als technische stack trace. Gebruik altijd try/catch rond orm-calls en geef specifieke feedback aan de dispatcher.

    async save() {
        try {
            await this.orm.write(
                "techniecool.equipment",
                [this.props.id],
                this.state.changes
            );
            this.notification.add("Opgeslagen!", { type: "success" });
        } catch (e) {
            const msg = e.data?.message || "Onbekende fout";
            this.notification.add(msg, { type: "danger" });
        }
    }</pre>
    ✏ Mini-oefening

    Hoe haal je de gebruikersvriendelijke foutmelding op uit een Odoo RPCError object?

    👁 Toon oplossing
    // e.data.message bevat de Odoo UserError tekst // e.data.name bevat het exception type const msg = e.data?.message || 'Onbekende fout';
    /** @odoo-module **/
    import { Component, onWillStart, useState } from "@odoo/owl";
    import { useService } from "@web/core/utils/hooks";
    
    export class InterventionDashboard extends Component {
        setup() {
            this.orm = useService("orm");
            this.state = useState({ orders: [], loading: true });
    
            onWillStart(async () => {
                this.state.orders = await this.orm.searchRead(
                    "fsm.order", 
                    [["state", "=", "open"]], 
                    ["name", "scheduled_date"]
                );
                this.state.loading = false;
            });
        }
    }</pre>
                
    ⚙ Praktijkopdracht

    Bouw een dashboard component dat alle openstaande interventies ophaalt van de server. Toon een lijst met de naam van de klant en de geplande starttijd. Voeg een knop "Nu Verversen" toe die de data handmatig opnieuw ophaalt. Toon een "Laden..." bericht zolang de RPC call loopt.

    H3
    Odoo Views Customizen met JS
    // widgets, registry, field extension
    Week 11 · 8u

    Soms is de standaard Odoo input niet genoeg. Een technieker wil een visuele indicator van de stock in zijn bestelwagen. We leren hoe we custom "Widgets" bouwen die we in de standaard Odoo list of form views kunnen gebruiken. We registreren onze OWL componenten in de Odoo registry zodat ze overal in het systeem bruikbaar zijn.

    3.1 List View Customisatie

    De list-view in Odoo kan je aanpassen via XML (kolommen, knoppen, decoraties) of via een JavaScript-extensie voor geavanceerder gedrag. Voor TechniCool voegen we een kleurcode toe aan de lijst: rode rij voor toestellen met vervallen garantie, oranje voor bijna-vervallen. Dit gebeurt via het decoration-danger attribuut in XML of via een custom renderer.

    <list decoration-danger="warranty_status=='expired'"
          decoration-warning="warranty_status=='expiring'">
      <field name="name"/>
      <field name="serial_number"/>
      <field name="warranty_expiry"/>
      <field name="warranty_status" invisible="True"/>
    </list></pre>
    ✏ Mini-oefening

    Voeg een decoration-info toe aan de list view die nieuwe toestellen (install_date van deze maand) blauw kleurt.

    👁 Toon oplossing
    ...
    3.2 Kanban View Aanpassen

    De kanban-view toont records als kaarten, ideaal voor een visueel toestellen-overzicht bij TechniCool. Je definieert de kaartinhoud in een QWeb-template binnen de view-definitie. Kleuren per kolom (state-balk), voortgangsbalkjes en quick-info-buttons zijn ingebouwde kanban-features. Voor TechniCool tonen we het merk-logo, de locatie en een statusbadge op elke kaart.

    <kanban>
      <field name="name"/>
      <field name="brand"/>
      <templates>
        <t t-name="kanban-box">
          <div class="oe_kanban_card">
            <strong><t t-esc="record.name.value"/></strong>
            <span><t t-esc="record.brand.value"/></span>
          </div>
        </t>
      </templates>
    </kanban></pre>
    ✏ Mini-oefening

    Voeg een statusbadge toe in een kanban-kaart die 'Garantie Verlopen' toont als warranty_status 'expired' is.

    👁 Toon oplossing
    Garantie Verlopen
    3.3 Custom Widget

    Een custom widget vervangt de standaard weergave van een veld. Je erft van Component en registreert het in registry.category('fields'). Voor TechniCool bouwen we een WarrantyIndicator-widget die de garantiedatum toont als een visuele progressiebalk: groen voor volle garantie, rood voor bijna-vervallen.

    export class WarrantyIndicator extends Component {
        get percentage() {
            const days = this.props.record.data.warranty_days_left;
            return Math.max(0, Math.min(100, (days / 730) * 100));
        }
    }
    
    // Registratie
    registry.category("fields").add("warranty_indicator", {
        component: WarrantyIndicator,
        displayName: "Garantie Indicator",
    });</pre>
    ✏ Mini-oefening

    Hoe gebruik je een custom widget in een XML view-definitie?

    👁 Toon oplossing
    3.4 Search View & Filters

    De search view bepaalt de beschikbare filters en groeperingen in de zoekbalk. Voor TechniCool voegen we een snelfilter 'Garantie Vervallen' toe en een groepering 'Per Merk'. Je kunt ook custom favorietenfilters definiëren die altijd zichtbaar zijn in de dropdown. Zoekweergave-elementen combineer je met <search> en <filter> tags.

    <search>
      <field name="name" string="Toestel"/>
      <filter string="Garantie Vervallen"
              name="expired"
              domain="[('warranty_status','=','expired')]"/>
      <group expand="0" string="Groepeer op">
        <filter string="Merk" name="brand" context="{'group_by':'brand'}"/>
      </group>
    </search></pre>
    ✏ Mini-oefening

    Voeg een filter 'Actief' toe aan de search view die enkel records met is_active = True toont.

    👁 Toon oplossing
    3.5 Form View & Statusbar

    De form view is de detail-weergave van één record. De <header> sectie bevat statusbar en actieknoppen. De statusbar_visible attribuut bepaalt welke statussen zichtbaar zijn. Voor TechniCool toont de form view een workflow-balk: Nieuw → Actief → In Herstelling → Afgevoerd, met een kleurindicator per staat.

    <form>
      <header>
        <button name="action_activate" type="object"
                string="Activeer" states="draft" class="btn-primary"/>
        <field name="state" widget="statusbar"
               statusbar_visible="draft,active,repair,retired"/>
      </header>
      <sheet>
        <field name="name"/>
      </sheet>
    </form></pre>
    ✏ Mini-oefening

    Voeg een knop 'Archiveer' toe in de header die enkel zichtbaar is in de staat 'active'.

    👁 Toon oplossing
    3.6 Optional Columns in List

    Optionele kolommen in de list view laten gebruikers zelf kiezen welke kolommen zichtbaar zijn. Dit is ideaal voor de TechniCool toestellen-lijst: de dispatcher wil andere kolommen zien dan de technicus. Gebruik het attribuut optional='show' of optional='hide' op een field-tag. Odoo slaat de gebruikersvoorkeur op in ir.ui.view.custom.

    <list>
      <field name="name"/>
      <field name="serial_number"/>
      <field name="power_kw" optional="show"/>
      <field name="purchase_price" optional="hide"
             groups="techniecool_base.group_manager"/>
    </list></pre>
    ✏ Mini-oefening

    Wat is het verschil tussen optional='show' en optional='hide'?

    👁 Toon oplossing
    # optional='show': kolom standaard zichtbaar, maar uitschakelbaar # optional='hide': kolom standaard verborgen, maar inschakelbaar
    3.7 Embedded Lists (One2many Tabbladen)

    One2many-velden worden in form views getoond als een embedded list of tabblad. Je kunt de kolommen en knoppen van de embedded list volledig aanpassen. Voor TechniCool toont het tabblad 'Interventies' in de toestel-form view alle werkorders met datum, technieker, kost en status, met een inline-bewerkbare kost-kolom voor de dispatcher.

    <notebook>
      <page string="Interventies">
        <field name="intervention_ids">
          <list editable="bottom">
            <field name="name"/>
            <field name="date_done"/>
            <field name="cost"/>
          </list>
        </field>
      </page>
    </notebook></pre>
    ✏ Mini-oefening

    Voeg een second tabblad 'Onderdelen' toe aan de notebook met het veld part_ids.

    👁 Toon oplossing
    3.8 Pivot & Graph Views

    Pivot en graph views geven managementinzichten op basis van geaggregeerde data. De pivot view laat je rijen, kolommen en measures configureren via drag-and-drop. Voor TechniCool toont de pivot view de interventiekosten per merk per maand, zodat de manager snel ziet welke merken het meest onderhoudsgevoelig zijn. Graph views bieden balk-, lijn- en taartdiagrammen.

    <pivot>
      <field name="brand" type="row"/>
      <field name="install_date" type="col"
             interval="month"/>
      <field name="intervention_count" type="measure"/>
    </pivot>
    
    <graph type="bar">
      <field name="brand" type="row"/>
      <field name="power_kw" type="measure"/>
    </graph></pre>
    ✏ Mini-oefening

    Definieer een graph view van type 'pie' die toestellen groepeert per merk.

    👁 Toon oplossing
    /** @odoo-module **/
    import { registry } from "@web/core/registry";
    import { standardFieldProps } from "@web/views/fields/standard_field_props";
    import { Component } from "@odoo/owl";
    
    export class VanStockIndicator extends Component {
        static template = "techniecool.VanStockIndicator";
        static props = { ...standardFieldProps };
        
        get percentage() {
            return this.props.value || 0;
        }
    }
    
    registry.category("fields").add("van_stock_bar", VanStockIndicator);</pre>
                
    ⚙ Praktijkopdracht

    Bouw een custom widget VanStockIndicator. Deze widget moet een numeriek veld (0-100) renderen als een gekleurde voortgangsbalk. Rood onder de 20%, oranje onder de 50% en groen daarboven. Gebruik deze widget in de list view van stock.quant voor de locaties die bestelwagens zijn.

    H4
    Client Actions & Systray
    // custom pages, action manager, systray items
    Week 12 · 8u

    Niet elke UI past in een standaard formulier. De "Van Planner" (de kern van Vanventory) heeft een volledige schermbreedte nodig met een kaart en drag-and-drop. We leren "Client Actions" bouwen: volledige OWL pagina's die buiten het standaard Odoo view systeem vallen. Ook voegen we een handige status-icon toe aan de systray (bovenaan naast de klok) voor snelle notificaties aan technici.

    4.1 Client Action Registreren

    Een client action is een volledig OWL-component dat als zelfstandige pagina in Odoo geladen wordt. Je registreert het via registry.category('actions').add() en koppelt het aan een ir.actions.client-record in XML. Voor TechniCool is het Vanventory-dashboard een client action die bereikbaar is via het hoofdmenu en de volledige browser-viewport gebruikt.

    // dashboard.js
    import { registry } from "@web/core/registry";
    import { VanventoryDashboard } from "./components/dashboard";
    
    registry.category("actions").add(
        "vanventory_dashboard",
        VanventoryDashboard
    );
    
    <!-- XML: ir.actions.client -->
    <record id="action_vanventory" model="ir.actions.client">
      <field name="tag">vanventory_dashboard</field>
    </record></pre>
    ✏ Mini-oefening

    Registreer een client action met tag 'techniecool_planner' voor een planningscomponent.

    👁 Toon oplossing
    registry.category('actions').add( 'techniecool_planner', TechniCoolPlanner );
    4.2 Action Props & State

    Een client action ontvangt via props.action de action-definitie, inclusief het context-woordenboek dat meegegeven werd bij het openen. Via props.action.context lees je parameters uit die de server of het menu meegaf. Voor TechniCool stuurt het menu een default_view-parameter mee die bepaalt of de dispatcher de lijst of de kaartweergave ziet.

    export class VanventoryDashboard extends Component {
        setup() {
            const ctx = this.props.action.context;
            this.state = useState({
                view: ctx.default_view || "cards",
                brand: ctx.default_brand || "all",
            });
        }
    }</pre>
    ✏ Mini-oefening

    Hoe lees je de context-waarde active_id uit van de action-props?

    👁 Toon oplossing
    const activeId = this.props.action.context?.active_id;
    4.3 Systray Component

    De systray is de rechterbovenhoek van de Odoo-menubar. Je kunt er iconen en dropdowns toevoegen via registry.category('systray').add(). Voor TechniCool voegen we een systray-icoon toe dat het aantal dringende interventies toont als een badge, zodat de dispatcher in één oogopslag de werkdruk ziet zonder de huidige pagina te verlaten.

    export class TechniCoolSystray extends Component {
        state = useState({ count: 0 });
    
        setup() {
            this.orm = useService("orm");
            onWillStart(() => this.loadCount());
        }
    
        async loadCount() {
            this.state.count = await this.orm.searchCount(
                "fsm.order",
                [["priority", "=", "urgent"]]
            );
        }
    }</pre>
    ✏ Mini-oefening

    Registreer het systray-component en geef het een prioriteit van 5.

    👁 Toon oplossing
    registry.category('systray').add( 'techniecool_systray', { Component: TechniCoolSystray }, { sequence: 5 } );
    4.4 useActionService — Navigatie

    De action-service laat je programmatisch navigeren naar andere views of client actions. Via this.action.doAction() kun je een action-id, -tag of -definitie meegeven. Voor TechniCool: na het aanmaken van een werkorder navigeert de app automatisch naar de form view van die werkorder, zodat de dispatcher de details kan invullen.

    setup() {
        this.action = useService("action");
    }
    
    async openWorkorder(id) {
        await this.action.doAction({
            type: "ir.actions.act_window",
            res_model: "fsm.order",
            res_id: id,
            views: [[false, "form"]],
        });
    }</pre>
    ✏ Mini-oefening

    Schrijf code die navigeert naar een XML-gedefinieerde action met id action_equipment_list.

    👁 Toon oplossing
    await this.action.doAction('techniecool_base.action_equipment_list');
    4.5 Breadcrumb & View Manager

    De breadcrumb bovenaan de Odoo-interface toont de navigatie-geschiedenis. Bij het doorklikken vanuit een client action naar een record-form view voegt Odoo automatisch een breadcrumb-stap toe. Voor TechniCool zorgt dit dat de dispatcher kan terugkeren naar het Vanventory-dashboard na het bekijken van een toesteldetail via de pijl links in de breadcrumb.

    async openEquipmentDetail(id) {
        await this.action.doAction({
            type: "ir.actions.act_window",
            res_model: "techniecool.equipment",
            res_id: id,
            views: [[false, "form"]],
            target: "current",  // voegt breadcrumb toe
        });
    }</pre>
    ✏ Mini-oefening

    Wat is het verschil tussen target: 'current' en target: 'new' in een action?

    👁 Toon oplossing
    // 'current': opent in huidige view met breadcrumb // 'new': opent in een apart dialoogvenster (popup)
    4.6 Dialog Service

    De dialog-service toont een OWL-component als modaal dialoogvenster. Dit is ideaal voor bevestigingsvragen of wizards die je niet als volledige pagina wil tonen. Voor TechniCool: de 'Garantie Verlengen'-wizard verschijnt als dialog boven de huidige view, zodat de dispatcher de context van het geselecteerde toestel behoudt.

    import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
    
    async onDelete() {
        this.dialog.add(ConfirmationDialog, {
            title: "Verwijderen?",
            body: "Ben je zeker dat je dit toestel wil verwijderen?",
            confirm: async () => {
                await this.orm.unlink("techniecool.equipment", [this.props.id]);
                this.notification.add("Verwijderd", { type: "success" });
            }
        });
    }</pre>
    ✏ Mini-oefening

    Toon een custom OWL-component WarrantyExtendDialog als dialog.

    👁 Toon oplossing
    this.dialog.add(WarrantyExtendDialog, { equipmentId: this.state.selectedId, onSave: () => this.refresh(), });
    4.7 Command Palette

    Odoo's command palette (Ctrl+K) laat gebruikers snel commando's uitvoeren. Je kunt custom commando's registreren via de command-service. Voor TechniCool voegen we een commando 'Nieuwe Interventie' toe zodat de dispatcher via Ctrl+K snel een werkorder kan aanmaken zonder meerdere klikken. Commando's kunnen ook context-gebonden zijn.

    setup() {
        this.command = useService("command");
        this.command.add(
            "TechniCool: Nieuwe Interventie",
            () => this.openNewWorkorderWizard(),
            { hotkey: "alt+n" }
        );
    }</pre>
    ✏ Mini-oefening

    Voeg een command palette-commando 'Scan Toestel' toe met hotkey Alt+S.

    👁 Toon oplossing
    this.command.add( 'TechniCool: Scan Toestel', () => this.startScanner(), { hotkey: 'alt+s' } );
    4.8 Tour & Onboarding

    Odoo's tour-systeem laat je interactieve walkthroughs maken voor nieuwe gebruikers. Je definieert een serie stappen die de gebruiker door de interface begeleiden. Voor TechniCool maken we een onboarding-tour voor nieuwe dispatchers die stap voor stap uitlegt hoe ze hun eerste toestel registreren in Vanventory, inclusief het instellen van garantie en het toewijzen van een technieker.

    import { registry } from "@web/core/registry";
    
    registry.category("web_tour.tours").add("vanventory_intro", {
        url: "/odoo/techniecool",
        steps: () => [
            { trigger: ".o-kanban-button-new", content: "Klik hier om een nieuw toestel toe te voegen" },
            { trigger: "input[id$=name]", content: "Vul de toestelnaam in" },
            { trigger: ".o_form_button_save", content: "Sla op" },
        ]
    });</pre>
    ✏ Mini-oefening

    Voeg een tour-stap toe die de gebruiker vraagt het serienummer-veld in te vullen.

    👁 Toon oplossing
    { trigger: 'input[id$=serial_number]', content: 'Vul het serienummer van het toestel in' },
    /** @odoo-module **/
    import { registry } from "@web/core/registry";
    import { Component } from "@odoo/owl";
    
    export class VanPlannerAction extends Component {
        static template = "techniecool.VanPlanner";
        setup() {
            // Planner logica hier
        }
    }
    
    registry.category("actions").add("van_planner_client_action", VanPlannerAction);</pre>
                
    ⚙ Praktijkopdracht

    Maak een Client Action "Mijn Dagoverzicht" voor de technieker. Deze pagina moet een grote klok tonen, de eerstvolgende interventie in het groot en een knop om direct de route te starten. Registreer dit in het menu en zorg dat het correct opent in Odoo.

    H5
    OWL + Camera & File API
    // getuserMedia, image processing, ir.attachment
    Week 13 · 8u

    Vanventory begint bij de foto. We leren hoe we de camera van de telefoon of tablet aansturen direct vanuit onze OWL component. We verwerken de afbeelding in de browser (resizen, croppen) en sturen deze als base64 naar de Odoo backend om op te slaan als `ir.attachment`. Dit is de eerste stap naar onze AI renderplaat.

    5.1 getUserMedia — Camera Toegang

    De browser Camera API via navigator.mediaDevices.getUserMedia() laat je de camera van het apparaat aansturen. Voor TechniCool's Vanventory-app scant de technieker het QR-label van een toestel of neemt een foto van een defect onderdeel. De video-stream koppel je aan een <video>-element via srcObject. Vergeet toestemming te vragen en de stream te stoppen bij unmount.

    async startCamera() {
        this.stream = await navigator.mediaDevices.getUserMedia({
            video: { facingMode: "environment" }  // achtercamera
        });
        this.videoRef.el.srcObject = this.stream;
    }
    
    stopCamera() {
        this.stream?.getTracks().forEach(t => t.stop());
    }</pre>
    ✏ Mini-oefening

    Schrijf de onWillUnmount-hook die de camerastream stopt bij verwijdering van het component.

    👁 Toon oplossing
    setup() { onWillUnmount(() => this.stopCamera()); }
    5.2 Canvas Capture — Foto Nemen

    Om een foto te maken van de videostream teken je het huidige frame op een <canvas> element via drawImage() en exporteer je het als base64 via canvas.toDataURL(). Voor TechniCool stuurt de app de base64-foto naar de Odoo backend als ir.attachment. De resolutie van de canvas moet overeenkomen met de videostroom voor scherpe resultaten.

    capturePhoto() {
        const canvas = this.canvasRef.el;
        const video = this.videoRef.el;
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;
        const ctx = canvas.getContext("2d");
        ctx.drawImage(video, 0, 0);
        return canvas.toDataURL("image/jpeg", 0.9);
    }</pre>
    ✏ Mini-oefening

    Sla de capturePhoto-resultaat op als variabele en log de lengte van de base64-string.

    👁 Toon oplossing
    const photo = this.capturePhoto(); console.log('Base64 lengte:', photo.length);
    5.3 File Input & Drag-and-Drop

    Naast de camera kun je bestanden selecteren via een <input type='file'> of drag-and-drop. De FileReader API leest het bestand asynchroon. Voor TechniCool's desktop-interface laadt de dispatcher technische documentatie of foto's via drag-and-drop op de toestelfiche, zonder de pagina te verlaten.

    onFileChange(ev) {
        const file = ev.target.files[0];
        if (!file) return;
        const reader = new FileReader();
        reader.onload = (e) => {
            this.state.fileData = e.target.result;
            this.state.fileName = file.name;
        };
        reader.readAsDataURL(file);
    }</pre>
    ✏ Mini-oefening

    Voeg een drag-and-drop handler toe die het bestand accepteert bij drop-event.

    👁 Toon oplossing
    onDrop(ev) { ev.preventDefault(); const file = ev.dataTransfer.files[0]; this.processFile(file); }
    5.4 QR Code Scannen — jsQR

    De jsQR-bibliotheek decodeert QR-codes vanuit canvas-pixels. Je leest de pixels van het canvas-frame via getImageData() en geeft die door aan jsQR(). Voor TechniCool scant de app het serienummer-QR-label op een Rational of Electrolux toestel en zoekt automatisch het bijhorende equipment-record op in Odoo.

    import jsQR from "jsqr";
    
    scanFrame() {
        const canvas = this.canvasRef.el;
        const ctx = canvas.getContext("2d");
        ctx.drawImage(this.videoRef.el, 0, 0);
        const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
        const code = jsQR(imgData.data, imgData.width, imgData.height);
        if (code) this.onQRDetected(code.data);
    }</pre>
    ✏ Mini-oefening

    Schrijf de onQRDetected-methode die het gescande serienummer zoekt in Odoo.

    👁 Toon oplossing
    async onQRDetected(serial) { const results = await this.orm.searchRead( 'techniecool.equipment', [['serial_number', '=', serial]], ['name', 'id'] ); if (results.length) this.openEquipment(results[0].id); }
    5.5 Continuö Scan Loop

    QR-codes worden niet in één keer gescand — je herhaalt de scan elke frame via requestAnimationFrame. Dit geeft een vloeiende scanervaring zonder de browser te blokkeren. Voor TechniCool draait de scan loop continu terwijl de camera actief is, stopt bij een geslaagde scan en herstart bij een nieuw te scannen toestel.

    startScanLoop() {
        this.scanning = true;
        const loop = () => {
            if (!this.scanning) return;
            this.scanFrame();
            requestAnimationFrame(loop);
        };
        requestAnimationFrame(loop);
    }
    
    stopScanLoop() {
        this.scanning = false;
    }</pre>
    ✏ Mini-oefening

    Hoe stop je de scan loop na een succesvolle QR-detectie?

    👁 Toon oplossing
    async onQRDetected(serial) { this.stopScanLoop(); // stop loop await this.processSerial(serial); // optioneel: this.startScanLoop() om te herstarten }
    5.6 MediaRecorder — Video Opname

    De MediaRecorder API legt video op van een MediaStream. Dit is nuttig voor TechniCool als een technieker een korte video wil opnemen van een defect. De video-chunks worden verzameld in een array en samengesteld tot een Blob die vervolgens als bijlage naar Odoo gestuurd wordt.

    startRecording() {
        this.chunks = [];
        this.recorder = new MediaRecorder(this.stream);
        this.recorder.ondataavailable = (e) => this.chunks.push(e.data);
        this.recorder.onstop = () => {
            const blob = new Blob(this.chunks, { type: "video/webm" });
            this.uploadVideo(blob);
        };
        this.recorder.start();
    }</pre>
    ✏ Mini-oefening

    Schrijf de methode die de opname stopt na 10 seconden via setTimeout.

    👁 Toon oplossing
    startRecording() { // ... recorder setup ... this.recorder.start(); setTimeout(() => this.recorder.stop(), 10000); }
    5.7 Bestand Comprimeren voor Upload

    Hoge-resolutie foto's van professionele keukentoestellen zijn groot. Voor TechniCool comprimeren we foto's op de client vóór upload via canvas-resize en JPEG-kwaliteitsparameter. Dit bespaart bandbreedte en opslagruimte terwijl de foto's nog steeds leesbaar zijn voor diagnose. De canvas-techniek laat je de doelresolutie en kwaliteit instellen.

    compressImage(dataUrl, maxWidth = 1200, quality = 0.8) {
        return new Promise(resolve => {
            const img = new Image();
            img.onload = () => {
                const canvas = document.createElement("canvas");
                const ratio = maxWidth / img.width;
                canvas.width = maxWidth;
                canvas.height = img.height * ratio;
                canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
                resolve(canvas.toDataURL("image/jpeg", quality));
            };
            img.src = dataUrl;
        });
    }</pre>
    ✏ Mini-oefening

    Gebruik compressImage en log de verhouding tussen originele en gecomprimeerde base64-lengte.

    👁 Toon oplossing
    const original = photo.length; const compressed = await this.compressImage(photo); console.log('Ratio:', compressed.length / original);
    5.8 EXIF Data Verwijderen

    Camera-foto's bevatten EXIF-metadata inclusief GPS-locatie, wat een privacyrisico vormt. Door de foto via canvas te tekenen worden EXIF-data automatisch verwijderd omdat canvas alleen pixels kopieert. Voor TechniCool is dit belangrijk: technici die foto's nemen bij klanten mogen geen locatiedata lekken naar externe systemen. Het canvas-compressiepatroon lost dit als bijwerking op.

    stripExif(dataUrl) {
        return new Promise(resolve => {
            const img = new Image();
            img.onload = () => {
                const c = document.createElement("canvas");
                c.width = img.width; c.height = img.height;
                c.getContext("2d").drawImage(img, 0, 0);
                resolve(c.toDataURL("image/jpeg"));
            };
            img.src = dataUrl;
        });
    }</pre>
    ✏ Mini-oefening

    Wanneer worden EXIF-data automatisch verwijderd in een canvas-pipeline?

    👁 Toon oplossing
    // Bij het tekenen op canvas via ctx.drawImage() // en opnieuw exporteren via canvas.toDataURL() // worden alle metadata incl. GPS verwijderd. // Enkel de ruwe pixels blijven over.
    async takePhoto() {
        const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
        const video = document.createElement('video');
        video.srcObject = stream;
        await video.play();
        
        const canvas = document.createElement('canvas');
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;
        canvas.getContext('2d').drawImage(video, 0, 0);
        
        const base64Image = canvas.toDataURL('image/jpeg').split(',')[1];
        await this.orm.create("ir.attachment", [{
            name: "van_setup.jpg",
            datas: base64Image,
            res_model: "fleet.vehicle",
            res_id: this.props.vanId
        }]);
    }</pre>
                
    ⚙ Praktijkopdracht

    Bouw een component VanCamera. Dit component moet een knop "Start Camera" hebben. Na klik zie je de live feed. Een tweede knop "Neem Foto" neemt een snapshot, toont een preview en geeft een melding "Opgeslagen in Odoo" na succesvolle upload naar een test-record.

    H6
    SVG & Canvas in OWL
    // coordinates, vector drawing, responsive overlays
    Week 14 · 8u

    Nu we een foto hebben, moeten we er locaties op kunnen tekenen. We leren hoe we SVG (Scalable Vector Graphics) gebruiken over een afbeelding. Je leert coördinaten systemen begrijpen: hoe zorg je dat een getekende box op de juiste plek blijft staan als het scherm van formaat verandert? Dit is de basis voor de Vanventory locatie mapper.

    6.1 SVG Fundamenten in OWL

    SVG (Scalable Vector Graphics) is een XML-gebaseerd formaat voor vectortekeningen rechtstreeks in de browser. In OWL embed je SVG inline in de template zodat je er reactief aan kunt binden. Voor TechniCool's Vanventory-app tekenen we de plattegrond van een professionele keuken als SVG-laag waarop we de posities van Rational combi-steamers en Electrolux blast chillers aanduiden. SVG-elementen zoals <rect>, <circle> en <text> zijn volledig stijlbaar via CSS.

    static template = xml`
      <svg width='800' height='600'>
        <t t-foreach='equipment' t-as='eq'>
          <rect t-att-x='eq.x' t-att-y='eq.y'
                width='60' height='40' fill='orange'/>
          <text t-att-x='eq.x + 5' t-att-y='eq.y + 25'>
            <t t-esc='eq.serial'/>
          </text>
        </t>
      </svg>
    `;</pre>
    ✏ Mini-oefening

    Voeg een <circle> toe in de SVG-template die een alarm-indicator toont op positie (eq.x, eq.y).

    👁 Toon oplossing
    6.2 Canvas 2D Tekenen

    De Canvas API geeft directe pixelmanipulatie voor complexe tekeningscenario's die SVG te traag maakt. Gebruik getContext('2d') om de 2D-rendercontext te krijgen. Voor TechniCool tekenen we heatmaps van interventiefrequentie per keukenzone op het canvas. Canvas is sneller dan SVG bij honderden dynamische elementen maar biedt geen native interactiviteit per element.

    onMounted(() => {
        const ctx = this.canvasRef.el.getContext('2d');
        ctx.fillStyle = 'orange';
        ctx.fillRect(10, 10, 150, 100);
        ctx.strokeStyle = '#1a1510';
        ctx.strokeRect(10, 10, 150, 100);
        ctx.fillStyle = 'white';
        ctx.font = '14px Syne';
        ctx.fillText('Rational iCombi', 20, 65);
    });</pre>
    ✏ Mini-oefening

    Teken een cirkel op het canvas op positie (200, 150) met straal 30 en oranje vulkleur.

    👁 Toon oplossing
    ctx.beginPath(); ctx.arc(200, 150, 30, 0, Math.PI * 2); ctx.fillStyle = 'orange'; ctx.fill();
    6.3 SVG Polygon Mapper — Klik Detectie

    Polygonen in SVG zijn klikbaar via pointer events. Je kunt een t-on-click handler koppelen aan elk SVG-element om interactie te verwerken. Voor TechniCool klikken technici op een toestel in de keukenplattegrond om de detailpagina van dat toestel te openen. De pointer-events CSS-eigenschap bepaalt of overlappende elementen de klik ontvangen.

    static template = xml`
      <svg>
        <t t-foreach='zones' t-as='zone'>
          <polygon
            t-att-points='zone.svgPoints'
            fill='rgba(255,128,0,0.3)'
            stroke='orange'
            t-on-click='() => this.onZoneClick(zone)'
            class='cursor-pointer'
          />
        </t>
      </svg>
    `;</pre>
    ✏ Mini-oefening

    Hoe zet je de SVG-polygoonstapel om van {x, y}-objecten naar een puntenstring?

    👁 Toon oplossing
    const toPoints = (pts) => pts.map(p => `${p.x},${p.y}`).join(' ');
    6.4 Animaties met requestAnimationFrame

    Vloeiende animaties in Canvas of SVG gebruik je requestAnimationFrame voor een 60fps-loop. Voor TechniCool animeren we een pulserend alarm-icoon op toestellen met een dringende interventie. De animatie start bij mounting en stopt bij unmount om geheugenlekken te vermijden. Gebruik performance.now() voor tijdgebaseerde animaties onafhankelijk van framerate.

    setup() {
        this.animId = null;
        onMounted(() => this.startAnim());
        onWillUnmount(() => cancelAnimationFrame(this.animId));
    }
    
    startAnim() {
        const draw = (t) => {
            const alpha = (Math.sin(t / 500) + 1) / 2;
            this.drawAlarm(alpha);
            this.animId = requestAnimationFrame(draw);
        };
        this.animId = requestAnimationFrame(draw);
    }</pre>
    ✏ Mini-oefening

    Bereken de sinuswaarde voor pulserende transparantie op tijdstip t (milliseconden).

    👁 Toon oplossing
    const alpha = (Math.sin(t / 500) + 1) / 2; // 0 tot 1
    6.5 SVG Export als PNG

    Een SVG kan geëxporteerd worden als PNG-afbeelding via de canvas-techniek: serialiseer de SVG naar een data-URL, laad die in een Image, teken op canvas en exporteer. Voor TechniCool exporteert de dispatcher de keukenplattegrond met toestelposities als PDF-bijlage voor de klant. Dit gaat volledig client-side zonder server-roundtrip.

    exportSvgAsPng() {
        const svg = this.svgRef.el;
        const data = new XMLSerializer().serializeToString(svg);
        const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(data);
        const img = new Image();
        img.onload = () => {
            const c = document.createElement('canvas');
            c.width = svg.width.baseVal.value;
            c.height = svg.height.baseVal.value;
            c.getContext('2d').drawImage(img, 0, 0);
            const link = document.createElement('a');
            link.download = 'plattegrond.png';
            link.href = c.toDataURL();
            link.click();
        };
        img.src = url;
    }</pre>
    ✏ Mini-oefening

    Hoe stel je de bestandsnaam in van de gedownloade PNG?

    👁 Toon oplossing
    link.download = 'TechniCool_plattegrond.png';
    6.6 SVG Drag & Drop

    Toestellen verplaatsen op de keukenplattegrond implementeer je via SVG drag events. Je houdt bij of een element gesleept wordt via mousedown, mousemove en mouseup. Voor TechniCool kunnen dispatchers toestellen op de plattegrond herpositioneren en worden de nieuwe x/y-coördinaten opgeslagen via een ORM write()-call. Gebruik de SVG-coördinatentransformatie voor correcte positionering.

    onMouseDown(ev, eq) {
        this.dragging = { id: eq.id, startX: ev.clientX - eq.x, startY: ev.clientY - eq.y };
    }
    
    onMouseMove(ev) {
        if (!this.dragging) return;
        const x = ev.clientX - this.dragging.startX;
        const y = ev.clientY - this.dragging.startY;
        this.updatePosition(this.dragging.id, x, y);
    }
    
    onMouseUp() { this.dragging = null; }</pre>
    ✏ Mini-oefening

    Schrijf de updatePosition-methode die de nieuwe x, y opslaat via ORM write.

    👁 Toon oplossing
    async updatePosition(id, x, y) { await this.orm.write('techniecool.equipment', [id], { pos_x: x, pos_y: y }); }
    6.7 D3.js Integratie

    D3.js is een krachtige datavisualisatiebibliotheek die SVG-elementen bindt aan data. Voor TechniCool's management-dashboard tekent D3 een staafdiagram van interventiekosten per merk. Je initialiseert D3 in onMounted na het laden van data via de ORM-service. D3's scales en axes automatiseren de berekening van posities en labels.

    import * as d3 from "d3";
    
    onMounted(() => {
        const svg = d3.select(this.svgRef.el);
        const bars = svg.selectAll('rect').data(this.state.stats);
        bars.enter().append('rect')
            .attr('x', (d, i) => i * 120)
            .attr('y', d => 300 - d.cost / 10)
            .attr('width', 80)
            .attr('height', d => d.cost / 10)
            .attr('fill', 'orange');
    });</pre>
    ✏ Mini-oefening

    Hoe selecteer je in D3 alle rect-elementen in een SVG en verander je de vulkleur naar '#ff6600'?

    👁 Toon oplossing
    d3.selectAll('rect').attr('fill', '#ff6600');
    6.8 Performance: OffscreenCanvas

    OffscreenCanvas laat je canvas-rendering uitvoeren in een Web Worker, buiten de main thread. Dit is essentieel voor zware renders die de UI anders blokkeren. Voor TechniCool verwerken we heatmap-berekeningen en beeldanalyse in een worker zodat de camera-feed vloeiend blijft draaien terwijl de AI-analyse op de achtergrond loopt. Ondersteuning in moderne browsers is breed maar check iOS Safari versie.

    const worker = new Worker('canvas_worker.js');
    const offscreen = canvas.transferControlToOffscreen();
    worker.postMessage({ canvas: offscreen }, [offscreen]);
    
    // In canvas_worker.js:
    self.onmessage = (e) => {
        const ctx = e.data.canvas.getContext('2d');
        // Render hier, non-blocking
    };</pre>
    ✏ Mini-oefening

    Waarom is OffscreenCanvas sneller dan gewone canvas-rendering op de main thread?

    👁 Toon oplossing
    // OffscreenCanvas draait in een Web Worker // die parallel aan de main thread loopt. // Zware berekeningen blokkeren de UI niet.
    <!-- OWL Template fragment voor SVG Overlay -->
    <svg viewBox="0 0 100 100" preserveAspectRatio="none" class="van-overlay">
        <t-foreach items="state.polygons" t-as="poly" t-key="poly.id">
            <polygon 
                t-att-points="poly.points" 
                t-att-class="{ 'highlight': poly.id === state.selectedId }"
                t-on-click="() => this.selectLocation(poly.id)"
                fill="rgba(255, 140, 0, 0.3)"
                stroke="orange"
                stroke-width="0.5"
            />
        </t-foreach>
    </svg></pre>
                
    ⚙ Praktijkopdracht

    Bouw een interactieve SVG mapper. Toon een afbeelding van een keukentoestel. Laat de gebruiker 3 rechthoeken tekenen (door te klikken en te slepen). Sla de coördinaten op in de state en toon ze als een JSON lijst onder de afbeelding. Zorg dat de rechthoeken oranje oplichten als je erover hovert.

    H7
    Mapbox/Leaflet Integratie in OWL
    // external libraries, markers, geojson
    Week 15 · 8u

    Voor de buitenkant van de wagen hebben we GPS nodig. We integreren Mapbox GL JS in onze Odoo client action. Je leert hoe je externe libraries laadt via de Odoo asset bundle, hoe je markers plaatst voor alle technici van TechniCool en hoe je routes tekent tussen interventies. We gebruiken GeoJSON als standaard formaat voor alle geografische data.

    7.1 Leaflet Setup in OWL

    Leaflet is een lichtgewicht open-source kaartbibliotheek. Je initialiseert een Leaflet-kaart in onMounted op een DOM-container. Voor TechniCool toont de kaart alle geregistreerde klantlocaties met hun toestellen als markers. De dispatcher ziet in één oogopslag welke technici in welke regio beschikbaar zijn en kan optimaal routes plannen voor interventies.

    import L from "leaflet";
    
    onMounted(() => {
        this.map = L.map(this.mapRef.el).setView([50.85, 4.35], 9);
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '© OpenStreetMap'
        }).addTo(this.map);
        this.addMarkers();
    });</pre>
    ✏ Mini-oefening

    Stel de kaartweergave in op Antwerpen (51.22, 4.40) met zoomniveau 11.

    👁 Toon oplossing
    this.map.setView([51.22, 4.40], 11);
    7.2 Markers & Popups

    Leaflet-markers kun je dynamisch toevoegen op basis van data. Elk marker kan een popup bevatten met HTML-inhoud. Voor TechniCool toont de popup de klantnaam, het aantal toestellen en een link naar de klantenfiche in Odoo. Custom iconen laten je het merk (Rational = blauw, Electrolux = rood) visueel onderscheiden op de kaart.

    addMarkers() {
        this.state.locations.forEach(loc => {
            const icon = L.divIcon({
                html: `
    `, className: '' }); L.marker([loc.lat, loc.lng], { icon }) .bindPopup(`${loc.name}
    ${loc.equipment_count} toestellen`) .addTo(this.map); }); }</pre>
    ✏ Mini-oefening

    Voeg een popup toe aan een marker die de naam en het serienummer van een toestel toont.

    👁 Toon oplossing
    L.marker([lat, lng]) .bindPopup(`${name}
    SN: ${serial}`) .addTo(this.map);
    7.3 Mapbox GL JS

    Mapbox GL JS biedt 3D-kaarten, aangepaste stijlen en betere performantie dan Leaflet voor complexe visualisaties. Je hebt een Mapbox API-token nodig, bewaard in ir.config_parameter. Voor TechniCool's geavanceerd dashboard toont een Mapbox kaart de routes van technici naar interventies als gekleurde lijnen, met clustering van markers bij hoge zoom-out.

    import mapboxgl from "mapbox-gl";
    
    onMounted(() => {
        mapboxgl.accessToken = this.state.apiToken;
        this.map = new mapboxgl.Map({
            container: this.mapRef.el,
            style: "mapbox://styles/mapbox/streets-v12",
            center: [4.35, 50.85],
            zoom: 9
        });
    });</pre>
    ✏ Mini-oefening

    Voeg een Mapbox NavigationControl toe zodat de gebruiker kan in- en uitzoomen.

    👁 Toon oplossing
    this.map.addControl(new mapboxgl.NavigationControl());
    7.4 GeoJSON Lagen

    GeoJSON is het standaard formaat voor geografische data in webmapping. Leaflet en Mapbox ondersteunen beiden native GeoJSON. Voor TechniCool laad je de regiogrenzen van Belgische provincies als GeoJSON-laag en kleurt elke provincie op basis van het aantal interventies in die regio. Dit geeft de servicemanager geografische inzichten in werkdruk.

    this.map.on('load', async () => {
        const geojson = await fetch('/static/src/data/belgium_provinces.geojson').then(r => r.json());
        this.map.addSource('provinces', { type: 'geojson', data: geojson });
        this.map.addLayer({
            id: 'provinces-fill',
            type: 'fill',
            source: 'provinces',
            paint: { 'fill-color': 'orange', 'fill-opacity': 0.3 }
        });
    });</pre>
    ✏ Mini-oefening

    Hoe voeg je een contour-lijn toe voor de provinciesgrenzen na het toevoegen van de fill-laag?

    👁 Toon oplossing
    this.map.addLayer({ id: 'provinces-line', type: 'line', source: 'provinces', paint: { 'line-color': '#ff6600', 'line-width': 2 } });
    7.5 Geocoding — Adres naar Coördinaten

    Geocoding converteert een tekstadres naar GPS-coördinaten. Gebruik de Mapbox Geocoding API of Nominatim (OpenStreetMap, gratis). Voor TechniCool geocodet de app het adres van een nieuwe klant bij registratie zodat de locatie automatisch op de kaart verschijnt zonder manuele coördinaten in te vullen. Sla de coördinaten op als Float-velden in het res.partner-model.

    async geocodeAddress(address) {
        const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`;
        const resp = await fetch(url, { headers: { 'Accept-Language': 'nl' } });
        const data = await resp.json();
        if (data.length) return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
        return null;
    }</pre>
    ✏ Mini-oefening

    Gebruik geocodeAddress voor 'Brusselsesteenweg 42, Gent' en log de coördinaten.

    👁 Toon oplossing
    const coords = await this.geocodeAddress('Brusselsesteenweg 42, Gent'); console.log(coords); // { lat: ..., lng: ... }
    7.6 Route Weergave

    Voor routeplanning gebruik je de OSRM API (open source) of de Mapbox Directions API. Je stuurt een lijst van waypoints (technieker thuis + klantadressen) en de API retourneert de optimale route als GeoJSON LineString. Voor TechniCool optimaliseert de app de dagplanning van een technieker met 6 klanten op basis van rijafstand.

    async fetchRoute(start, end) {
        const url = `https://router.project-osrm.org/route/v1/driving/${start.lng},${start.lat};${end.lng},${end.lat}?geometries=geojson`;
        const resp = await fetch(url);
        const data = await resp.json();
        return data.routes[0].geometry;
    }
    
    displayRoute(geojson) {
        this.map.getSource('route')?.setData(geojson);
    }</pre>
    ✏ Mini-oefening

    Bereken de afstand in km van een OSRM-route-response.

    👁 Toon oplossing
    const distanceKm = data.routes[0].distance / 1000;
    7.7 Cluster Markers

    Bij veel markers op een kaart gebruik je clustering om performance te verbeteren en de kaart leesbaar te houden. Leaflet.markercluster groepeert nabijgelegen markers in een cluster-icoon. Voor TechniCool clustert de kaart de 200+ klantlocaties van TechniCool automatisch per regio, met een badge die het aantal toestellen in het cluster toont.

    import L from "leaflet";
    import "leaflet.markercluster";
    
    const cluster = L.markerClusterGroup();
    locations.forEach(loc => {
        cluster.addLayer(L.marker([loc.lat, loc.lng]));
    });
    this.map.addLayer(cluster);</pre>
    ✏ Mini-oefening

    Hoe pas je de maximale clusterradius aan naar 60 pixels bij initialisatie?

    👁 Toon oplossing
    const cluster = L.markerClusterGroup({ maxClusterRadius: 60 });
    7.8 Real-time Locatie Updates

    Technici met GPS-tracking sturen hun locatie periodiek via WebSocket of polling. De kaart toont real-time de positie van elke technieker als een bewegend icoon. Voor TechniCool gebruikt de dispatcher-view de Odoo bus (longpolling) om live locatieupdates te ontvangen zonder de pagina te herladen. Dit vereist een bus-channel per technieker.

    setup() {
        this.busService = useService('bus_service');
        onMounted(() => {
            this.busService.subscribe('technician_location', (payload) => {
                this.updateTechMarker(payload.technician_id, payload.lat, payload.lng);
            });
        });
    }</pre>
    ✏ Mini-oefening

    Leg uit hoe je een Odoo bus-channel aanmaakt in Python voor locatieupdates.

    👁 Toon oplossing
    # In een Odoo controller: self.env['bus.bus']._sendone( f'technician_{technician_id}', 'technician_location', {'lat': lat, 'lng': lng} )
    /** @odoo-module **/
    import { Component, onMounted, useRef } from "@odoo/owl";
    
    export class MapView extends Component {
        static template = "techniecool.MapView";
        setup() {
            this.mapRef = useRef("map");
            onMounted(() => {
                mapboxgl.accessToken = 'YOUR_KEY';
                this.map = new mapboxgl.Map({
                    container: this.mapRef.el,
                    style: 'mapbox://styles/mapbox/streets-v11',
                    center: [4.4025, 51.2194], // Antwerpen
                    zoom: 12
                });
            });
        }
    }</pre>
                
    ⚙ Praktijkopdracht

    Integreer Mapbox in een Odoo Client Action. Laad een lijst van TechniCool klantlocaties via RPC en toon ze als markers op de kaart. Als je op een marker klikt, moet er een popup verschijnen met de naam van de klant en een knop "Plan Interventie" (die voorlopig enkel een console log doet).

    H8
    Performance & Testing Frontend
    // asset bundles, unit tests, profiling
    Week 16 · 8u

    Een trage app wordt niet gebruikt. We leren hoe Odoo zijn assets (JS/CSS) combineert en hoe we dit kunnen optimaliseren. We duiken in de Odoo test suite om OWL componenten automatisch te testen. Je leert hoe je 'Mocks' gebruikt om de server te simuleren, zodat je tests razendsnel draaien zonder echte database connectie.

    8.1 Bundle Optimalisatie

    Odoo's asset bundler combineert JavaScript-bestanden in produktie. Grote afhankelijkheden zoals D3 of Leaflet moeten als externe asset geladen worden om de bundle niet onnodig op te blazen. Voor TechniCool configureren we de __manifest__.py correct zodat alleen de benodigde assets in de correcte volgorde geladen worden en laadtijden minimaal blijven voor mobiele technici op 4G.

    'assets': {
        'web.assets_backend': [
            'techniecool_base/static/src/js/dashboard.js',
            'techniecool_base/static/src/css/dashboard.css',
        ],
        'web.assets_frontend': [
            'techniecool_base/static/src/js/portal.js',
        ],
    }</pre>
    ✏ Mini-oefening

    Hoe voeg je een externe CDN-bibliotheek toe als script in de Odoo asset-definitie?

    👁 Toon oplossing
    'assets': { 'web.assets_backend': [ 'https://unpkg.com/jsqr/dist/jsQR.js', ], }
    8.2 Lazy Loading Componenten

    Grote OWL-componenten die niet onmiddellijk nodig zijn kun je lazily laden met dynamic import(). Dit versnelt de initiële laadtijd. Voor TechniCool laadt het camera-component pas als de gebruiker op 'Scan QR' klikt, niet bij het opstarten van het dashboard. OWL's useLazyComponent-patroon of dynamic import werken hiervoor.

    async openScanner() {
        const { CameraScanner } = await import('./camera_scanner');
        this.dialog.add(CameraScanner, {
            onScan: (serial) => this.handleScan(serial)
        });
    }</pre>
    ✏ Mini-oefening

    Schrijf een lazy import die de MapView-component laadt enkel wanneer de gebruiker op 'Kaart' klikt.

    👁 Toon oplossing
    async showMap() { const { MapView } = await import('./map_view'); // open map }
    8.3 OWL Unit Tests — jest

    OWL-componenten kunnen getest worden met Jest en de OWL test helpers. mount() rendert een component in een test-DOM. Je kunt props simuleren, state veranderen en DOM-queries uitvoeren. Voor TechniCool testen we of de WarrantyIndicator de juiste kleur toont op basis van het aantal resterende garantiedagen.

    import { mount } from '@odoo/owl';
    import { WarrantyIndicator } from './warranty_indicator';
    
    test('toont rood bij 0 resterende dagen', async () => {
        const component = await mount(WarrantyIndicator, document.body, {
            props: { daysLeft: 0 }
        });
        expect(component.el.classList.contains('danger')).toBe(true);
    });</pre>
    ✏ Mini-oefening

    Schrijf een test die controleert of de component de tekst 'Vervallen' toont als daysLeft 0 is.

    👁 Toon oplossing
    test('toont Vervallen tekst', async () => { const comp = await mount(WarrantyIndicator, document.body, { props: { daysLeft: 0 } }); expect(comp.el.textContent).toContain('Vervallen'); });
    8.4 End-to-End Tests — Playwright

    Playwright automatiseert een echte browser voor end-to-end tests. Je kunt navigeren, klikken, typen en screenshots maken. Voor TechniCool test de e2e-suite de volledige flow: inloggen, toestel registreren, QR scannen en werkorder aanmaken. Dit garandeert dat integraties tussen OWL, Odoo en de Camera API correct werken in productie.

    const { chromium } = require('playwright');
    
    test('registreer nieuw toestel', async () => {
        const browser = await chromium.launch();
        const page = await browser.newPage();
        await page.goto('http://localhost:8069/web');
        await page.fill('#login', 'admin');
        await page.fill('#password', 'admin');
        await page.click('button[type=submit]');
        // navigeer naar toestellen...
        await browser.close();
    });</pre>
    ✏ Mini-oefening

    Hoe wacht je in Playwright tot een element met class 'equipment-card' zichtbaar is?

    👁 Toon oplossing
    await page.waitForSelector('.equipment-card');
    8.5 Performance Profiling

    Chrome DevTools Performance-tab toont flame charts van je JavaScript-uitvoering. Zoek naar lange taken (>50ms) die de UI blokkeren. Voor TechniCool profileren we het laden van 500 toestellen in het dashboard en ontdekken we dat de guarantee-kleurberekening per kaart de bottleneck is. Door dit te verplaatsen naar een server-side computed field reduceren we de rendertijd met 80%.

    // Voor profiling: markeer kritische code
    performance.mark('render-start');
    this.renderEquipmentList();
    performance.mark('render-end');
    performance.measure('render', 'render-start', 'render-end');
    const [measure] = performance.getEntriesByName('render');
    console.log('Render tijd:', measure.duration, 'ms');</pre>
    ✏ Mini-oefening

    Wat is een 'Long Task' in browser performance profiling?

    👁 Toon oplossing
    // Een Long Task is een JavaScript-taak die // langer dan 50ms duurt op de main thread. // Dit blokkeert rendering en maakt de UI // niet-responsief voor de gebruiker.
    8.6 Memoization & useCallback

    Onnodige her-renders vermijden is essentieel voor performance. OWL herrendert een component enkel als de props of state veranderen. Callback-functies die inline in de template gedefinieerd worden, zijn elke render een nieuw object en triggeren onnodige re-renders bij child-componenten. Gebruik useMemo of definieer callbacks als klasse-methoden.

    // Slecht: nieuwe functie bij elke render
    static template = xml`
      <EquipmentCard onSelect='() => this.select(eq.id)'/>
    `;
    
    // Goed: stabiele referentie
    select = (id) => { this.state.selected = id; };</pre>
    ✏ Mini-oefening

    Waarom triggert een inline arrow function in een template onnodige re-renders?

    👁 Toon oplossing
    // Elke render maakt een nieuwe functie-instantie // Die als 'nieuw' prop herkend wordt door OWL // Waardoor het child-component onnodig re-rendert
    8.7 Virtual Scrolling

    Bij lijsten met honderden items verbetert virtual scrolling de performance dramatisch. Alleen de zichtbare items worden gerenderd in de DOM; de rest wordt gesimuleerd via lege ruimte. Voor TechniCool's toestellen-lijst met 2000 items implementeren we virtual scrolling zodat de lijst vloeiend scrolt op mobiele apparaten van technici. Bibliotheken zoals @tanstack/virtual vereenvoudigen de implementatie.

    import { useVirtualizer } from "@tanstack/virtual";
    
    setup() {
        this.virtualizer = useVirtualizer({
            count: this.state.items.length,
            getScrollElement: () => this.listRef.el,
            estimateSize: () => 60,
        });
    }</pre>
    ✏ Mini-oefening

    Wat is het verschil tussen virtual scrolling en paginering?

    👁 Toon oplossing
    // Paginering laadt data in pagina's (API-calls) // Virtual scrolling laadt alle data maar rendert // enkel de zichtbare DOM-elementen
    8.8 Lighthouse Audit

    Google Lighthouse analyseert webperformance, toegankelijkheid en best practices. Voor TechniCool's Vanventory PWA streven we naar een Lighthouse score van 90+ op alle categorieën. De grootste winst zit in het toevoegen van een service worker, het optimaliseren van afbeeldingsformaten (WebP), en het verwijderen van ongebruikte JavaScript via tree-shaking.

    // Voeg toe aan _manifest.py voor PWA-support
    # manifest.json voor service worker
    {
      "name": "Vanventory TechniCool",
      "start_url": "/odoo/vanventory",
      "display": "standalone",
      "icons": [{"src": "/static/src/img/icon-192.png", "sizes": "192x192"}]
    }</pre>
    ✏ Mini-oefening

    Welke Lighthouse-categorie is het meest kritisch voor mobiele gebruikers op 4G?

    👁 Toon oplossing
    // Performance - specifiek: // Largest Contentful Paint (LCP) < 2.5s // en First Input Delay (FID) < 100ms
    // Voorbeeld OWL Unit Test
    QUnit.module("TechnicianStatusCard", (hooks) => {
        QUnit.test("status changes on click", async (assert) => {
            const card = await makeTestComponent({
                type: TechnicianStatusCard,
                props: { name: "Jonas" }
            });
            
            await click(card.el, ".btn-toggle");
            assert.equal(card.state.status, "busy", "Status moet wijzigen naar busy");
        });
    });</pre>
                
    Vak 3 · Weken 17–28 · 96 contacturen

    Odoo Modules
    & Addons

    Dit is het centrale vak. Je bouwt de volledige `techniecool_service` module van nul. Na dit vak heb je een productierijpe Odoo module.

    12
    Weken
    96u
    Les
    TechniCool
    Module
    Cursusinhoud
    Hoofdstukken
    H1
    Module Architectuur & Best Practices
    // manifest, structure, naming, git
    Week 17 · 8u

    Een goede Odoo module begint bij een goede structuur. We leren de standaard mappenstructuur van Odoo modules kennen en waarom TechniCool NV kiest voor een modulaire aanpak. We configureren het manifest bestand volledig, beheren afhankelijkheden en zetten een Git workflow op die voldoet aan de OCA standaarden.

    1.1 Module Mapstructuur

    Een Odoo-module is een Python-package met een vaste mapstructuur. De __manifest__.py beschrijft de module: naam, versie, afhankelijkheden en bestanden. Voor TechniCool volgt techniecool_base de standaard Odoo-structuur met aparte mappen voor models, views, security, data en static assets. Een consistente structuur maakt onderhoud en samenwerking in teams eenvoudiger.

    techniecool_base/
    ├── __init__.py
    ├── __manifest__.py
    ├── models/
    │   ├── __init__.py
    │   └── equipment.py
    ├── views/
    │   └── equipment_views.xml
    ├── security/
    │   ├── ir.model.access.csv
    │   └── security.xml
    └── data/
        └── sequences.xml</pre>
    ✏ Mini-oefening

    Waar bewaar je de ir.model.access.csv in een Odoo-module?

    👁 Toon oplossing
    # In de map security/ # Pad: module_name/security/ir.model.access.csv
    1.2 __manifest__.py Best Practices

    De manifest beschrijft alle metadata van een Odoo-module. Correcte versienummering (16.0.1.0.0), duidelijke afhankelijkheden en een goede summary zijn essentieel voor modulemanagement en OCA-contributie. Voor TechniCool declareren we afhankelijkheden op fieldservice, stock en mail. Vergeet nooit de installable en auto_install flags.

    {
        'name': 'TechniCool Vanventory',
        'version': '17.0.1.0.0',
        'category': 'Field Service',
        'summary': 'Professionele keukentoestellen beheer',
        'depends': ['fieldservice', 'stock', 'mail'],
        'data': [
            'security/ir.model.access.csv',
            'views/equipment_views.xml',
        ],
        'installable': True,
        'license': 'LGPL-3',
    }</pre>
    ✏ Mini-oefening

    Welke depends zijn nodig voor een module die mail.thread en stock.picking gebruikt?

    👁 Toon oplossing
    'depends': ['mail', 'stock']
    1.3 Models Package Organisatie

    Grote modules splitsen hun modellen over meerdere Python-bestanden per domein. Het models/__init__.py importeert alle submodules. Voor TechniCool scheiden we equipment.py, intervention.py, contract.py en technician.py. Dit vermijdt bestanden van duizenden regels en maakt code reviews beheersbaar.

    # models/__init__.py
    from . import equipment
    from . import intervention
    from . import contract
    from . import technician
    
    # models/equipment.py
    from odoo import models, fields
    
    class TechniCoolEquipment(models.Model):
        _name = 'techniecool.equipment'</pre>
    ✏ Mini-oefening

    Hoe importeer je een nieuw bestand report.py in het models-package?

    👁 Toon oplossing
    # In models/__init__.py voeg toe: from . import report
    1.4 Data Files & Demo Data

    Data files laden XML-records bij installatie (configuratiedata, sequences, e-mailsjablonen). Demo data laadt enkel bij demo-installaties en mag nooit in productie. Voor TechniCool laden data files de standaard serviceniveaus en garantiesequenties. Demo files vullen het systeem met realistische testtoestellen van Rational en Electrolux.

    # In __manifest__.py
    'data': [
        'data/sequences.xml',
        'data/service_levels.xml',
    ],
    'demo': [
        'demo/demo_equipment.xml',
        'demo/demo_clients.xml',
    ],</pre>
    ✏ Mini-oefening

    Wat is het verschil tussen 'data' en 'demo' in de manifest?

    👁 Toon oplossing
    # 'data': laadt altijd bij installatie/update # 'demo': laadt enkel bij demo-installaties # Demo data mag nooit naar productie
    1.5 Versioning & Upgrading

    Odoo-modules volgen semantic versioning: ODOO_VERSION.MAJOR.MINOR.PATCH. Bij een module-update voert Odoo automatisch post_init_hook of migratescripts uit. Voor TechniCool schrijven we een migratescript in migrations/17.0.1.1.0/post-migrate.py dat bestaande garantiedatums converteert naar het nieuwe formaat.

    # migrations/17.0.1.1.0/post-migrate.py
    def migrate(cr, version):
        cr.execute("""
            UPDATE techniecool_equipment
            SET warranty_expiry = install_date + INTERVAL '2 years'
            WHERE warranty_expiry IS NULL
              AND install_date IS NOT NULL
        """)</pre>
    ✏ Mini-oefening

    Wat is de bestandsnaamconventie voor een Odoo-migratescript van versie 1.0.0 naar 1.1.0?

    👁 Toon oplossing
    # migrations/17.0.1.1.0/post-migrate.py # of pre-migrate.py voor migratie voor module-update
    1.6 Naamgevingsconventies

    Consistente naamgeving voorkomt conflicten en maakt de codebase leesbaar. Modelnamen gebruiken dot-notatie (techniecool.equipment), velden gebruiken snake_case, XML-IDs krijgen een module-prefix. Voor TechniCool: alle XML-IDs beginnen met techniecool_, alle modelnamen beginnen met techniecool., alle Python-klassen beginnen met TechniCool.

    # Correct
    class TechniCoolEquipment(models.Model):
        _name = 'techniecool.equipment'
        serial_number = fields.Char()
    
    # XML
    
    
    # Fout: geen prefix = risico op conflict
    class Equipment(models.Model):
        _name = 'equipment'</pre>
    ✏ Mini-oefening

    Waarom is het gevaarlijk om een model te noemen zonder namespace-prefix?

    👁 Toon oplossing
    # Zonder prefix kan de modelnaam conflicteren # met een andere Odoo-module die hetzelfde model # definieert, wat tot dataverlies kan leiden.
    1.7 Git & Modulestructuur

    Elke Odoo-module leeft in een eigen git-repository of in een monorepo. OCA-conventies schrijven voor: één module per directory, een README.rst, tests in /tests. Voor TechniCool gebruiken we een monorepo met alle eigen modules en pinnen we de OCA-afhankelijkheden via pip of een requirements.txt.

    # .gitignore voor Odoo modules
    *.pyc
    __pycache__/
    .idea/
    *.log
    filestore/
    
    # requirements.txt
    odoo-addon-fieldservice==17.0.1.0.0
    odoo-addon-fieldservice-stock==17.0.1.0.0</pre>
    ✏ Mini-oefening

    Waar bewaar je de Python-tests voor een Odoo-module?

    👁 Toon oplossing
    # In de map tests/ # techniecool_base/tests/__init__.py # techniecool_base/tests/test_equipment.py
    1.8 Pre-commit & Linting

    Pre-commit hooks voeren automatisch code-kwaliteitschecks uit voor elke git commit. Voor Odoo-modules gebruik je pylint-odoo voor Odoo-specifieke regels en flake8 voor algemene Python-stijl. TechniCool's CI-pipeline blokkeert commits met linting-fouten zodat de codebase altijd op een basisniveau consistent blijft.

    # .pre-commit-config.yaml
    repos:
      - repo: https://github.com/PyCQA/flake8
        hooks:
          - id: flake8
      - repo: https://github.com/OCA/pylint-odoo
        hooks:
          - id: pylint_odoo
            args: ['--disable=all', '--enable=odoo']</pre>
    ✏ Mini-oefening

    Welke pylint-odoo check detecteert vergeten super()-aanroepen?

    👁 Toon oplossing
    # pylint-odoo rule: odoo-method-compute # en: missing-return in ORM methods
    # __manifest__.py voor TechniCool Service
                {
                'name': 'TechniCool Service Management',
                'version': '17.0.1.0.0',
                'category': 'Services/Field Service',
                'summary': 'Beheer van professionele keukentoestellen en interventies',
                'author': 'Olivier - TechniCool NV',
                'depends': [
                'base', 
                'fieldservice', 
                'stock', 
                'mail', 
                'fleet'
                ],
                'data': [
                'security/ir.model.access.csv',
                'views/equipment_views.xml',
                'views/intervention_views.xml',
                'data/sequence_data.xml',
                ],
                'installable': True,
                'application': True,
                'license': 'LGPL-3',
                }</pre>
                  
    ⚙ Praktijkopdracht

    Maak de volledige mappenstructuur aan voor de module techniecool_service. Schrijf een __manifest__.py die voldoet aan alle OCA eisen. Initialiseer een Git repository en maak je eerste commit met de basisstructuur. Zorg dat de module zichtbaar is in de Odoo apps lijst (vergeet de icon.png niet!).

    H2
    Data Models TechniCool Volledig
    // equipment, categories, checklists, parts
    Week 18 · 8u

    We bouwen de complete database architectuur voor TechniCool NV. Dit omvat niet alleen de toestellen, maar ook categorieën met specifieke technische eigenschappen en checklists per type onderhoud. Je leert hoe je complexe relaties legt zodat een technieker bij een Rational combi-steamer automatisch de juiste checklist te zien krijgt.

    2.1 Equipment Hoofdmodel

    Het centrale model van Vanventory is techniecool.equipment. Het bevat alle technische en commerciële data van een keukentoestel: identificatiegegevens, technische specificaties, eigenaar en locatie. Relaties naar interventies, contracten en onderdelen maken het het spil van het hele systeem. Dit model erft van mail.thread voor volledige audittrail.

    class TechniCoolEquipment(models.Model):
        _name = 'techniecool.equipment'
        _inherit = ['mail.thread', 'mail.activity.mixin']
        _description = 'Professioneel Keukentoestel'
        _order = 'install_date desc'
    
        name = fields.Char(required=True, tracking=True)
        serial_number = fields.Char(copy=False, index=True)
        brand = fields.Selection([('rational','Rational'),('electrolux','Electrolux')])
        model_name = fields.Char()
        install_date = fields.Date(default=fields.Date.today)
        owner_id = fields.Many2one('res.partner', ondelete='restrict')
        state = fields.Selection([('active','Actief'),('repair','In Herstelling')], default='active', tracking=True)</pre>
    ✏ Mini-oefening

    Voeg een location_id Many2one toe naar res.partner met domain op type 'delivery'.

    👁 Toon oplossing
    location_id = fields.Many2one( 'res.partner', domain="[('type','=','delivery')]", string='Locatie' )
    2.2 Intervention Model

    Elke herstelling of onderhoud wordt bijgehouden als een techniecool.intervention-record. Dit model is de kern van de FSM-integratie: het erft van fsm.order of staat er naast als eigen model. Velden omvatten probleemomschrijving, gebruikte onderdelen, tijdsregistratie en kosten. De koppeling met techniecool.equipment via Many2one sluit de lus.

    class TechniCoolIntervention(models.Model):
        _name = 'techniecool.intervention'
        _description = 'Interventie / Herstelling'
    
        name = fields.Char(default='Nieuwe Interventie')
        equipment_id = fields.Many2one('techniecool.equipment', required=True)
        technician_id = fields.Many2one('hr.employee')
        date_start = fields.Datetime()
        date_end = fields.Datetime()
        problem_description = fields.Text()
        solution_description = fields.Text()
        cost = fields.Float(digits=(16, 2))
        state = fields.Selection([('draft','Concept'),('done','Afgerond')], default='draft')</pre>
    ✏ Mini-oefening

    Voeg een computed field duration_hours toe dat het verschil berekent tussen date_end en date_start.

    👁 Toon oplossing
    @api.depends('date_start', 'date_end') def _compute_duration(self): for rec in self: if rec.date_start and rec.date_end: delta = rec.date_end - rec.date_start rec.duration_hours = delta.total_seconds() / 3600 else: rec.duration_hours = 0
    2.3 Contract Model

    Onderhoudscontracten binden klanten aan TechniCool voor een vaste periode. Het techniecool.contract-model bevat start- en einddatum, serviceniveau (basis of premium), en gekoppelde toestellen via Many2many. Een computed field toont of het contract actief, verlopen of bijna verlopen is. Geplande taken sturen automatisch verlengingsherinneringen.

    class TechniCoolContract(models.Model):
        _name = 'techniecool.contract'
        _description = 'Onderhoudscontract'
    
        name = fields.Char(required=True)
        partner_id = fields.Many2one('res.partner', required=True)
        equipment_ids = fields.Many2many('techniecool.equipment')
        date_start = fields.Date(required=True)
        date_end = fields.Date(required=True)
        service_level = fields.Selection([('basic','Basis'),('premium','Premium')])
        state = fields.Selection([('active','Actief'),('expired','Vervallen')], compute='_compute_state', store=True)</pre>
    ✏ Mini-oefening

    Schrijf de _compute_state-methode die 'expired' geeft als date_end in het verleden ligt.

    👁 Toon oplossing
    @api.depends('date_end') def _compute_state(self): today = fields.Date.today() for rec in self: rec.state = 'expired' if rec.date_end and rec.date_end < today else 'active'
    2.4 Part Usage Model

    Elke interventie verbruikt onderdelen die bijgehouden worden in techniecool.part.usage. Dit model koppelt een interventie aan een stock-product (onderdeel) met een hoeveelheid en prijs. De integratie met Odoo-stock zorgt voor automatische voorraadaftrek. Voor TechniCool is dit essentieel voor accurate kostprijsberekening van herstelorders.

    class TechniCoolPartUsage(models.Model):
        _name = 'techniecool.part.usage'
        _description = 'Gebruikte Onderdelen'
    
        intervention_id = fields.Many2one('techniecool.intervention', required=True)
        product_id = fields.Many2one('product.product', required=True)
        quantity = fields.Float(default=1.0)
        unit_price = fields.Float(related='product_id.standard_price')
        total_cost = fields.Float(compute='_compute_total', store=True)
    
        @api.depends('quantity', 'unit_price')
        def _compute_total(self):
            for rec in self:
                rec.total_cost = rec.quantity * rec.unit_price</pre>
    ✏ Mini-oefening

    Hoe voeg je de som van alle part_usage totaalkosten toe als computed field op de intervention?

    👁 Toon oplossing
    @api.depends('part_usage_ids.total_cost') def _compute_parts_cost(self): for rec in self: rec.parts_cost = sum(rec.part_usage_ids.mapped('total_cost'))
    2.5 Photo Model

    Foto's van toestellen (installatie, defecten, herstelresultaat) worden opgeslagen als ir.attachment of in een eigen techniecool.equipment.photo-model voor extra metadata. Het eigen model laat je velden toevoegen zoals foto-type, opnamedatum en een beschrijving. De Vanventory-app uploadt camera-captures rechtstreeks naar dit model.

    class TechniCoolEquipmentPhoto(models.Model):
        _name = 'techniecool.equipment.photo'
        _description = 'Toestel Foto'
    
        equipment_id = fields.Many2one('techniecool.equipment', required=True)
        image = fields.Binary(string='Foto', attachment=True)
        photo_type = fields.Selection([
            ('install', 'Installatie'),
            ('defect', 'Defect'),
            ('repair', 'Na Herstelling')
        ])
        date_taken = fields.Datetime(default=fields.Datetime.now)
        description = fields.Text()</pre>
    ✏ Mini-oefening

    Voeg een computed field thumbnail toe dat een verkleinde versie van het image retourneert.

    👁 Toon oplossing
    thumbnail = fields.Binary(compute='_compute_thumbnail') # Gebruik PIL of base64-resize in de compute methode
    2.6 Warranty Model

    Garantiebeheer is een apart subdomein in TechniCool. Het techniecool.warranty-model of de velden op equipment bewaren de garantieperiode, het type (fabrikant vs. TechniCool-garantie) en de status. Een aparte model laat je meerdere garanties per toestel bijhouden (bijv. verlengde garantie na een contract-verlenging).

    class TechniCoolWarranty(models.Model):
        _name = 'techniecool.warranty'
        _description = 'Garantie'
    
        equipment_id = fields.Many2one('techniecool.equipment')
        warranty_type = fields.Selection([
            ('manufacturer', 'Fabrikant'),
            ('techniecool', 'TechniCool Service')
        ])
        date_start = fields.Date()
        date_end = fields.Date()
        is_active = fields.Boolean(compute='_compute_active', store=True)</pre>
    ✏ Mini-oefening

    Schrijf de _compute_active-methode die True geeft als date_end in de toekomst ligt.

    👁 Toon oplossing
    @api.depends('date_end') def _compute_active(self): today = fields.Date.today() for rec in self: rec.is_active = bool(rec.date_end and rec.date_end >= today)
    2.7 Technician Model

    Technici zijn hr.employee-records uitgebreid met TechniCool-specifieke velden: certificeringen per merk (Rational, Electrolux), vaardigheidsniveaus en regiobinding. De Many2many naar techniecool.certification bewaakt welke toestellen een technieker zelfstandig mag herstellen. Dit stuurt de automatische toewijzing in het planningsalgoritme.

    class HrEmployeeTechniCool(models.Model):
        _inherit = 'hr.employee'
    
        certification_ids = fields.Many2many('techniecool.certification')
        region_id = fields.Many2one('res.country.state', string='Regio')
        is_available = fields.Boolean(default=True)
        skill_level = fields.Selection([
            ('junior', 'Junior'),
            ('senior', 'Senior'),
            ('expert', 'Expert')
        ])</pre>
    ✏ Mini-oefening

    Voeg een computed field open_intervention_count toe dat het aantal actieve werkorders telt.

    👁 Toon oplossing
    @api.depends('intervention_ids') def _compute_open_interventions(self): for rec in self: rec.open_intervention_count = len( rec.intervention_ids.filtered(lambda r: r.state != 'done') )
    2.8 Certification Model

    Certificeringen zijn trainingsattesten die bewijzen dat een technieker bevoegd is voor een bepaald type toestel. Het techniecool.certification-model bevat de naam, de uitgevende instantie (Rational Academy, Electrolux Professional) en een vervaldatum. Verlopen certificeringen triggeren een activiteit voor de HR-manager.

    class TechniCoolCertification(models.Model):
        _name = 'techniecool.certification'
        _description = 'Techniker Certificatie'
    
        name = fields.Char(required=True)
        brand = fields.Selection([('rational','Rational'),('electrolux','Electrolux')])
        issuer = fields.Char(string='Uitgevende Instantie')
        validity_years = fields.Integer(default=3)
    
    class TechniCoolTechnicianCert(models.Model):
        _name = 'techniecool.technician.cert'
        employee_id = fields.Many2one('hr.employee')
        certification_id = fields.Many2one('techniecool.certification')
        date_obtained = fields.Date()
        date_expiry = fields.Date(compute='_compute_expiry', store=True)</pre>
    ✏ Mini-oefening

    Schrijf de compute-methode voor date_expiry op basis van date_obtained + validity_years.

    👁 Toon oplossing
    @api.depends('date_obtained', 'certification_id.validity_years') def _compute_expiry(self): for rec in self: if rec.date_obtained: rec.date_expiry = rec.date_obtained.replace( year=rec.date_obtained.year + rec.certification_id.validity_years )
    from odoo import models, fields, api
    from odoo.exceptions import ValidationError
    
    class TechnicoolEquipment(models.Model):
        _name = 'techniecool.equipment'
        _inherit = ['mail.thread', 'mail.activity.mixin']
        _description = 'TechniCool Keukentoestel'
    
        name = fields.Char(string='Naam', required=True, tracking=True)
        category_id = fields.Many2one('techniecool.equipment.category', string='Categorie')
        serial_number = fields.Char(string='Serienummer', copy=False, tracking=True)
        install_date = fields.Date(string='Installatiedatum', default=fields.Date.today)
        
        # Technische Specs
        voltage = fields.Selection([
            ('230', '230V'),
            ('400', '400V (3-fase)')
        ], string='Spanning')
        power_kw = fields.Float(string='Vermogen (kW)')
        refrigerant_type = fields.Selection([
            ('r404a', 'R404A'),
            ('r134a', 'R134A'),
            ('r290', 'R290 (Propaan)')
        ], string='Koelmiddel')
        
        # Relaties
        partner_id = fields.Many2one('res.partner', string='Klant', tracking=True)
        location_id = fields.Many2one('fsm.location', string='Service Locatie')
        order_ids = fields.One2many('fsm.order', 'equipment_id', string='Interventies')
    
        _sql_constraints = [
            ('serial_unique', 'unique(serial_number)', 'Dit serienummer bestaat al in TechniCool!')
        ]
    
        @api.constrains('power_kw')
        def _check_power(self):
            for record in self:
                if record.power_kw < 0:
                    raise ValidationError("Vermogen kan niet negatief zijn.")
    
    class TechnicoolChecklistTemplate(models.Model):
        _name = 'techniecool.checklist.template'
        _description = 'Checklist Template'
    
        name = fields.Char(required=True)
        category_id = fields.Many2one('techniecool.equipment.category')
        line_ids = fields.One2many('techniecool.checklist.line', 'template_id', string='Checkpunten')</pre>
                
    ⚙ Praktijkopdracht

    Implementeer de modellen techniecool.equipment en techniecool.checklist.template. Voeg 20 records demo data toe via een XML bestand voor TechniCool NV, inclusief 3 verschillende types combi-steamers met hun specifieke checklists.

    H3
    Views — Form, List, Kanban, Search
    // xml views, notebooks, smart buttons
    Week 19 · 8u

    Data moet mooi gepresenteerd worden. We bouwen uitgebreide schermen voor TechniCool dispatchers en technici. Je leert hoe je notebook tabbladen gebruikt voor technische details, hoe smart buttons werken om direct naar gerelateerde interventies te springen en hoe je een Kanban view bouwt die de status van alle herstellingen visueel maakt.

    3.1 Equipment Form View

    De form view van het equipment-model is de centrale beheerpagina voor een keukentoestel. De header bevat de statusbar en actieknoppen. Tabbladen groeperen technische details, interventiehistoriek, foto's en contracten. Voor TechniCool bevat het hoofdtabblad de identificatiegegevens en technische specificaties.

    <form>
      <header>
        <button name='action_send_to_repair' type='object' string='Stuur naar Herstelling' states='active'/>
        <field name='state' widget='statusbar'/>
      </header>
      <sheet>
        <div class='oe_title'><field name='name'/></div>
        <group>
          <field name='serial_number'/>
          <field name='brand'/>
          <field name='install_date'/>
          <field name='owner_id'/>
        </group>
      </sheet>
      <div class='oe_chatter'>
        <field name='message_follower_ids'/>
        <field name='message_ids'/>
      </div>
    </form></pre>
    ✏ Mini-oefening

    Voeg een tabblad 'Foto's' toe aan de form view met het One2many-veld photo_ids.

    👁 Toon oplossing
    3.2 Equipment List View

    De list view toont een overzicht van alle toestellen. Decoraties kleuren rijen op basis van status. Optionele kolommen laten de dispatcher de weergave personaliseren. Voor TechniCool zijn naam, serienummer, merk, klant en garantiestatus de standaardkolommen.

    <list decoration-danger="state=='repair'" decoration-success="state=='active'">
      <field name='name'/>
      <field name='serial_number'/>
      <field name='brand'/>
      <field name='owner_id'/>
      <field name='install_date'/>
      <field name='warranty_expiry' optional='show'/>
      <field name='state' widget='badge'
             decoration-danger="state=='repair'"
             decoration-success="state=='active'"/>
    </list></pre>
    ✏ Mini-oefening

    Voeg een optionele kolom power_kw toe die standaard verborgen is.

    👁 Toon oplossing
    3.3 Kanban View

    De kanban view toont toestellen als kaarten gegroepeerd per staat. Elke kaart bevat het merk-logo, de naam, het serienummer en een kleurgecodeerde statusbadge. De dispatcher kan cards slepen tussen staten voor visueel workflow-beheer. Quick-info knoppen openen de form view of starten een actie.

    <kanban default_group_by='state' group_create='false'>
      <field name='name'/><field name='brand'/><field name='state'/>
      <templates>
        <t t-name='kanban-box'>
          <div class='oe_kanban_card'>
            <strong><t t-esc='record.name.value'/></strong>
            <div><t t-esc='record.brand.value'/></div>
            <div class='oe_kanban_bottom_right'>
              <button class='btn btn-sm btn-primary' name='action_create_workorder' type='object'>Werkorder</button>
            </div>
          </div>
        </t>
      </templates>
    </kanban></pre>
    ✏ Mini-oefening

    Voeg een oranje badge toe in de kanban-kaart die 'Dringend' toont als is_emergency True is.

    👁 Toon oplossing
    Dringend
    3.4 Search View

    Een goede search view versnelt het dagelijks werk van de dispatcher. Zoekvelden, snelfilters en groepering-opties zijn de drie pijlers. Voor TechniCool zoeken we op naam en serienummer, filteren we op garantiestatus en groeperen we per merk of klant.

    <search>
      <field name='name' string='Naam/Serienummer' filter_domain="[('name','ilike',self),('serial_number','ilike',self)]"/>
      <filter string='Actief' name='active' domain="[('state','=','active')]"/>
      <filter string='In Herstelling' name='repair' domain="[('state','=','repair')]"/>
      <filter string='Garantie Vervallen' name='expired' domain="[('warranty_status','=','expired')]"/>
      <group expand='0' string='Groepeer op'>
        <filter string='Merk' name='brand' context="{'group_by':'brand'}"/>
        <filter string='Klant' name='owner' context="{'group_by':'owner_id'}"/>
      </group>
    </search></pre>
    ✏ Mini-oefening

    Voeg een filter toe voor toestellen waarvan de installatiedatum dit jaar valt.

    👁 Toon oplossing
    3.5 Calendar View

    De calendar view toont interventies op een tijdlijn. Voor TechniCool ziet de planningsdispatcher welke technici op welke dag ingepland zijn. Het date_start en date_stop attribuut koppelen de tijdsvelden. Kleur per technicus maakt de planning in één oogopslag overzichtelijk.

    <calendar date_start='date_start' date_stop='date_end'
             color='technician_id' event_open_popup='True'>
      <field name='name'/>
      <field name='technician_id'/>
      <field name='equipment_id'/>
    </calendar></pre>
    ✏ Mini-oefening

    Welke twee velden zijn verplicht voor een calendar view?

    👁 Toon oplossing
    # date_start: verplicht - start tijdstip # date_stop: optioneel maar aanbevolen - eind tijdstip # Zonder date_stop zijn events puntvormig
    3.6 Activity View

    De activity view toont geplande activiteiten per record gegroepeerd per activiteitstype. Voor TechniCool ziet de dispatcher welke technici follow-up nodig hebben na een garantie-verloop of een openstaande offerte. Activiteiten kunnen gepland worden met een deadline en een verantwoordelijke.

    <activity string='Activiteiten'>
      <field name='name'/>
      <field name='owner_id'/>
      <field name='activity_ids'/>
    </activity>
    
    # Python: activiteit aanmaken
    equipment.activity_schedule(
        'mail.mail_activity_data_todo',
        note='Garantie verloopt over 30 dagen',
        user_id=account_manager.id
    )</pre>
    ✏ Mini-oefening

    Hoe plan je een activiteit op morgen voor de huidige gebruiker?

    👁 Toon oplossing
    from odoo.fields import Date from datetime import timedelta equipment.activity_schedule( 'mail.mail_activity_data_todo', date_deadline=Date.today() + timedelta(days=1) )
    3.7 Map View (OWL)

    De map view toont records op een geografische kaart. Odoo heeft een ingebouwde map view die een Google Maps of Leaflet kaart toont als het model lat/lng-velden heeft. Voor TechniCool toont de map view alle klantlocaties met hun toestellen, en de real-time positie van technici in het veld.

    <map res_partner='owner_id' default_order='name'>
      <field name='name'/>
      <field name='serial_number'/>
      <field name='owner_id'/>
    </map>
    
    # Vereist in res.partner:
    # partner_latitude en partner_longitude</pre>
    ✏ Mini-oefening

    Welk model bevat de lat/lng-velden die de map view gebruikt voor adressen?

    👁 Toon oplossing
    # res.partner - de velden zijn: # partner_latitude (Float) # partner_longitude (Float) # Geocoding kan via Odoo's ingebouwde geocoder
    3.8 Gantt View

    De Gantt view (enterprise functie of OCA module) toont een tijdlijnplanning. Voor TechniCool plant de dispatcher interventies op een Gantt-balk per technieker. Sleep-en-drop herplanning, kleur per prioriteit en conflictdetectie maken het de krachtigste planningsview voor TechniCool's dispatcher-team.

    # Vereist de enterprise Gantt view of OCA web_gantt
    <gantt date_start='date_start' date_stop='date_end'
           default_group_by='technician_id'
           color='priority'>
      <field name='name'/>
      <field name='technician_id'/>
      <field name='equipment_id'/>
    </gantt></pre>
    ✏ Mini-oefening

    Welk attribuut in de gantt view bepaalt de groepering per technieker?

    👁 Toon oplossing
    # default_group_by='technician_id'
    <!-- Smart Button voorbeeld voor TechniCool -->
                <div class="oe_button_box" name="button_box">
                <button name="action_view_interventions" type="object" class="oe_stat_button" icon="fa-wrench">
                <field name="intervention_count" widget="statinfo" string="Interventies"/>
                </button>
                </div>
    
                <!-- Kanban View met kleurcodering -->
                <kanban default_group_by="state">
                <field name="state"/>
                <templates>
                <t t-name="kanban-box">
                  <div t-attf-class="oe_kanban_global_click {{ record.priority.raw_value == '1' ? 'oe_kanban_color_red' : '' }}">
                      <strong><field name="name"/></strong>
                      <div><field name="equipment_id"/></div>
                  </div>
                </t>
                </templates>
                </kanban></pre>
                  
    ⚙ Praktijkopdracht

    Bouw alle views voor techniecool.intervention. Zorg dat de Kanban view de interventies groepeert per status. Voeg in de list view kleurcodering toe: blauw voor 'vaststelling', groen voor 'onderhoud' en rood voor 'herstel'. Voeg een smart button toe op de equipment form die direct filtert op alle interventies voor dat specifieke toestel.

    H4
    Business Logic — State Machine
    // states, buttons, email templates, chatter
    Week 20 · 8u

    Een interventie doorloopt een levenscyclus. Van 'Aanvraag' tot 'Factuur verzonden'. We implementeren een status-machine die bepaalt welke acties wanneer mogelijk zijn. Je leert hoe je buttons toont of verbergt op basis van de state, hoe je automatisch chatter berichten post en hoe Odoo automatisch e-mails verstuurt naar de klant van TechniCool NV wanneer een technieker onderweg is.

    4.1 State Machine Patroon

    Een state machine definieert de levenscyclus van een record als een reeks staten en overgangen. In Odoo implementeer je dit met een Selection-veld en methoden per transitie. Voor TechniCool doorloopt een toestel de staten: Concept → Actief → In Herstelling → Afgevoerd. Elke overgang heeft bewakers (guards) die controleren of de transitie toegestaan is.

    state = fields.Selection([
        ('draft', 'Concept'),
        ('active', 'Actief'),
        ('repair', 'In Herstelling'),
        ('retired', 'Afgevoerd')
    ], default='draft', tracking=True)
    
    def action_activate(self):
        self.write({'state': 'active'})
    
    def action_send_to_repair(self):
        for rec in self:
            if not rec.serial_number:
                raise UserError('Serienummer verplicht voor herstelling.')
        self.write({'state': 'repair'})</pre>
    ✏ Mini-oefening

    Schrijf een transitiemethode action_retire die enkel werkt als de staat 'active' of 'repair' is.

    👁 Toon oplossing
    def action_retire(self): for rec in self: if rec.state not in ('active', 'repair'): raise UserError('Kan enkel actieve of herstel-toestellen afvoeren.') self.write({'state': 'retired'})
    4.2 Guard Conditions

    Guards zijn validaties die een transitie blokkeren als aan bepaalde voorwaarden niet voldaan is. Dit voorkomt dat data in een inconsistente staat terechtkmt. Voor TechniCool mag een toestel pas afgevoerd worden als alle openstaande interventies afgesloten zijn en er geen actief onderhoudscontract meer loopt.

    def action_retire(self):
        for rec in self:
            open_interventions = rec.intervention_ids.filtered(
                lambda i: i.state != 'done'
            )
            if open_interventions:
                raise UserError(
                    f'{len(open_interventions)} interventie(s) nog niet afgesloten.'
                )
            if rec.contract_ids.filtered(lambda c: c.state == 'active'):
                raise UserError('Beëindig eerst het actieve onderhoudscontract.')
        self.write({'state': 'retired'})</pre>
    ✏ Mini-oefening

    Schrijf een guard die verhindert dat een toestel hersteld wordt zolang de technieker niet gecertificeerd is.

    👁 Toon oplossing
    def action_send_to_repair(self): for rec in self: if rec.assigned_technician_id: certs = rec.assigned_technician_id.certification_ids.mapped('brand') if rec.brand not in certs: raise UserError('Technieker niet gecertificeerd voor dit merk.')
    4.3 Automatic State Transitions

    Sommige staatstransities gebeuren automatisch via geplande taken of triggers. Voor TechniCool: als de garantie van een toestel verloopt, schakelt de staat automatisch naar 'warranty_expired' via een dagelijkse cron. Dit is veiliger dan manuele updates omdat het altijd consistent is, ook na een import of API-call.

    def _cron_update_states(self):
        today = fields.Date.today()
        # Warranty expiry check
        expiring = self.search([
            ('warranty_expiry', '<', today),
            ('warranty_status', '!=', 'expired')
        ])
        expiring.write({'warranty_status': 'expired'})
        # Notify account managers
        for eq in expiring:
            eq.activity_schedule(
                'mail.mail_activity_data_todo',
                note='Garantie vervallen - contacteer klant'
            )</pre>
    ✏ Mini-oefening

    Schrijf een cron die alle toestellen in 'repair' staat langer dan 30 dagen een herinnering stuurt.

    👁 Toon oplossing
    def _cron_repair_reminder(self): cutoff = fields.Date.today() - timedelta(days=30) long_repair = self.search([ ('state', '=', 'repair'), ('repair_start_date', '<', cutoff) ]) long_repair.message_post(body='Herstelling loopt al meer dan 30 dagen.')
    4.4 Chatter Notificaties bij Transities

    Bij elke staatstransitie stuur je een geautomatiseerd bericht naar de relevante partijen. De eigenaar ontvangt een e-mail als zijn toestel in herstelling gaat. De dispatcher ziet de statuswijziging in de chatter met datum en verantwoordelijke. Voor TechniCool configureert het e-mailsjabloon automatisch de technicus en locatiegegevens.

    def action_send_to_repair(self):
        self.write({'state': 'repair'})
        for rec in self:
            rec.message_post(
                body=f'Toestel {rec.name} is in herstelling geplaatst.',
                message_type='comment',
                subtype_xmlid='mail.mt_note'
            )
            # Stuur e-mail naar eigenaar
            template = self.env.ref('techniecool_base.mail_repair_notification')
            template.send_mail(rec.id, force_send=True)</pre>
    ✏ Mini-oefening

    Hoe stuur je een e-mail via een QWeb-mailtemplate naar de eigenaar van een toestel?

    👁 Toon oplossing
    template = self.env.ref('techniecool_base.mail_equipment_notification') template.send_mail(equipment_id, force_send=True)
    4.5 Workflow Knoppen in Views

    Actieknoppen in de form view sturen de workflow aan. Het attribuut states bepaalt in welke staten een knop zichtbaar is. Voor TechniCool is 'Stuur naar Herstelling' enkel zichtbaar in staat 'active', en 'Activeer' enkel in staat 'draft'.

    <header>
      <button name='action_activate' type='object'
              string='Activeer' states='draft' class='btn-primary'/>
      <button name='action_send_to_repair' type='object'
              string='Herstelling' states='active' class='btn-warning'/>
      <button name='action_retire' type='object'
              string='Afvoeren' states='active,repair' class='btn-danger'/>
      <field name='state' widget='statusbar'
             statusbar_visible='draft,active,repair,retired'/>
    </header></pre>
    ✏ Mini-oefening

    Hoe verberg je een knop voor niet-managers via het groups-attribuut?

    👁 Toon oplossing
    4.6 Audit Log via mail.thread

    mail.thread met tracking=True op velden logt automatisch elke wijziging in de chatter met de oude en nieuwe waarde. Voor compliance bij TechniCool zijn de staatswijzigingen, kostprijsaanpassingen en technicienwisselingen volledig traceerbaar. Dit is essentieel bij garantiegeschillen met klanten.

    class TechniCoolEquipment(models.Model):
        _inherit = ['mail.thread', 'mail.activity.mixin']
    
        state = fields.Selection(..., tracking=True)
        cost_estimate = fields.Float(tracking=True)
        assigned_technician_id = fields.Many2one(
            'hr.employee', tracking=True
        )</pre>
    ✏ Mini-oefening

    Welk attribuut stel je in op een veld om wijzigingen automatisch te loggen in de chatter?

    👁 Toon oplossing
    # tracking=True op het veld # Voorbeeld: name = fields.Char(tracking=True)
    4.7 Activiteiten & Deadlines

    Odoo-activiteiten zijn taken met een deadline en een type (bellen, e-mail, to-do). Ze worden weergegeven in de activity view en in het chatter-gedeelte. Voor TechniCool plant de state machine automatisch een activiteit bij elke overgang die actie vereist: 'Garantie verlopen' plant een telefonische follow-up bij de accountmanager.

    def action_send_to_repair(self):
        self.write({'state': 'repair'})
        for rec in self:
            # Plan follow-up activiteit over 5 dagen
            rec.activity_schedule(
                'mail.mail_activity_data_call',
                date_deadline=fields.Date.today() + timedelta(days=5),
                note='Controleer status herstelling met klant',
                user_id=rec.owner_id.user_id.id
            )</pre>
    ✏ Mini-oefening

    Schrijf code die alle activiteiten van type 'to-do' op een equipment-record markeert als gedaan.

    👁 Toon oplossing
    equipment.activity_ids.filtered( lambda a: a.activity_type_id.name == 'To-Do' ).action_done()
    4.8 Batch State Updates

    Via de list view kan de dispatcher meerdere toestellen tegelijk van staat veranderen. Dit werkt via een server action die op de geselecteerde records opereert. Voor TechniCool is 'Bulk Activeer' een server action die alle geselecteerde draft-toestellen in één klik activeert. De guard-validaties worden per record uitgevoerd.

    def action_batch_activate(self):
        draft_records = self.filtered(lambda r: r.state == 'draft')
        if not draft_records:
            raise UserError('Geen concept-toestellen geselecteerd.')
        draft_records.action_activate()
        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'message': f'{len(draft_records)} toestellen geactiveerd.',
                'type': 'success'
            }
        }</pre>
    ✏ Mini-oefening

    Hoe retourneer je een groene notificatie-popup vanuit een Python-methode?

    👁 Toon oplossing
    return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': {'message': 'Actie geslaagd!', 'type': 'success'} }
    def action_assign(self):
                for record in self:
                if not record.technician_id:
                  raise UserError("Wijs eerst een technieker toe!")
                record.state = 'assigned'
    
                # Automatisch chatter bericht
                record.message_post(
                  body=f"Interventie toegewezen aan {record.technician_id.name}",
                  subtype_xmlid="mail.mt_note"
                )
    
                # E-mail naar klant
                template = self.env.ref('techniecool_service.email_template_assigned')
                template.send_mail(record.id, force_send=True)</pre>
                  
    ⚙ Praktijkopdracht

    Implementeer de volledige statusmachine voor techniecool.intervention: draft → confirmed → assigned → in_progress → done. Zorg dat de knop "Starten" enkel zichtbaar is in de status 'assigned'. Voeg een e-mail template toe die de klant verwittigt wanneer de status naar 'in_progress' gaat.

    H5
    Stock Integratie
    // stock.picking, quants, van inventory
    Week 21 · 8u

    Technici hebben onderdelen nodig. In dit hoofdstuk koppelen we TechniCool aan Odoo Inventory. We maken automatisch stock-locaties aan voor de bestelwagens, beheren de voorraad per wagen en zorgen dat bij het afsluiten van een interventie de verbruikte onderdelen automatisch afgeboekt worden van de juiste wagen-stock. Dit is de back-end motor van Vanventory.

    5.1 Stock.Picking & Voorraad

    Odoo's voorraadbeheer draait rond stock.picking (magazijnopdracht) en stock.move (productstroom). Voor TechniCool boekt de app automatisch een component-aftrek als een technieker een onderdeel verbruikt bij een interventie. De technieker-wagen is een interne locatie in Odoo Stock waarvan de voorraad bijgehouden wordt.

    def _create_stock_picking(self, intervention):
        picking_type = self.env.ref('stock.picking_type_internal')
        picking = self.env['stock.picking'].create({
            'picking_type_id': picking_type.id,
            'location_id': intervention.technician_id.van_location_id.id,
            'location_dest_id': self.env.ref('stock.stock_location_customers').id,
        })
        for part in intervention.part_usage_ids:
            self.env['stock.move'].create({
                'picking_id': picking.id,
                'product_id': part.product_id.id,
                'product_uom_qty': part.quantity,
                'name': part.product_id.name,
            })
        return picking</pre>
    ✏ Mini-oefening

    Welk model vertegenwoordigt een afzonderlijke productbeweging binnen een picking?

    👁 Toon oplossing
    # stock.move - één lijn per product in een picking # stock.move.line - de gedetailleerde boekingslijn
    5.2 Product Configuratie voor Onderdelen

    Onderdelen voor keukentoestellen worden geconfigureerd als producten in Odoo. Het producttype 'Stockable Product' laat voorraadtracking toe. Voor TechniCool beheren we reserveonderdelen per merk en type: filters voor Rational iCombi, verwarmingselementen voor Electrolux blast chillers. Het interne referentienummer koppelt aan het serienummer van de fabrikant.

    product = self.env['product.product'].create({
        'name': 'Deurrubber Rational iCombi 6-1/1',
        'type': 'product',  # stockable
        'categ_id': self.env.ref('techniecool_base.categ_rational_parts').id,
        'default_code': 'RAT-6-1/1-DOOR-RUB',
        'standard_price': 45.50,
        'uom_id': self.env.ref('uom.product_uom_unit').id,
    })</pre>
    ✏ Mini-oefening

    Wat is het verschil tussen product.template en product.product in Odoo?

    👁 Toon oplossing
    # product.template: het abstracte product (varianten-parent) # product.product: de concrete variant (met attributen) # Bij één variant zijn ze praktisch equivalent
    5.3 Technieker-wagen als Interne Locatie

    Elke technieker heeft een wagen die als interne voorraadlocatie geregistreerd is. De stock.location wordt aangemaakt als kind van de hoofdlocatie 'Techniekers'. Bij het begin van een week vult de technieker zijn wagen aan via een interne transfer. Bij het afsluiten van een interventie worden de gebruikte onderdelen automatisch afgeboekt.

    def create_technician_location(self, employee):
        parent = self.env.ref('techniecool_base.location_technicians')
        return self.env['stock.location'].create({
            'name': f'Wagen {employee.name}',
            'location_id': parent.id,
            'usage': 'internal',
            'complete_name': f'Techniekers/{employee.name}',
        })</pre>
    ✏ Mini-oefening

    Wat is de usage-waarde voor een klantlocatie in Odoo Stock?

    👁 Toon oplossing
    # usage='customer' voor klantlocaties # usage='internal' voor magazijnlocaties # usage='supplier' voor leverancierslocaties
    5.4 Lot & Serienummer Tracking

    Odoo kan voorraden bijhouden per lot (batch) of per serienummer. Voor TechniCool volgen we elk vervangen onderdeel via serienummer zodat we bij een kwaliteitsprobleem snel alle toestellen kunnen traceren waarbij een defect onderdeel van een bepaalde productiebatch geplaatst werd.

    # Tracking instellen op product
    product.write({'tracking': 'serial'})  # per stuk
    # of: 'lot' voor batch-tracking
    
    # Bij stock.move.line: serienummer opgeven
    move_line = self.env['stock.move.line'].create({
        'move_id': move.id,
        'lot_id': self.env['stock.lot'].search([
            ('product_id', '=', product.id),
            ('name', '=', serial_number)
        ], limit=1).id,
        'qty_done': 1,
    })</pre>
    ✏ Mini-oefening

    Hoe maak je een nieuw stock.lot aan voor een product met een specifiek serienummer?

    👁 Toon oplossing
    lot = self.env['stock.lot'].create({ 'product_id': product.id, 'name': 'SN-RAT-2024-001', 'company_id': self.env.company.id })
    5.5 Reordering Rules

    Reordering rules triggeren automatisch een inkooporder of productieorder als de voorraad onder een minimum daalt. Voor TechniCool stellen we per onderdeel minimumvoorraden in zodat de meest gebruikte filters, dichtingen en sensoren altijd op voorraad zijn. De scheduler (procure.order) draait dagelijks en genereert aanvulorders.

    self.env['stock.warehouse.orderpoint'].create({
        'product_id': product.id,
        'warehouse_id': self.env.ref('stock.warehouse0').id,
        'product_min_qty': 5.0,
        'product_max_qty': 20.0,
        'qty_multiple': 5.0,
    })</pre>
    ✏ Mini-oefening

    Hoe trigger je de reordering scheduler manueel via Python?

    👁 Toon oplossing
    self.env['procurement.group'].run_scheduler()
    5.6 Valuation & FIFO

    Voorraadwaardering bepaalt de kostprijs bij voorraadafname. FIFO (First In, First Out) waardeert onderdelen op de aankoopprijs van de oudste batch. Voor TechniCool's financiële rapportering gebruiken we standaardprijswaardering voor eenvoud, maar FIFO bij onderdelen met sterk wisselende prijzen. De keuze bepaalt de kostprijs op de interventie-factuur.

    # Instellen via product.category
    categ = self.env['product.category'].create({
        'name': 'Rational Onderdelen',
        'property_cost_method': 'fifo',  # of 'standard', 'average'
        'property_valuation': 'real_time',
    })</pre>
    ✏ Mini-oefening

    Wat is het verschil tussen 'standard', 'average' en 'fifo' kostprijsmethoden?

    👁 Toon oplossing
    # standard: vaste standaardprijs (eenvoudig) # average: gewogen gemiddelde aankoopprijs # fifo: oudste batch eerst (meest nauwkeurig)
    5.7 Stock Picking Integration in Interventies

    Bij het afsluiten van een interventie maakt de app automatisch een stock picking aan die de gebruikte onderdelen van de techniekerswagen naar de klantlocatie boekt. De picking wordt gevalideerd (done) zodat de voorraad correct bijgewerkt wordt. De kostprijs van de beweging berekent automatisch de materiaalkosten van de interventie.

    def action_complete_intervention(self):
        self.write({'state': 'done', 'date_end': fields.Datetime.now()})
        for rec in self:
            if rec.part_usage_ids:
                picking = rec._create_stock_picking()
                picking.action_confirm()
                picking.action_assign()
                for ml in picking.move_line_ids:
                    ml.qty_done = ml.reserved_qty
                picking.button_validate()</pre>
    ✏ Mini-oefening

    Wat doet picking.action_assign() in de stock workflow?

    👁 Toon oplossing
    # action_assign() reserveert de beschikbare # voorraad voor de picking (status: 'Ready') # Zonder dit kunnen de move lines niet gedaan worden
    5.8 Inventory Reports

    Odoo biedt ingebouwde voorraadrapportering via de pivot view op stock.quant. Voor TechniCool genereren we maandelijkse rapporten van de techniekerswageninhoud en de verbruikte onderdelen per merk. Dit helpt de inkoopafdeling om betere aankoopbeslissingen te nemen op basis van werkelijke verbruikscijfers.

    # Lees voorraad per locatie
    quants = self.env['stock.quant'].search([
        ('location_id.usage', '=', 'internal'),
        ('location_id.complete_name', 'ilike', 'Techniekers')
    ])
    
    # Groepeer per product
    result = self.env['stock.quant'].read_group(
        [('location_id', 'child_of', technicians_location.id)],
        ['product_id', 'quantity:sum'],
        ['product_id']
    )</pre>
    ✏ Mini-oefening

    Schrijf een read_group die de totale voorraadwaarde per techniekerswagen toont.

    👁 Toon oplossing
    result = self.env['stock.quant'].read_group( [('location_id', 'child_of', location.id)], ['location_id', 'value:sum'], ['location_id'] )
    # Onderdelen verbruiken van voertuig stock
                def action_consume_parts(self):
                picking_type = self.env.ref('stock.picking_type_internal')
                source_loc = self.technician_id.vehicle_id.location_id
                dest_loc = self.location_id.stock_location_id # Klant locatie
    
                picking = self.env['stock.picking'].create({
                'picking_type_id': picking_type.id,
                'location_id': source_loc.id,
                'location_dest_id': dest_loc.id,
                'origin': self.name
                })
    
                for line in self.part_ids:
                self.env['stock.move'].create({
                  'name': line.product_id.name,
                  'product_id': line.product_id.id,
                  'product_uom_qty': line.quantity,
                  'product_uom': line.product_id.uom_id.id,
                  'picking_id': picking.id,
                  'location_id': source_loc.id,
                  'location_dest_id': dest_loc.id,
                })
                picking.action_confirm()</pre>
                  
    ⚙ Praktijkopdracht

    Implementeer de "Onderdelen verbruiken" knop. Selecteer 3 producten in een interventie, klik op de knop en verifieer dat er een stock.picking is aangemaakt. Controleer in de Inventory app of de voorraad van de bestelwagen effectief is gedaald met de juiste aantallen.

    H6
    QWeb Reports & PDF Werkbonnen
    // templates, pdf generation, barcodes, branding
    Week 22 · 8u

    Een interventie is pas afgerond als de klant een werkbon krijgt. We bouwen een professioneel TechniCool PDF rapport met QWeb. Je leert hoe je data van verschillende modellen samenbrengt op één pagina, hoe je barcodes genereert voor snelle scanning en hoe je de layout volledig aanpast aan de huisstijl van TechniCool (logo, fonts, kleuren).

    6.1 QWeb Report Basis

    Odoo gebruikt QWeb-templates om PDF-rapporten te genereren via wkhtmltopdf. Een rapport wordt geregistreerd als een ir.actions.report-record. Voor TechniCool genereren we een PDF werkbon met de toestelgegevens, probleemomschrijving, gebruikte onderdelen en handtekeningveld voor de klant.

    <report id='report_intervention_werkbon'
           model='techniecool.intervention'
           string='Werkbon PDF'
           report_type='qweb-pdf'
           name='techniecool_base.report_intervention_werkbon'
           file='techniecool_base.report_intervention_werkbon'
           attachment_use='True'
           attachment='object.name + ".pdf"'
    /></pre>
    ✏ Mini-oefening

    Wat doet het attribuut attachment_use='True' in een rapport-definitie?

    👁 Toon oplossing
    # Het rapport wordt gecached als ir.attachment # Volgende aanvragen gebruiken de cache # Gebruik 'False' voor dynamisch gegenereerde rapporten
    6.2 QWeb Template Structuur

    Een QWeb-rapport-template bestaat uit een outer wrapper en een inner template. De outer definieert de paginaindeling, de inner de inhoud. Gebruik t-foreach voor meerdere records in één PDF. Voor TechniCool bevat de werkbon het TechniCool-logo, klantgegevens en een tabel met onderdelen.

    <template id='report_intervention_werkbon'>
      <t t-call='web.html_container'>
        <t t-foreach='docs' t-as='o'>
          <t t-call='web.external_layout'>
            <div class='page'>
              <h2>Werkbon #<t t-esc='o.name'/></h2>
              <p>Klant: <t t-esc='o.equipment_id.owner_id.name'/></p>
              <p>Toestel: <t t-esc='o.equipment_id.name'/></p>
              <p>Technicus: <t t-esc='o.technician_id.name'/></p>
            </div>
          </t>
        </t>
      </t>
    </template></pre>
    ✏ Mini-oefening

    Wat is het verschil tussen web.external_layout en web.internal_layout?

    👁 Toon oplossing
    # external_layout: met bedrijfslogo en adres (voor klanten) # internal_layout: zonder koptekst (voor intern gebruik)
    6.3 Tabel met Onderdelen in QWeb

    Tabellen in QWeb-rapporten gebruik je Bootstrap-klassen voor opmaak. Een t-foreach genereert een rij per onderdeel. Totalen berekenen via sum() in een QWeb-expressie. Voor TechniCool toont de werkbon een tabel met onderdeel, hoeveelheid, eenheidsprijs en totaal.

    <table class='table table-sm'>
      <thead>
        <tr>
          <th>Onderdeel</th><th>Qty</th><th>Prijs</th><th>Totaal</th>
        </tr>
      </thead>
      <tbody>
        <t t-foreach='o.part_usage_ids' t-as='part'>
          <tr>
            <td><t t-esc='part.product_id.name'/></td>
            <td><t t-esc='part.quantity'/></td>
            <td><t t-esc-options='{"widget": "monetary"}' t-esc='part.unit_price'/></td>
            <td><t t-esc='part.total_cost'/></td>
          </tr>
        </t>
      </tbody>
    </table></pre>
    ✏ Mini-oefening

    Hoe toon je een geldbedrag in een QWeb-template met het juiste valutateken?

    👁 Toon oplossing
    6.4 CSS Styling in Rapporten

    QWeb-rapporten gebruiken Bootstrap-klassen en aangepaste CSS. Je linkt CSS-bestanden via de template-definitie. Voor TechniCool-werkbonnen gebruiken we het oranje merkkleur voor koppen en een professionele tabelopmaak. Vergeet dat wkhtmltopdf beperktere CSS-ondersteuning heeft dan browsers.

    <template id='report_techniecool_assets' inherit_id='web.report_assets_common'>
      <xpath expr='.' position='inside'>
        <link rel='stylesheet' href='/techniecool_base/static/src/css/reports.css'/>
      </xpath>
    </template>
    
    /* reports.css */
    .tc-header { background-color: #ff6600; color: white; padding: 20px; }
    h2 { color: #ff6600; font-family: 'Syne', sans-serif; }</pre>
    ✏ Mini-oefening

    Waarom werkt CSS position:fixed niet correct in wkhtmltopdf-rapporten?

    👁 Toon oplossing
    # wkhtmltopdf rendert elke pagina onafhankelijk # Fixed positioning gedraagt zich als absolute # Gebruik @page CSS rules voor kopteksten per pagina
    6.5 Pagina-indeling & Paginanummers

    QWeb-pagina's kunnen voetteksten met paginanummers bevatten. Odoo's web.external_layout bevat al een standaardvoettekst. Voor aangepaste voetteksten gebruik je @page CSS-regels. De variabelen page en topage bevatten het huidige en totale paginanummer.

    /* CSS voor voettekst */
    @page {
        @bottom-center {
            content: 'Pagina ' counter(page) ' van ' counter(pages);
            font-size: 10px;
            color: #666;
        }
    }
    
    /* Of via HTML footer */
    <div class='footer'>
      Pagina <span class='page'/> van <span class='topage'/>
    </div></pre>
    ✏ Mini-oefening

    Hoe voeg je het bedrijfslogo toe in een QWeb-rapport-koptekst?

    👁 Toon oplossing
    6.6 Rapport Knop in Form View

    Rapporten worden toegankelijk gemaakt via een knop in de form view of via het Print-menu in de action bar. Voor TechniCool heeft de interventie-form view een 'Print Werkbon'-knop die direct het PDF opent in een nieuw tabblad. De knop retourneert een rapport-action via Python.

    <button name='%(techniecool_base.report_intervention_werkbon)d'
            type='action' string='Print Werkbon'
            class='btn-secondary' icon='fa-print'/>
    
    # Of via Python:
    def action_print_werkbon(self):
        return self.env.ref(
            'techniecool_base.report_intervention_werkbon'
        ).report_action(self)</pre>
    ✏ Mini-oefening

    Hoe genereer je een PDF en sla je het op als bijlage via Python?

    👁 Toon oplossing
    pdf_content, _ = self.env.ref( 'techniecool_base.report_intervention_werkbon' )._render_qweb_pdf(self.ids) self.env['ir.attachment'].create({ 'name': f'{self.name}.pdf', 'datas': base64.b64encode(pdf_content), 'res_model': self._name, 'res_id': self.id, })
    6.7 Rapport via E-mail Versturen

    QWeb-rapporten kunnen automatisch als bijlage verstuurd worden via e-mail. Een QWeb-mailtemplate combineert een HTML-body met een PDF-bijlage. Voor TechniCool stuurt het systeem na het afsluiten van een interventie automatisch de werkbon als PDF naar de klant, samen met een factuuroverzicht.

    def action_send_werkbon_email(self):
        template = self.env.ref('techniecool_base.mail_werkbon_template')
        for rec in self:
            template.send_mail(
                rec.id,
                force_send=True,
                email_values={
                    'email_to': rec.equipment_id.owner_id.email
                }
            )
    
    # Mail template XML:
    # <field name='report_template' ref='report_intervention_werkbon'/></pre>
    ✏ Mini-oefening

    Hoe voeg je een CC-adres toe bij het versturen van een e-mail via send_mail?

    👁 Toon oplossing
    template.send_mail( rec.id, force_send=True, email_values={'email_cc': 'manager@techniecool.be'} )
    6.8 Rapport Translatie

    QWeb-rapporten kunnen vertaald worden voor meertalige klanten. Gebruik de t-lang directive op het template-element om de taal van het rapport te bepalen op basis van de klantvoorkeur. Voor TechniCool genereert het systeem werkbonnen in de taal van de klant: Nederlands voor Vlaamse klanten, Frans voor Waalse klanten.

    <t t-call='web.html_container'>
      <t t-foreach='docs' t-as='o'>
        <t t-call='web.external_layout'
           t-lang='o.equipment_id.owner_id.lang'>
          <!-- inhoud in klant-taal -->
        </t>
      </t>
    </t></pre>
    ✏ Mini-oefening

    Hoe stel je de taalvoorkeur in op een res.partner-record via Python?

    👁 Toon oplossing
    partner.write({'lang': 'fr_BE'}) # Frans # of 'nl_BE' voor Nederlands
    <!-- TechniCool Werkbon Template fragment -->
                <template id="report_intervention_document">
                <t t-call="web.external_layout">
                <div class="page">
                  <h2>Werkbon: <span t-field="o.name"/></h2>
                  <div class="row">
                      <div class="col-6">
                          <strong>Klant:</strong> <p t-field="o.partner_id.name"/>
                          <strong>Toestel:</strong> <p t-field="o.equipment_id.name"/>
                      </div>
                  </div>
                  <!-- Checklist resultaten -->
                  <table class="table table-sm">
                      <thead><tr><th>Checkpunt</th><th>Resultaat</th></tr></thead>
                      <tbody>
                          <tr t-foreach="o.checklist_ids" t-as="line">
                              <td><span t-field="line.name"/></td>
                              <td><span t-field="line.value"/></td>
                          </tr>
                      </tbody>
                  </table>
                </div>
                </t>
                </template></pre>
                  
    ⚙ Praktijkopdracht

    Bouw de volledige TechniCool werkbon PDF. Het moet bevatten: logo, adresblok klant, toestelgegevens (inclusief serienummer en koelmiddel), checklistresultaten, verbruikte onderdelen met prijzen, BTW berekening en een handtekeningvak. Genereer de PDF voor een test-interventie.

    H7
    Controllers & REST API
    // http.Controller, routes, json, auth
    Week 23 · 8u

    Odoo moet praten met de buitenwereld. We bouwen een REST API voor TechniCool zodat externe apps (zoals de Vanventory PWA) data kunnen ophalen en wegschrijven. Je leert hoe je Python controllers schrijft, routes definieert met authenticatie en hoe je JSON responses genereert die door moderne frontend frameworks begrepen worden.

    7.1 HTTP Controller Basis

    Odoo-controllers verwerken HTTP-requests via de @http.route decorator. Je definieert het URL-pad, de toegestane methoden en het authenticatietype. Voor TechniCool bouwen we een REST API zodat de Vanventory-PWA toesteldata kan opvragen zonder de volledige Odoo web client te laden. Controllers leven in een controllers/ map en worden geïmporteerd via __init__.py.

    from odoo import http
    from odoo.http import request
    
    class TechniCoolController(http.Controller):
    
        @http.route('/api/v1/equipment', type='json',
                    auth='user', methods=['GET'])
        def get_equipment(self, **kwargs):
            equipment = request.env['techniecool.equipment'].search_read(
                [], ['name', 'serial_number', 'state']
            )
            return {'status': 'ok', 'data': equipment}</pre>
    ✏ Mini-oefening

    Maak een route /api/v1/equipment/<int:id> die één toestel retourneert.

    👁 Toon oplossing
    @http.route('/api/v1/equipment/', type='json', auth='user') def get_one(self, id, **kw): eq = request.env['techniecool.equipment'].browse(id) return eq.read(['name', 'serial_number'])[0]
    7.2 JSON vs HTTP Type

    Routes met type='json' verwachten en retourneren JSON automatisch. Routes met type='http' geven volledige HTTP-responses terug inclusief headers. Voor een REST API gebruik je altijd type='json'. Voor bestandsdownloads of HTML-pagina's gebruik je type='http'.

    # type='json': automatisch JSON in/out
    @http.route('/api/data', type='json', auth='user')
    def json_api(self, **kw):
        return {'key': 'value'}  # automatisch geserialiseerd
    
    # type='http': volledige controle over response
    @http.route('/api/download', type='http', auth='user')
    def download(self, **kw):
        return request.make_response(
            b'PDF content',
            headers=[('Content-Type', 'application/pdf')]
        )</pre>
    ✏ Mini-oefening

    Hoe retourneer je een 404-fout vanuit een Odoo controller?

    👁 Toon oplossing
    from odoo.exceptions import NotFound raise werkzeug.exceptions.NotFound()
    7.3 Authenticatie: user vs public vs none

    Het auth-attribuut bepaalt wie een route mag aanroepen. auth='user' vereist een ingelogde Odoo-gebruiker. auth='public' laat ook anonieme bezoekers toe (portaal). auth='none' slaat alle authenticatie over — enkel voor speciale gevallen. Voor TechniCool's interne API gebruik je auth='user'; voor de klantportaal auth='public'.

    # Interne API: enkel voor ingelogde gebruikers
    @http.route('/api/v1/equipment', type='json', auth='user')
    def internal_api(self): ...
    
    # Klantportaal: ook voor portaalgebruikers
    @http.route('/my/equipment', type='http', auth='public', website=True)
    def portal_page(self): ...
    
    # Webhook ontvanger: geen auth (API key in payload)
    @http.route('/webhook/equipment', type='json', auth='none', csrf=False)
    def webhook(self): ...</pre>
    ✏ Mini-oefening

    Wanneer gebruik je csrf=False op een route?

    👁 Toon oplossing
    # Bij externe webhooks en API-calls die geen # Odoo-browser-sessie hebben en dus geen # CSRF-token kunnen meesturen. # Nooit voor interne routes gebruiken!
    7.4 Request Parsing

    Vanuit de controller lees je request-data via request.jsonrequest (voor JSON-routes) of request.params (voor HTTP-routes). Voor TechniCool's API stuurt de Vanventory-PWA een POST-request met serienummer om een toestel op te zoeken. Valideer altijd de inkomende data voor je de ORM aanroept.

    @http.route('/api/v1/equipment/search', type='json',
                auth='user', methods=['POST'])
    def search_equipment(self, **kw):
        data = request.jsonrequest
        serial = data.get('serial_number', '').strip()
        if not serial:
            return {'status': 'error', 'message': 'Serienummer verplicht'}
        eq = request.env['techniecool.equipment'].search(
            [('serial_number', '=', serial)], limit=1
        )
        if not eq:
            return {'status': 'not_found'}
        return {'status': 'ok', 'id': eq.id, 'name': eq.name}</pre>
    ✏ Mini-oefening

    Hoe lees je een query-string parameter brand uit een HTTP GET-request?

    👁 Toon oplossing
    brand = request.params.get('brand', 'all')
    7.5 CORS & Externe Toegang

    Cross-Origin Resource Sharing (CORS) laat externe websites of PWA's toe om requests te maken naar de Odoo-API. Voor TechniCool's PWA die op een eigen domein draait, moeten CORS-headers ingesteld worden. Dit doe je via nginx-configuratie of door de juiste headers mee te sturen in de response.

    @http.route('/api/v1/equipment', type='http', auth='user')
    def api_with_cors(self, **kw):
        response = request.make_response(
            json.dumps({'data': []}),
            headers=[
                ('Content-Type', 'application/json'),
                ('Access-Control-Allow-Origin', 'https://vanventory.techniecool.be'),
                ('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'),
            ]
        )
        return response</pre>
    ✏ Mini-oefening

    Waarom is Access-Control-Allow-Origin: * gevaarlijk voor een interne API?

    👁 Toon oplossing
    # '*' laat elk domein toe requests te maken # inclusief kwaadaardige sites. # Gebruik altijd een specifiek domein of # een whitelist van toegestane origins.
    7.6 API Sleutel Authenticatie

    Voor machine-to-machine API-calls is session-authenticatie niet praktisch. Odoo ondersteunt API keys via res.users.apikeys. De client stuurt de API-sleutel in de Authorization-header. Voor TechniCool's integraties met externe systemen (ERP-koppeling, IoT-gateways) gebruiken we API-sleutels per integratie.

    # API key genereren voor een gebruiker
    key = self.env['res.users.apikeys']._generate(
        'TechniCool IoT Gateway',
        self.env.user
    )
    
    # Authenticatie in controller (Odoo doet dit automatisch
    # via de Authorization: Bearer  header)</pre>
    ✏ Mini-oefening

    Hoe genereer je een API-sleutel voor een gebruiker via de Odoo interface?

    👁 Toon oplossing
    # Ga naar Instellingen > Gebruikers > API Sleutels # of via Settings > Technical > API Keys # Klik 'Nieuwe API Sleutel' en kopieer de gegenereerde sleutel
    7.7 Webhook Ontvanger

    Webhooks zijn HTTP-callbacks die externe systemen sturen bij een event. Voor TechniCool ontvangt het systeem webhooks van Rational's serviceportal wanneer een firmware-update beschikbaar is voor een geregistreerd toestel. De webhook-controller valideert de signature, verwerkt het payload en maakt een activiteit aan.

    @http.route('/webhook/rational/firmware', type='json',
                auth='none', methods=['POST'], csrf=False)
    def rational_firmware_webhook(self, **kw):
        payload = request.jsonrequest
        # Valideer signature
        expected_sig = hmac.new(
            RATIONAL_SECRET.encode(), request.httprequest.data, sha256
        ).hexdigest()
        if payload.get('signature') != expected_sig:
            return {'status': 'unauthorized'}
        # Verwerk firmware update notificatie
        serial = payload.get('serial_number')
        eq = request.env['techniecool.equipment'].sudo().search(
            [('serial_number', '=', serial)], limit=1
        )
        if eq:
            eq.sudo().activity_schedule('mail.mail_activity_data_todo',
                note=f'Firmware update beschikbaar: {payload["version"]}')
        return {'status': 'ok'}</pre>
    ✏ Mini-oefening

    Waarom gebruik je .sudo() in een webhook-controller?

    👁 Toon oplossing
    # Webhooks hebben auth='none', dus er is geen # ingelogde gebruiker. sudo() laat toe # de ORM te gebruiken als superuser.
    7.8 Paginering in de API

    Grote datasets stuur je nooit in één keer terug. Paginering via limit en offset verdeelt de data in pagina's. Voor TechniCool's API retourneert elke pagina maximaal 50 records met metadata over het totaal aantal records en de volgende pagina-URL.

    @http.route('/api/v1/equipment', type='json', auth='user')
    def get_equipment(self, page=1, per_page=50, **kw):
        domain = []
        offset = (int(page) - 1) * int(per_page)
        total = request.env['techniecool.equipment'].search_count(domain)
        records = request.env['techniecool.equipment'].search_read(
            domain, ['name', 'serial_number'],
            limit=int(per_page), offset=offset
        )
        return {
            'total': total,
            'page': page,
            'per_page': per_page,
            'data': records
        }</pre>
    ✏ Mini-oefening

    Bereken het totaal aantal pagina's gegeven total=250 en per_page=50.

    👁 Toon oplossing
    import math total_pages = math.ceil(250 / 50) # = 5
    # API Controller voor TechniCool
                from odoo import http
                from odoo.http import request
                import json
    
                class TechnicoolAPI(http.Controller):
                @http.route('/api/equipment/<int:id>', auth='user', type='json', methods=['GET'])
                def get_equipment_info(self, id, **kwargs):
                equipment = request.env['techniecool.equipment'].browse(id)
                if not equipment.exists():
                  return {'error': 'Not Found'}
    
                return {
                  'name': equipment.name,
                  'serial': equipment.serial_number,
                  'owner': equipment.owner_id.name,
                  'interventions': equipment.intervention_ids.mapped('name')
                }</pre>
                  
    ⚙ Praktijkopdracht

    Bouw een REST API endpoint GET /api/technician/schedule die een lijst teruggeeft van alle toegewezen orders voor de ingelogde technieker. Gebruik Postman om de API te testen en verifieer dat je een geldig JSON object terugkrijgt met de juiste data van TechniCool.

    H8
    Portal & Website Integratie
    // portal.mixin, website templates, controller logic
    Week 24 · 8u

    Klanten van TechniCool willen zelf hun historiek kunnen inkijken. We bouwen een klant-portal in Odoo. Je leert hoe je de `portal.mixin` gebruikt om bestaande records veilig publiek toegankelijk te maken (enkel voor de eigenaar), hoe je website templates bouwt en hoe je een "Mijn Toestellen" pagina maakt waar klanten hun eigen vloot keukentoestellen kunnen beheren.

    8.1 Portal Mixin

    Via portal.mixin kunnen klanten hun eigen records bekijken via het Odoo-portaal. De mixin voegt access_url en access_token toe. Voor TechniCool kunnen klanten via het portaal de status van hun toestellen en openstaande interventies raadplegen. Je configureert welke data zichtbaar is via record rules op de portaalgroep.

    class TechniCoolEquipment(models.Model):
        _inherit = ['techniecool.equipment', 'portal.mixin']
    
        def _compute_access_url(self):
            super()._compute_access_url()
            for rec in self:
                rec.access_url = f'/my/equipment/{rec.id}'
    
    # In de controller:
    @http.route('/my/equipment', auth='public', website=True)
    def portal_equipment(self, **kw):
        partner = request.env.user.partner_id
        equipment = request.env['techniecool.equipment'].search(
            [('owner_id', '=', partner.id)]
        )
        return request.render('techniecool_base.portal_equipment', {
            'equipment': equipment
        })</pre>
    ✏ Mini-oefening

    Hoe genereer je een gedeelde URL met access token voor een portaalrecord?

    👁 Toon oplossing
    url = equipment.get_portal_url() # Geeft: /my/equipment/42?access_token=abc123
    8.2 Website Templates

    Portaalpagina's worden gerenderd via QWeb-website-templates. Je erft van portal.portal_layout voor de portaalomgeving. Voor TechniCool toont de portaalpagina een lijst van toestellen met status en garantie-badge. Klanten kunnen klikken op een toestel voor het volledige overzicht inclusief interventiehistoriek.

    <template id='portal_equipment'
             name='Mijn Toestellen'
             inherit_id='portal.portal_layout'>
      <xpath expr="//div[@id='portal_my_home']" position='inside'>
        <div class='row'>
          <t t-foreach='equipment' t-as='eq'>
            <div class='col-md-4'>
              <div class='card'>
                <h5><t t-esc='eq.name'/></h5>
                <a t-att-href='eq.access_url'>Details</a>
              </div>
            </div>
          </t>
        </div>
      </xpath>
    </template></pre>
    ✏ Mini-oefening

    Welk template erft van je portaalpagina om de standaard portaalomgeving te krijgen?

    👁 Toon oplossing
    # inherit_id='portal.portal_layout' # Dit voegt de portaal-navigatie, breadcrumb # en stijl van het Odoo-portaal toe.
    8.3 My Home Portaal Link

    Het 'Mijn Account'-portaal toont een overzicht van alle beschikbare secties. Voeg je eigen sectie toe via het portal.portal_my_home-template. Voor TechniCool verschijnt een 'Mijn Toestellen'-blok op de portaalhomepagina met het totaal aantal toestellen en een link naar de detailpagina.

    <template inherit_id='portal.portal_my_home'>
      <xpath expr="//div[@id='portal_my_home']" position='inside'>
        <div class='col-lg-3'>
          <div class='card'>
            <i class='fa fa-wrench'/>
            <h3><t t-esc='equipment_count'/></h3>
            <a href='/my/equipment'>Mijn Toestellen</a>
          </div>
        </div>
      </xpath>
    </template>
    
    # In de controller:
    def portal_my_home(self, **kw):
        kw['equipment_count'] = request.env['techniecool.equipment'].search_count(
            [('owner_id','=', request.env.user.partner_id.id)]
        )
        return super().portal_my_home(**kw)</pre>
    ✏ Mini-oefening

    Welke methode override je om extra data toe te voegen aan de portaal-homepagina?

    👁 Toon oplossing
    def portal_my_home(self, **kw): kw['my_count'] = self.env['my.model'].search_count([]) return super().portal_my_home(**kw)
    8.4 Portaal Formulier — Klant Input

    Klanten kunnen via het portaal formulieren invullen voor serviceaanvragen. Het formulier verwerkt POST-data, valideert en slaat op via de ORM. Voor TechniCool kunnen klanten een defectmelding indienen via het portaal, die automatisch een concept-interventie aanmaakt en de dispatcher notificeert.

    @http.route('/my/equipment/report_defect', auth='public',
                website=True, methods=['POST'])
    def report_defect(self, equipment_id, description, **kw):
        partner = request.env.user.partner_id
        eq = request.env['techniecool.equipment'].sudo().browse(
            int(equipment_id)
        )
        if eq.owner_id != partner:
            return request.redirect('/my')
        request.env['techniecool.intervention'].sudo().create({
            'equipment_id': eq.id,
            'problem_description': description,
            'state': 'draft'
        })
        return request.redirect(f'/my/equipment/{equipment_id}?success=1')</pre>
    ✏ Mini-oefening

    Hoe redirect je een portaalgebruiker na een succesvolle actie?

    👁 Toon oplossing
    return request.redirect('/my/equipment?message=success')
    8.5 Webhook voor Klant Notificaties

    TechniCool stuurt proactieve meldingen naar klanten via het portaal en e-mail. Bij een statuswijziging op een interventie ontvangt de klant automatisch een e-mail met een link naar het portaal. De e-mail bevat de access token zodat de klant zonder inloggen details kan raadplegen.

    def action_notify_client(self):
        for rec in self:
            rec.owner_id.message_notify(
                subject=f'Update interventie {rec.name}',
                body=f'De status van uw herstelling is bijgewerkt: {rec.state}',
                partner_ids=[rec.owner_id.id]
            )</pre>
    ✏ Mini-oefening

    Hoe stuur je een e-mail naar een partner zonder dat het in de chatter verschijnt?

    👁 Toon oplossing
    partner.message_notify( subject='Notificatie', body='Bericht voor klant', partner_ids=[partner.id] )
    8.6 Klantportaal Toegangscontrole

    Portaalgebruikers mogen enkel hun eigen records zien. Controleer altijd in de controller of de opgevraagde record toebehoort aan de ingelogde klant. Gebruik de access_token voor anonieme toegang tot een specifiek record. Voor TechniCool valideert de controller het token voor elke portaalrequest op toesteldetails.

    @http.route('/my/equipment/', auth='public', website=True)
    def equipment_detail(self, id, access_token=None, **kw):
        eq = request.env['techniecool.equipment'].sudo().browse(id)
        # Controleer toegang
        if access_token:
            if not eq.sudo()._check_access_token(access_token):
                raise werkzeug.exceptions.Forbidden()
        elif request.env.user == request.env.ref('base.public_user'):
            return request.redirect(f'/web/login?redirect=/my/equipment/{id}')
        elif eq.owner_id != request.env.user.partner_id:
            raise werkzeug.exceptions.Forbidden()
        return request.render('techniecool_base.equipment_detail', {'eq': eq})</pre>
    ✏ Mini-oefening

    Hoe redirect je een niet-ingelogde gebruiker naar de login-pagina met terugkeerpad?

    👁 Toon oplossing
    return request.redirect(f'/web/login?redirect=/my/equipment/{id}')
    8.7 Website Builder Integratie

    De Odoo Website Builder laat marketingteams pagina's bouwen zonder code. Voor TechniCool bouwen we een publieksgerichte pagina over professioneel keukenonderhoud met een aanvraagformulier voor een inspectieafspraak. Odoo Snippets maken herbruikbare blokken die het marketingteam kan slepen.

    # Controller voor de publieke pagina
    @http.route('/keukenonderhoud', auth='public', website=True)
    def maintenance_page(self, **kw):
        return request.render(
            'techniecool_base.website_maintenance_page', {}
        )
    
    # Template erft van website.layout
    <template id='website_maintenance_page'
             name='TechniCool Onderhoud'>
      <t t-call='website.layout'>
        <section class='hero'>
          <h1>Professioneel Keukenonderhoud</h1>
        </section>
      </t>
    </template></pre>
    ✏ Mini-oefening

    Wat is het verschil tussen portal.portal_layout en website.layout?

    👁 Toon oplossing
    # portal.portal_layout: portaalstijl voor ingelogde klanten # website.layout: publieke websitestijl met navbar # Portaalpagina's erven van portal, publieke van website
    8.8 SEO & Metadata

    Publieke websitepagina's hebben correcte meta-tags nodig voor zoekmachines. Odoo's website.layout bevat automatisch de basis-SEO. Voor TechniCool's productpagina's voeg je per-pagina title en description toe. Gestructureerde data (JSON-LD Schema.org) verbetert de vindbaarheid in Google.

    <template id='maintenance_page'>
      <t t-call='website.layout'>
        <t t-set='head'>
          <title>Professioneel Keukenonderhoud | TechniCool NV</title>
          <meta name='description'
                content='Specialist in Rational combi-steamers en Electrolux blast chillers.'/>
        </t>
        <!-- pagina-inhoud -->
      </t>
    </template></pre>
    ✏ Mini-oefening

    Hoe stel je een canonieke URL in voor een Odoo-websitepagina?

    👁 Toon oplossing
    <!-- Portal Template voor TechniCool Toestellen -->
                <template id="portal_my_equipments" name="My Equipments">
                <t t-call="portal.portal_layout">
                <t t-set="breadcrumbs_searchbar" t-value="True"/>
                <t t-call="portal.portal_table">
                  <thead>
                      <tr><th>Naam</th><th>Serie</th><th>Status</th></tr>
                  </thead>
                  <tbody>
                      <tr t-foreach="equipments" t-as="equip">
                          <td><a t-attf-href="/my/equipment/#{equip.id}"><span t-field="equip.name"/></a></td>
                          <td><span t-field="equip.serial_number"/></td>
                          <td><span class="badge badge-info" t-field="equip.warranty_status"/></td>
                      </tr>
                  </tbody>
                </t>
                </t>
                </template></pre>
                  
    ⚙ Praktijkopdracht

    Bouw de TechniCool klantportal. De klant logt in, ziet een lijst van zijn eigen toestellen en kan voor elk toestel de volledige interventiehistoriek bekijken en de laatste werkbon als PDF downloaden. Test dit met een portal-gebruiker gekoppeld aan een test-klant.

    H9
    Onderhoudscontracten & Recurring
    // subscriptions, scheduling, automated generation
    Week 25 · 8u

    Stabiliteit komt van onderhoudscontracten. We implementeren terugkerende interventies voor TechniCool. Je leert hoe je modellen bouwt voor jaarlijkse of halfjaarlijkse service-beurten, hoe je een 'Cron job' schrijft die elke nacht controleert welke toestellen aan onderhoud toe zijn en hoe je automatisch werkorders genereert en inplant voor de juiste technieker.

    9.1 Contract Lifecycle

    Een onderhoudscontract doorloopt de staten: Concept → Actief → In Verlenging → Vervallen. Voor TechniCool beheert het systeem automatisch de overgangen: 30 dagen voor de vervaldatum stuurt de app een verlengingvoorstel naar de klant. Bij acceptatie verlengt het contract automatisch en start een nieuwe facturatiecyclus.

    class TechniCoolContract(models.Model):
        _name = 'techniecool.contract'
        state = fields.Selection([
            ('draft','Concept'), ('active','Actief'),
            ('renewal','In Verlenging'), ('expired','Vervallen')
        ], default='draft', tracking=True)
    
        def _cron_check_renewals(self):
            cutoff = fields.Date.today() + timedelta(days=30)
            expiring = self.search([
                ('state','=','active'),
                ('date_end','<=',cutoff)
            ])
            expiring.write({'state': 'renewal'})
            expiring.mapped('partner_id').message_notify(
                subject='Contractverlenging TechniCool',
                body='Uw onderhoudscontract verloopt binnenkort.'
            )</pre>
    ✏ Mini-oefening

    Schrijf een methode die een verlopen contract automatisch naar staat 'expired' zet.

    👁 Toon oplossing
    def _cron_expire_contracts(self): today = fields.Date.today() expired = self.search([('date_end','<',today),('state','!=','expired')]) expired.write({'state': 'expired'})
    9.2 Recurring Facturatie

    Onderhoudscontracten worden maandelijks of jaarlijks gefactureerd. Odoo's sale.subscription-module (of een eigen implementatie) beheert dit. Voor TechniCool genereert een maandelijkse cron een factuur per actief contract. De factuurregel bevat de contractnaam, de periode en het bedrag.

    def _create_invoice(self):
        for contract in self:
            invoice = self.env['account.move'].create({
                'move_type': 'out_invoice',
                'partner_id': contract.partner_id.id,
                'invoice_line_ids': [(0, 0, {
                    'name': f'{contract.name} - {fields.Date.today().strftime("%B %Y")}',
                    'price_unit': contract.monthly_amount,
                    'quantity': 1,
                })]
            })
            return invoice</pre>
    ✏ Mini-oefening

    Hoe bevestig je een factuur direct na aanmaak via Python?

    👁 Toon oplossing
    invoice.action_post() # Zet factuur op 'Geplaatst'
    9.3 Contract Sjablonen

    Contractsjablonen definiëren een standaardcontract dat klanten kunnen kiezen. Het sjabloon bevat de prijs, duur, serviceniveau en inbegrepen interventies. Voor TechniCool zijn er drie niveaus: Basis (1 preventief onderhoud/jaar), Premium (4 onderhoudsbeurten + 24/7 support) en Enterprise (dedicated technieker).

    class TechniCoolContractTemplate(models.Model):
        _name = 'techniecool.contract.template'
        name = fields.Char(required=True)
        duration_months = fields.Integer(default=12)
        monthly_price = fields.Float()
        max_interventions = fields.Integer()
        response_time_hours = fields.Float()
        includes_parts = fields.Boolean()
    
        def create_contract(self, partner, equipment_ids):
            return self.env['techniecool.contract'].create({
                'partner_id': partner.id,
                'template_id': self.id,
                'equipment_ids': [(6, 0, equipment_ids)],
                'monthly_amount': self.monthly_price,
            })</pre>
    ✏ Mini-oefening

    Wat betekent de tuple (6, 0, [id1, id2]) in een Many2many-write?

    👁 Toon oplossing
    # (6, 0, [ids]) = vervang de volledige Many2many # door de opgegeven lijst van IDs # Andere commands: (4, id) = voeg toe # (3, id) = verwijder link
    9.4 Preventief Onderhoudsplanning

    Preventief onderhoud wordt gepland op vaste intervallen per toestel. Een jaarlijkse inspectie, filters reinigen en kalibratiecheck zijn typische taken. Voor TechniCool genereert het systeem automatisch onderhoudswerkorders 6 weken op voorhand zodat de dispatcher ze kan inplannen.

    def _schedule_preventive_maintenance(self):
        for contract in self.filtered(lambda c: c.state == 'active'):
            next_date = fields.Date.today() + timedelta(weeks=6)
            for eq in contract.equipment_ids:
                existing = self.env['techniecool.intervention'].search([
                    ('equipment_id','=', eq.id),
                    ('intervention_type','=','preventive'),
                    ('state','=','draft'),
                    ('date_planned','>=', fields.Date.today())
                ])
                if not existing:
                    self.env['techniecool.intervention'].create({
                        'equipment_id': eq.id,
                        'intervention_type': 'preventive',
                        'date_planned': next_date,
                    })</pre>
    ✏ Mini-oefening

    Hoe zoek je alle interventies voor een toestel die ingepland zijn voor volgende maand?

    👁 Toon oplossing
    next_month_start = (date.today().replace(day=1) + timedelta(days=32)).replace(day=1) next_month_end = (next_month_start + timedelta(days=32)).replace(day=1) interventions = self.env['techniecool.intervention'].search([ ('equipment_id','=', equipment.id), ('date_planned','>=', str(next_month_start)), ('date_planned','<', str(next_month_end)) ])
    9.5 SLA Monitoring

    Service Level Agreements (SLA) definiëren garantietijden: Premium-klanten krijgen een reactietijd van 4 uur, Basis-klanten 24 uur. Voor TechniCool bewaken we SLA-naleving via een computed field op de interventie dat aangeeft of de reactietijd overschreden is, en escaleren we automatisch naar de manager.

    sla_deadline = fields.Datetime(compute='_compute_sla_deadline', store=True)
    sla_breached = fields.Boolean(compute='_compute_sla_breach', store=True)
    
    @api.depends('create_date', 'equipment_id.contract_ids.response_time_hours')
    def _compute_sla_deadline(self):
        for rec in self:
            hours = rec.equipment_id.active_contract_id.response_time_hours or 24
            rec.sla_deadline = rec.create_date + timedelta(hours=hours)</pre>
    ✏ Mini-oefening

    Schrijf de _compute_sla_breach-methode die True geeft als nu > sla_deadline.

    👁 Toon oplossing
    @api.depends('sla_deadline') def _compute_sla_breach(self): now = fields.Datetime.now() for rec in self: rec.sla_breached = bool(rec.sla_deadline and now > rec.sla_deadline and rec.state != 'done')
    9.6 Contract Rapportage

    Maandelijkse contractrapporten geven klanten inzicht in het gebruik van hun contract: aantal interventies, gebruikte onderdelen, SLA-naleving en resterende credits. Voor TechniCool genereert een QWeb-rapport dit overzicht automatisch en stuurt het per e-mail op de eerste werkdag van de maand.

    def _cron_monthly_reports(self):
        today = fields.Date.today()
        if today.day != 1:
            return
        active = self.search([('state','=','active')])
        report = self.env.ref('techniecool_base.report_contract_monthly')
        for contract in active:
            pdf, _ = report._render_qweb_pdf([contract.id])
            attachment = self.env['ir.attachment'].create({
                'name': f'Maandrapport_{today.strftime("%Y_%m")}.pdf',
                'datas': base64.b64encode(pdf),
                'res_model': 'techniecool.contract',
                'res_id': contract.id
            })
            contract.partner_id.message_notify(
                subject=f'TechniCool Maandrapport {today.strftime("%B %Y")}',
                attachment_ids=[attachment.id]
            )</pre>
    ✏ Mini-oefening

    Hoe formatteer je een datum als 'Januari 2024' in Python?

    👁 Toon oplossing
    from datetime import date date(2024, 1, 1).strftime('%B %Y') # 'January 2024' # Voor Nederlands: locale instellen of handmatig vertalen
    9.7 Upsell via Contractverlenging

    Bij contractverlenging probeert TechniCool klanten te upgraden naar een hoger serviceniveau. Het systeem analyseert het gebruikspatroon: als een Basis-klant meer dan 6 interventies per jaar had, stelt het systeem automatisch een upgrade naar Premium voor via een offerte.

    def create_renewal_quotation(self):
        for contract in self:
            intervention_count = len(contract.intervention_ids.filtered(
                lambda i: i.state == 'done'
            ))
            # Upsell logica
            recommended = contract.template_id
            if intervention_count > 6 and contract.service_level == 'basic':
                recommended = self.env.ref('techniecool_base.template_premium')
            sale_order = self.env['sale.order'].create({
                'partner_id': contract.partner_id.id,
                'note': f'Contractverlenging - Aanbevolen: {recommended.name}'
            })
            return sale_order</pre>
    ✏ Mini-oefening

    Hoe tel je het aantal afgeronde interventies van een contract in het afgelopen jaar?

    👁 Toon oplossing
    from datetime import date, timedelta year_ago = date.today() - timedelta(days=365) count = len(contract.intervention_ids.filtered( lambda i: i.state == 'done' and i.date_end and i.date_end.date() >= year_ago ))
    9.8 Contract Import via CSV

    Bij een fusie of overname neemt TechniCool contracten over van een ander systeem. Een CSV-import laadt honderden contracten in één keer. De import-module gebruikt Odoo's ingebouwde base_import of een custom controller. Validatie voor import voorkomt duplicaten en ontbrekende referenties.

    def import_contracts_csv(self, file_content):
        import csv, io
        reader = csv.DictReader(io.StringIO(file_content))
        errors = []
        for row in reader:
            partner = self.env['res.partner'].search(
                [('name','=', row['client_naam'])], limit=1
            )
            if not partner:
                errors.append(f"Klant niet gevonden: {row['client_naam']}")
                continue
            self.env['techniecool.contract'].create({
                'partner_id': partner.id,
                'name': row['contract_nummer'],
                'date_start': row['startdatum'],
                'date_end': row['einddatum'],
            })
        return errors</pre>
    ✏ Mini-oefening

    Hoe vermijd je duplicaten bij bulk-import van contracten?

    👁 Toon oplossing
    existing = self.env['techniecool.contract'].search( [('name','=', row['contract_nummer'])] ) if existing: continue # sla over als al bestaat
    class TechnicoolAgreement(models.Model):
                _name = 'techniecool.agreement'
    
                name = fields.Char(required=True)
                partner_id = fields.Many2one('res.partner')
                equipment_ids = fields.Many2many('techniecool.equipment')
                next_service_date = fields.Date(string='Volgende Service')
                interval_months = fields.Integer(default=12)
    
                def _cron_generate_service_orders(self):
                today = fields.Date.today()
                agreements = self.search([('next_service_date', '<=', today)])
                for ag in agreements:
                  for eq in ag.equipment_ids:
                      self.env['fsm.order'].create({
                          'name': f"Preventief Onderhoud {eq.name}",
                          'equipment_id': eq.id,
                          'location_id': eq.location_id.id,
                          'type': self.env.ref('techniecool.type_maintenance').id
                      })
                  # Update volgende datum
                  ag.next_service_date += relativedelta(months=ag.interval_months)</pre>
                  
    ⚙ Praktijkopdracht

    Implementeer onderhoudscontracten. Maak een contract aan voor "Hotel De Kroon" met 4 combi-steamers. Schrijf de cron methode en test deze door de datum handmatig in het verleden te zetten en de cron manueel te triggeren. Verifieer dat er 4 nieuwe onderhouds-interventies zijn aangemaakt.

    H10
    Odoo Studio Alternative — Programmatisch
    // ir.ui.view records, post_init_hooks, migrations
    Week 26 · 8u

    Odoo Studio is voor amateurs, code is voor professionals. We leren hoe we alles wat Studio kan, programmatisch doen in onze module. Je leert hoe je velden en views aanmaakt via XML data bestanden, hoe je 'post_init_hooks' gebruikt om bij installatie direct de juiste data-structuur op te zetten en hoe je migratie-scripts schrijft voor als TechniCool naar een nieuwere Odoo versie gaat.

    10.1 ir.ui.view Programmatisch

    In plaats van Odoo Studio kun je views volledig programmatisch aanmaken via de ORM. Dit is reproduceerbaar via versiecontrole en werkt in alle Odoo-versies. Voor TechniCool maken we via een script de custom form-view aan met alle TechniCool-specifieke velden die we bij een klant-installatie nodig hebben.

    view_arch = '''
    
    ''' self.env['ir.ui.view'].create({ 'name': 'techniecool.equipment.form.custom', 'model': 'techniecool.equipment', 'arch': view_arch, 'type': 'form', })</pre>
    ✏ Mini-oefening

    Hoe maak je een view-inheritance record programmatisch aan?

    👁 Toon oplossing
    self.env['ir.ui.view'].create({ 'name': 'equipment.form.inherit.custom', 'model': 'techniecool.equipment', 'inherit_id': self.env.ref('techniecool_base.equipment_form').id, 'arch': '', 'type': 'form', })
    10.2 ir.model.fields Dynamisch Toevoegen

    Je kunt velden dynamisch toevoegen aan modellen via ir.model.fields. Dit is wat Odoo Studio achter de schermen doet. Voor TechniCool's configureerbare productkaart laat het toe dat klanten zelf extra velden toevoegen aan het equipment-model zonder code te schrijven. Dynamische velden worden opgeslagen als x_-prefixed velden.

    self.env['ir.model.fields'].create({
        'model_id': self.env['ir.model']._get('techniecool.equipment').id,
        'name': 'x_custom_field',
        'field_description': 'Mijn Aangepast Veld',
        'ttype': 'char',
        'state': 'manual',
    })</pre>
    ✏ Mini-oefening

    Wat is het verplichte prefix voor manueel aangemaakte velden in Odoo?

    👁 Toon oplossing
    # x_ - alle custom fields moeten beginnen met x_ # Voorbeeld: x_custom_note, x_extra_serial
    10.3 Menuitems Programmatisch

    Menuitems in Odoo zijn ir.ui.menu-records. Je kunt deze aanmaken, bijwerken of verbergen via de ORM. Voor TechniCool's rol-gebaseerde interface verbergen we technisch geavanceerde menu's voor junior technici door de groups-restrictie programmatisch in te stellen.

    self.env['ir.ui.menu'].create({
        'name': 'Vanventory Dashboard',
        'parent_id': self.env.ref('techniecool_base.menu_techniecool').id,
        'action': f'ir.actions.client,{self.env.ref("techniecool_base.action_vanventory").id}',
        'sequence': 10,
        'groups_id': [(4, self.env.ref('techniecool_base.group_dispatcher').id)]
    })</pre>
    ✏ Mini-oefening

    Hoe verberg je een bestaand menuitem voor alle gebruikers behalve admins?

    👁 Toon oplossing
    menu.write({ 'groups_id': [(4, self.env.ref('base.group_system').id)] })
    10.4 ir.actions Aanmaken

    Actions kunnen programmatisch aangemaakt worden wat handig is voor dynamische configuraties per klant. Voor TechniCool maakt een on-boarding wizard automatisch de klant-specifieke dashboard-actie aan met het juiste domain en context voor hun vestiging.

    action = self.env['ir.actions.act_window'].create({
        'name': f'Toestellen {partner.name}',
        'res_model': 'techniecool.equipment',
        'view_mode': 'kanban,list,form',
        'domain': [('owner_id','=', partner.id)],
        'context': {'default_owner_id': partner.id},
    })
    menu = self.env['ir.ui.menu'].create({
        'name': partner.name,
        'parent_id': parent_menu.id,
        'action': f'ir.actions.act_window,{action.id}',
    })</pre>
    ✏ Mini-oefening

    Hoe koppel je een actie aan een menuitem via het action-string formaat?

    👁 Toon oplossing
    # Formaat: 'model_name,record_id' # Voorbeeld: menu.action = f'ir.actions.act_window,{action.id}'
    10.5 Automated Actions

    Geautomatiseerde acties (base.automation) voeren acties uit op basis van triggers: bij aanmaak, bij schrijven, op tijdstip. Voor TechniCool maakt een geautomatiseerde actie bij aanmaak van een nieuwe interventie automatisch een activiteit voor de dispatcher om een technieker toe te wijzen.

    self.env['base.automation'].create({
        'name': 'Interventie: Technieker Toewijzen',
        'model_id': self.env['ir.model']._get('techniecool.intervention').id,
        'trigger': 'on_create',
        'action_server_ids': [(0, 0, {
            'name': 'Plan Toewijzing Activiteit',
            'model_id': self.env['ir.model']._get('techniecool.intervention').id,
            'state': 'code',
            'code': "record.activity_schedule('mail.mail_activity_data_todo', note='Wijs technieker toe')",
        })]
    })</pre>
    ✏ Mini-oefening

    Welke triggers zijn beschikbaar in base.automation?

    👁 Toon oplossing
    # on_create, on_write, on_create_or_write # on_unlink (bij verwijdering) # on_change (bij form-wijziging) # based_on_timed_condition (op tijdstip)
    10.6 Reports Programmatisch

    Rapporten aanmaken via de ORM laat je rapportconfiguraties deployen via datamigratiescrips. Voor TechniCool's klant-specifieke installaties genereren we op maat gemaakte rapportdefinities met de bedrijfsnaam en het logo van de klant ingebakken.

    self.env['ir.actions.report'].create({
        'name': 'Werkbon TechniCool',
        'model': 'techniecool.intervention',
        'report_type': 'qweb-pdf',
        'report_name': 'techniecool_base.report_intervention_werkbon',
        'print_report_name': "'Werkbon - ' + object.name",
        'binding_model_id': self.env['ir.model']._get(
            'techniecool.intervention'
        ).id,
    })</pre>
    ✏ Mini-oefening

    Hoe maak je een rapport beschikbaar in de 'Afdrukken'-dropdown van een list view?

    👁 Toon oplossing
    # Stel binding_model_id in: report.binding_model_id = self.env['ir.model']._get('mijn.model').id
    10.7 Custom Filters Opslaan

    Gebruikers kunnen filters opslaan als favoriete zoekopdrachten. Programmatisch kun je standaard-favorieten instellen voor alle gebruikers via ir.filters. Voor TechniCool deployen we standaard-favorieten bij installatie: 'Mijn Toestellen', 'Dringende Interventies', 'Garantie Vervalt Binnenkort'.

    self.env['ir.filters'].create({
        'name': 'Garantie Vervalt Binnenkort',
        'model_id': 'techniecool.equipment',
        'domain': "[('warranty_expiry','<=', (datetime.now() + timedelta(days=30)).strftime('%Y-%m-%d'))]",
        'user_id': False,  # False = beschikbaar voor iedereen
        'is_default': False,
    })</pre>
    ✏ Mini-oefening

    Hoe stel je een filter in als standaard voor een specifiek model en view?

    👁 Toon oplossing
    self.env['ir.filters'].create({ 'name': 'Mijn filter', 'model_id': 'mijn.model', 'domain': '[("active","=",True)]', 'is_default': True, 'user_id': self.env.uid })
    10.8 Config Settings

    Bedrijfsinstellingen sla je op via res.config.settings. Je erft van dit model en voegt je eigen configuratievelden toe. Voor TechniCool voegen we instellingen toe voor de standaard garantieduur, de e-mailadressen voor escalaties en de Rational API-sleutel.

    class TechniCoolSettings(models.TransientModel):
        _inherit = 'res.config.settings'
    
        default_warranty_months = fields.Integer(
            string='Standaard Garantie (maanden)',
            config_parameter='techniecool.default_warranty_months',
            default=24
        )
        rational_api_key = fields.Char(
            string='Rational API Sleutel',
            config_parameter='techniecool.rational_api_key'
        )</pre>
    ✏ Mini-oefening

    Hoe lees je een config_parameter in een methode zonder via res.config.settings te gaan?

    👁 Toon oplossing
    value = self.env['ir.config_parameter'].sudo().get_param( 'techniecool.default_warranty_months', default='24' )
    # __init__.py fragment
                def post_init_hook(cr, registry):
                # Maak standaard TechniCool categorieën aan bij installatie
                env = api.Environment(cr, super_user_id, {})
                categories = [
                {'name': 'Combi-steamer', 'code': 'CS'},
                {'name': 'Vaatwasser', 'code': 'DW'},
                {'name': 'Koeling', 'code': 'REF'}
                ]
                for cat in categories:
                env['techniecool.equipment.category'].create(cat)
    
                # In __manifest__.py: 'post_init_hook': 'post_init_hook'</pre>
                  
    ⚙ Praktijkopdracht

    Schrijf een post_init_hook die bij installatie van de module automatisch 3 equipment categorieën, 2 checklist templates en de standaard stocklocaties voor de eerste 2 voertuigen configureert. Test de installatie op een schone database en verifieer dat de data aanwezig is.

    H11
    Testing & CI/CD
    // unit tests, integration tests, github actions, runbot
    Week 27 · 8u

    Code moet betrouwbaar zijn. We schrijven uitgebreide testen voor de TechniCool module. Je leert hoe je `TransactionCase` gebruikt voor backend tests die na elke run de database roll-backen, en hoe je `HttpCase` gebruikt om browser-acties te simuleren. We zetten ook een GitHub Actions workflow op die bij elke 'push' automatisch alle testen draait.

    11.1 Unit Tests Schrijven

    Odoo-tests erven van TransactionCase (rollback na elke test) of SavepointCase (savepoints per methode). Tests leven in de tests/-map en worden automatisch ontdekt door Odoo. Voor TechniCool testen we dat de garantiedatum correct berekend wordt en dat de SQL-constraint duplicaten blokkeert.

    from odoo.tests import TransactionCase
    
    class TestEquipment(TransactionCase):
    
        def setUp(self):
            super().setUp()
            self.eq = self.env['techniecool.equipment'].create({
                'name': 'Rational iCombi Test',
                'serial_number': 'TEST-001',
                'install_date': '2022-01-01',
            })
    
        def test_warranty_computation(self):
            self.assertEqual(
                str(self.eq.warranty_expiry), '2024-01-01'
            )</pre>
    ✏ Mini-oefening

    Hoe test je dat een UserError gegooid wordt bij een ongeldige actie?

    👁 Toon oplossing
    from odoo.exceptions import UserError def test_serial_required(self): with self.assertRaises(UserError): self.eq.serial_number = False self.eq.action_send_to_repair()
    11.2 Test Tags

    Test tags groeperen tests zodat je selectief kunt uitvoeren. De decorators @tagged markeren tests als slow, post_install of at_install. Voor TechniCool markeren we de API-tests als @tagged('api') en de zware integratietests als @tagged('slow') zodat de snelle unit-tests apart kunnen draaien in CI.

    from odoo.tests import tagged
    
    @tagged('techniecool', '-standard')
    class TestEquipmentAPI(TransactionCase):
    
        @tagged('api', 'slow')
        def test_equipment_api_endpoint(self):
            # Test de REST API controller
            pass</pre>
    ✏ Mini-oefening

    Welk tag gebruik je om een test enkel na module-installatie uit te voeren?

    👁 Toon oplossing
    @tagged('post_install', '-at_install') class MyTest(TransactionCase): ...
    11.3 Mocking & Externe Services

    Externe API-calls (Rational portal, Mapbox) mocken we in tests zodat tests niet afhangen van een internetverbinding. Python's unittest.mock.patch vervangt de echte API-call tijdelijk. Voor TechniCool mocken we de geocoding-service in de adres-importtest.

    from unittest.mock import patch
    
    def test_geocode_address(self):
        with patch('requests.get') as mock_get:
            mock_get.return_value.json.return_value = [
                {'lat': '50.85', 'lon': '4.35'}
            ]
            coords = self.service.geocode_address('Brussel')
            self.assertEqual(coords['lat'], 50.85)</pre>
    ✏ Mini-oefening

    Hoe mock je een orm.call in een OWL unit test?

    👁 Toon oplossing
    // In Jest: const mockOrm = { call: jest.fn().mockResolvedValue({status: 'ok'}) }; const component = await mount(MyComponent, env, { services: { orm: mockOrm } });
    11.4 CI/CD met GitHub Actions

    GitHub Actions automatiseren het testen bij elke push of pull request. Een Odoo-CI pipeline start een Odoo-instantie, installeert de module en draait alle tests. Voor TechniCool's CI draait de pipeline in circa 5 minuten dankzij Docker-caching van de Odoo-basisimage.

    # .github/workflows/test.yml
    name: Odoo Tests
    on: [push, pull_request]
    jobs:
      test:
        runs-on: ubuntu-latest
        services:
          postgres:
            image: postgres:15
            env:
              POSTGRES_USER: odoo
              POSTGRES_DB: odoo_test
        steps:
          - uses: actions/checkout@v3
          - name: Run Odoo Tests
            run: |
              docker run --rm \
                -v $(pwd):/mnt/extra-addons \
                odoo:17 \
                odoo -d odoo_test --test-enable --stop-after-init \
                -i techniecool_base</pre>
    ✏ Mini-oefening

    Hoe beperk je de CI-run tot enkel de tests van jouw module?

    👁 Toon oplossing
    odoo --test-enable -i techniecool_base --stop-after-init # --test-tags techniecool voor specifieke tags
    11.5 Code Coverage

    Code coverage meet welk percentage van de Python-code door tests gedekt wordt. Voor Odoo gebruik je coverage.py gecombineerd met de Odoo testrunner. TechniCool streeft naar minimum 80% dekking op businesslogica. Rapporten genereer je als HTML voor visueel inzicht.

    # Uitvoeren met coverage
    coverage run --source=techniecool_base \
        /usr/bin/odoo -d test_db --test-enable \
        -i techniecool_base --stop-after-init
    
    coverage report --show-missing
    coverage html -d htmlcov/</pre>
    ✏ Mini-oefening

    Welk coverage percentage wordt als minimum aanbevolen voor Odoo-modules?

    👁 Toon oplossing
    # Minimum 70-80% voor businesslogica # Computed fields, actions en constraints # zijn de hoogste prioriteit om te testen
    11.6 Performantietests

    Performantietests meten responstijden bij hoge belasting. Locust is een Python-gebaseerde load-testing tool die HTTP-requests simuleert. Voor TechniCool simuleren we 50 gelijktijdige dispatchers die het dashboard laden en 10 technici die QR-codes scannen.

    from locust import HttpUser, task, between
    
    class TechniCoolUser(HttpUser):
        wait_time = between(1, 3)
    
        @task(3)
        def load_equipment_list(self):
            self.client.post('/web/dataset/call_kw', json={
                'jsonrpc': '2.0', 'method': 'call',
                'params': {
                    'model': 'techniecool.equipment',
                    'method': 'search_read',
                    'args': [[], ['name', 'serial_number']],
                    'kwargs': {}
                }
            })
    
        @task(1)
        def scan_qr(self):
            self.client.get('/api/v1/equipment/scan?serial=TEST-001')</pre>
    ✏ Mini-oefening

    Welk Locust commando start een test met 50 gebruikers op http://localhost:8069?

    👁 Toon oplossing
    locust -f locustfile.py --host=http://localhost:8069 --users=50 --spawn-rate=5
    11.7 Staging & Deploy

    Een goede deploy-pipeline heeft minstens drie omgevingen: development, staging en productie. Voor TechniCool deployen we via Docker Compose op een VPS. Database-backups voor elke update, blue-green deployment voor zero-downtime en automatische rollback bij een mislukte test zijn de standaard.

    # docker-compose.yml voor staging
    version: '3'
    services:
      odoo:
        image: odoo:17
        volumes:
          - ./addons:/mnt/extra-addons
        environment:
          - HOST=db
          - USER=odoo
          - PASSWORD=odoo
      db:
        image: postgres:15
        environment:
          - POSTGRES_USER=odoo
          - POSTGRES_DB=odoo_staging</pre>
    ✏ Mini-oefening

    Hoe maak je een backup van de Odoo-database via de command line?

    👁 Toon oplossing
    pg_dump -U odoo odoo_prod > backup_$(date +%Y%m%d).sql
    11.8 Database Migraties Testen

    Migratiesscripts moeten getest worden op een kopie van de productiедатаbase. Een testmigratierun valideert of het script correct werkt voor alle edge cases in de bestaande data. Voor TechniCool testen we migratiesscripts op een anonieme dump van productie met gepseudonimiseerde klantgegevens.

    # Test migratiesscript
    def test_migration_warranty_dates(self):
        # Simuleer pre-migratie staat
        self.env.cr.execute("""
            UPDATE techniecool_equipment
            SET warranty_expiry = NULL
            WHERE install_date IS NOT NULL
        """)
        # Voer migratie uit
        from techniecool_base.migrations.v1_1_0 import post_migrate
        post_migrate(self.env.cr, '1.0.0')
        # Valideer resultaat
        eq = self.env['techniecool.equipment'].browse(self.eq.id)
        self.assertIsNotNone(eq.warranty_expiry)</pre>
    ✏ Mini-oefening

    Waarom test je migratiesscripts op een anonieme kopie van productie?

    👁 Toon oplossing
    # Productiedata bevat edge cases die synthetische # testdata niet dekt: NULL-waarden, speciale tekens, # historische formaten en uitzonderingen.
    # tests/test_intervention.py
                from odoo.tests import common
    
                class TestTechnicoolIntervention(common.TransactionCase):
                def setUp(self):
                super(TestTechnicoolIntervention, self).setUp()
                self.partner = self.env['res.partner'].create({'name': 'Test Klant'})
                self.equip = self.env['techniecool.equipment'].create({
                  'name': 'Test Oven', 'owner_id': self.partner.id
                })
    
                def test_state_machine(self):
                order = self.env['fsm.order'].create({
                  'name': 'Herstel order', 'equipment_id': self.equip.id
                })
                self.assertEqual(order.state, 'draft')
                order.action_confirm()
                self.assertEqual(order.state, 'confirmed')</pre>
                  
    ⚙ Praktijkopdracht

    Schrijf 10 backend tests voor techniecool_service. Test alle statusovergangen van een interventie, de automatische creatie van een stock picking en de berekening van de garantie-status op het equipment model. Voer de tests uit met odoo-bin --test-enable.

    H12
    Module Publiceren & OCA Bijdragen
    // readme.rst, app store, open source workflow
    Week 28 · 8u

    Je module is klaar voor de wereld. We leren hoe we professionele documentatie schrijven, hoe we de module voorbereiden voor de Odoo App Store en hoe we kunnen bijdragen aan de OCA (Odoo Community Association). Dit is je visitekaartje als Odoo developer. We sluiten het vak af met een volledige release van de TechniCool module op GitHub.

    12.1 OCA Contributie Workflow

    De Odoo Community Association (OCA) beheert open source Odoo-modules op GitHub. Om bij te dragen fork je het repository, maak je een feature branch en open je een Pull Request met je module. Alle OCA-modules volgen strenge kwaliteitseisen: tests, README.rst, linting. TechniCool's open source FSM-extensies worden gecontribueerd aan de OCA.

    # OCA contributie flow
    # 1. Fork het OCA repository
    # 2. Clone je fork
    git clone https://github.com/jouw_username/OCAfieldservice.git
    # 3. Maak feature branch
    git checkout -b 17.0-add-techniecool-equipment-fields
    # 4. Schrijf code + tests
    # 5. Push en open PR
    git push origin 17.0-add-techniecool-equipment-fields</pre>
    ✏ Mini-oefening

    Wat is de verplichte branch-naamconventie voor OCA pull requests?

    👁 Toon oplossing
    # VERSIE-beschrijving # Voorbeeld: 17.0-add-equipment-category # De Odoo-versie (17.0) staat altijd vooraan
    12.2 README.rst Schrijven

    Elke OCA-module heeft een README.rst met beschrijving, installatie-instructies en gebruik-voorbeelden. Gebruik de OCA's oca-gen-addons-table tool om de README automatisch te genereren op basis van de manifest. Voor TechniCool beschrijft de README de integratie met FSM en de vereiste configuratiestappen.

    # README.rst structuur
    ================
    TechniCool Base
    ================
    
    .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
    
    Beschrijving
    ============
    Professionele keukentoestellen beheer voor TechniCool NV.
    
    Installatie
    ===========
    #. Installeer de module via Settings > Apps
    
    Gebruik
    =======
    Navigeer naar TechniCool > Toestellen</pre>
    ✏ Mini-oefening

    Welk tool genereert automatisch een OCA-conforme README?

    👁 Toon oplossing
    # oca-gen-addons-table # of: setuptools-odoo voor package metadata pip install setuptools-odoo setuptools-odoo-make-default-setup
    12.3 PyPI Publiceren

    Odoo-modules kunnen gepubliceerd worden op PyPI via het setuptools-odoo-pakket. Dit maakt installatie via pip install mogelijk. Voor TechniCool's open source extensies maakt dit installatie in containeromgevingen eenvoudiger. Een setup.py of pyproject.toml configureren de package-metadata.

    # setup.py
    from setuptools import setup
    from setuptools_odoo import make_default_setup
    setup(**make_default_setup())
    
    # Publiceren op PyPI:
    pip install build twine
    python -m build
    twine upload dist/*</pre>
    ✏ Mini-oefening

    Hoe installeer je een Odoo-module via pip na publicatie op PyPI?

    👁 Toon oplossing
    pip install odoo14-addon-techniecool-base # of voor Odoo 17: pip install odoo-addon-techniecool-base
    12.4 Odoo Apps Store

    De Odoo Apps Store (apps.odoo.com) is de officiële marktplaats voor Odoo-modules. Je publiceert een module door een ZIP-bestand te uploaden met de vereiste screenshots, beschrijving en prijsstelling. Voor commerciële TechniCool-extensies kan dit een inkomstenstroom zijn naast de service- en implementatieactiviteiten.

    # Vereisten voor Apps Store publicatie:
    # 1. Geldige manifest met summary, description, category
    # 2. Minimum 1 screenshot (640x480)
    # 3. Odoo SA partner account
    # 4. Module getest op de target Odoo-versie
    
    # In __manifest__.py:
    'images': ['static/description/banner.png'],
    'website': 'https://techniecool.be',
    'live_test_url': 'https://demo.techniecool.be',</pre>
    ✏ Mini-oefening

    Wat is de minimale afmeting van een Apps Store screenshot?

    👁 Toon oplossing
    # 640x480 pixels - aanbevolen: 1280x960 # Banner: 1120x512 pixels
    12.5 Changelog & Versiebeheer

    Een duidelijke changelog communiceert wijzigingen aan gebruikers en beheerders. Volg Semantic Versioning: MAJOR voor breaking changes, MINOR voor nieuwe features, PATCH voor bugfixes. OCA gebruikt het formaat ODOO_VERSION.MAJOR.MINOR.PATCH. Voor TechniCool bewaren we de changelog in HISTORY.rst.

    # HISTORY.rst
    17.0.1.2.0 (2024-03-15)
    ~~~~~~~~~~~~~~~~~~~~~~~
    **Features**
    - Vanventory camera scan integration
    - Bulk warranty update wizard
    
    17.0.1.1.0 (2024-02-01)
    ~~~~~~~~~~~~~~~~~~~~~~~
    **Bug fixes**
    - Fix warranty expiry computation for leap years
    - Fix SLA breach detection timezone handling</pre>
    ✏ Mini-oefening

    Wanneer verhoog je het MAJOR-versienummer in een Odoo-module?

    👁 Toon oplossing
    # Bij breaking changes: # - Model/veldnamen gewijzigd # - API-wijzigingen die andere modules breken # - Vereiste datamigratiesscripts # Niet bij nieuwe features (MINOR)
    12.6 Module Afhankelijkheden Beheren

    Externe afhankelijkheden (OCA-modules, Python-packages) beheer je via de manifest en een requirements.txt. Voor TechniCool zijn OCA fieldservice en stock verplicht. Python-packages zoals jsqr of leaflet worden als npm-packages beheerd en gebundeld via het Odoo-assetsysteem.

    # In __manifest__.py
    'depends': ['fieldservice', 'stock', 'mail', 'portal'],
    'external_dependencies': {
        'python': ['pillow', 'qrcode'],
        'bin': ['wkhtmltopdf'],
    },
    
    # requirements.txt
    pillow>=9.0
    qrcode>=7.0
    requests>=2.28</pre>
    ✏ Mini-oefening

    Hoe geef je aan dat een module een extern Python-pakket nodig heeft in de manifest?

    👁 Toon oplossing
    'external_dependencies': { 'python': ['requests', 'pillow'] }
    12.7 Lokale Module Repository

    Voor enterprise Odoo-installaties gebruik je een privé module-repository via een privé PyPI-server (bijv. devpi) of een interne OCA-compatibele git-server. Voor TechniCool hosten we alle eigen modules op een GitLab-server met automatische CI/CD die bij elke tag een nieuwe versie publiceert naar de interne PyPI-server.

    # Installeren vanuit privé git repo
    pip install git+https://gitlab.techniecool.be/odoo-modules/techniecool-base.git@17.0.1.2.0
    
    # Of met devpi privé PyPI:
    pip install --index-url https://pypi.techniecool.be/simple/ \
        odoo-addon-techniecool-base</pre>
    ✏ Mini-oefening

    Hoe pin je een exacte moduleversie in een Odoo Docker-image?

    👁 Toon oplossing
    # In Dockerfile: RUN pip install odoo-addon-techniecool-base==17.0.1.2.0
    12.8 Sponsoren & OCA Governance

    OCA-modules worden beheerd door een PSC (Project Steering Committee). Als contributor van TechniCool kun je actief bijdragen aan het Field Service PSC. Dit geeft toegang tot de roadmap-discussies, releases en merge-rechten. Bedrijven die intensief gebruik maken van OCA-modules worden aangemoedigd te sponsoren.

    # OCA Contributiepunten verdienen door:
    # 1. Pull Requests mergen
    # 2. Code reviews doen
    # 3. Bug reports en fixes
    # 4. Documentatie schrijven
    # 5. Financieel sponsoren
    
    # PSC lidmaatschap: min. 6 maanden actieve bijdrage
    # Info: https://odoo-community.org</pre>
    ✏ Mini-oefening

    Wat is het voordeel van OCA PSC-lidmaatschap voor TechniCool?

    👁 Toon oplossing
    # Directe invloed op de roadmap van # Field Service en andere relevante modules. # Merge-rechten versnellen het accepteren # van TechniCool-specifieke features.
    Vak 4 · Weken 37–44 · 64 contacturen

    AI Integratie

    We transformeren de TechniCool workflow met AI. Van automatische onderdeelherkenning via Vision tot een intelligente dispatcher assistent.

    8
    Weken
    64u
    Les
    Vision
    LLM
    Cursusinhoud
    Hoofdstukken
    H1
    AI APIs & Odoo Connectiviteit
    // openai, api keys, request handling, json-rpc
    Week 37 · 8u

    AI in Odoo begint bij de connectie met externe modellen. We leren hoe we veilig praten met de OpenAI API vanuit Python. Je leert hoe je API keys beheert in Odoo's 'System Parameters', hoe je robuuste error-handling schrijft voor netwerkfouten en hoe je de asynchrone aard van AI requests beheert binnen de synchrone Odoo ORM flow.

    1.1 Wat is een LLM API?

    Large Language Models (LLMs) zijn neurale netwerken getraind op enorme tekstcorpora. Via een REST API stuur je een prompt en ontvang je een tekstueel antwoord. Voor TechniCool NV gebruiken we dit om technische handleidingen te samenvatten en servicerapporten automatisch te genereren op basis van storingscodes. De meest gebruikte providers zijn OpenAI (GPT-4), Anthropic (Claude) en Google (Gemini).

    import requests
    
    API_KEY = 'sk-...'
    URL = 'https://api.openai.com/v1/chat/completions'
    
    resp = requests.post(URL,
        headers={'Authorization': f'Bearer {API_KEY}'},
        json={
            'model': 'gpt-4o-mini',
            'messages': [
                {'role': 'system', 'content': 'Je bent een technische assistent voor keukenapparatuur.'},
                {'role': 'user', 'content': 'Leg uit wat foutcode E32 op een Rational SelfCookingCenter betekent.'}
            ]
        }
    )
    print(resp.json()['choices'][0]['message']['content'])</pre>
    ✏ Mini-oefening

    Stuur een prompt naar de OpenAI API met jouw eigen API-sleutel en vraag een diagnose voor foutcode F15.

    👁 Toon oplossing
    resp = requests.post(URL, headers={'Authorization': f'Bearer {API_KEY}'}, json={'model': 'gpt-4o-mini', 'messages': [ {'role': 'user', 'content': 'Diagnose foutcode F15 Electrolux koeling'} ]}) print(resp.json()['choices'][0]['message']['content'])
    1.2 Anthropic Claude API

    Anthropic biedt de Claude API aan met een vergelijkbaar chatpatroon maar eigen SDK. Claude is bijzonder sterk in technisch redeneren en het volgen van complexe instructies. TechniCool NV kan Claude inzetten voor het automatisch opstellen van offertes op basis van toestelhistoriek en onderdelen. De `anthropic` Python-bibliotheek vereenvoudigt authenticatie en streaming.

    import anthropic
    
    client = anthropic.Anthropic(api_key='sk-ant-...')
    
    message = client.messages.create(
        model='claude-3-5-sonnet-20241022',
        max_tokens=1024,
        system='Je bent een expert in professionele keukenapparatuur voor TechniCool NV.',
        messages=[
            {'role': 'user',
             'content': 'Genereer een offerte-samenvatting voor een Rational SelfCookingCenter revisie.'}
        ]
    )
    print(message.content[0].text)</pre>
    ✏ Mini-oefening

    Gebruik de Anthropic SDK om een onderhoudsadvies te genereren voor een Electrolux blast chiller met 5000 draaiuren.

    👁 Toon oplossing
    msg = client.messages.create( model='claude-3-5-sonnet-20241022', max_tokens=512, messages=[{'role': 'user', 'content': 'Onderhoudsadvies Electrolux blast chiller, 5000 draaiuren'}] ) print(msg.content[0].text)
    1.3 Tokens & Kosten Beheren

    LLM-providers rekenen per token aan — een token is ruwweg 4 tekens in het Engels. Een langere system prompt of context verhoogt de kosten bij elke API-aanroep. TechniCool NV moet tokens bewust budgetteren: een servicerapport-generator met 500 tokens output kost bij GPT-4o circa 0,0075 USD per oproep. Gebruik `max_tokens` om kosten te beheersen en monitor verbruik via de provider-dashboard.

    import tiktoken
    
    enc = tiktoken.encoding_for_model('gpt-4o')
    
    prompt = 'Analyseer de staat van de compressor in toestel SN-2024-001'
    tokens = enc.encode(prompt)
    print(f'Tokens: {len(tokens)}')
    
    # Kostenschatting
    INPUT_COST_PER_1K = 0.005   # USD
    OUTPUT_COST_PER_1K = 0.015
    estimated_input_cost = len(tokens) / 1000 * INPUT_COST_PER_1K
    print(f'Geschatte inputkost: ${estimated_input_cost:.6f}')</pre>
    ✏ Mini-oefening

    Tel het aantal tokens in een volledige servicerapport-prompt van 200 woorden en bereken de kostprijs.

    👁 Toon oplossing
    prompt = 'Verslag: ' + 'technische term ' * 50 tokens = enc.encode(prompt) cost = len(tokens) / 1000 * 0.005 print(f'{len(tokens)} tokens, ${cost:.5f}')
    1.4 Streaming Responses

    Streaming laat toe om LLM-output te tonen terwijl hij nog gegenereerd wordt, net zoals ChatGPT doet. Dit verbetert de gebruikerservaring aanzienlijk bij langere antwoorden. In de TechniCool-portal kan streaming gebruikt worden voor real-time diagnoseverslagen. Beide OpenAI en Anthropic ondersteunen streaming via server-sent events (SSE).

    import anthropic
    
    client = anthropic.Anthropic(api_key='sk-ant-...')
    
    with client.messages.stream(
        model='claude-3-5-sonnet-20241022',
        max_tokens=512,
        messages=[{'role': 'user', 'content': 'Schrijf een uitgebreid diagnoseverslag voor toestel SN-001.'}]
    ) as stream:
        for text in stream.text_stream:
            print(text, end='', flush=True)</pre>
    ✏ Mini-oefening

    Implementeer streaming voor een OpenAI GPT-4o aanroep die een onderhoudsplan uitschrijft.

    👁 Toon oplossing
    import openai client = openai.OpenAI(api_key='sk-...') for chunk in client.chat.completions.create( model='gpt-4o-mini', stream=True, messages=[{'role': 'user', 'content': 'Onderhoudsplan combi-steamer'}]): print(chunk.choices[0].delta.content or '', end='')
    1.5 System Prompts & Few-Shot

    Een system prompt bepaalt de persoonlijkheid en focus van het model voor de hele conversatie. Few-shot voorbeelden in de context sturen het model naar het gewenste outputformaat. TechniCool gebruikt few-shot om de AI te leren hoe een Odoo-servicebonnen-notitie eruitziet. Goed ontworpen prompts verminderen post-processing en verbeteren consistentie drastisch.

    messages = [
        {'role': 'system', 'content': 'Jij genereert beknopte Odoo-servicebon-notities voor TechniCool NV. Gebruik steeds: Toestel, Klacht, Diagnose, Actie, Onderdelen.'},
        {'role': 'user', 'content': 'Voorbeeld:\nToestel: Rational SCC61\nKlacht: Stoom onvoldoende\nDiagnose: Kalkaanslag stoomgenerator\nActie: Ontkalkingsprogramma uitgevoerd\nOnderdelen: geen'},
        {'role': 'assistant', 'content': 'Begrepen. Geef het volgende geval.'},
        {'role': 'user', 'content': 'Toestel: Electrolux AOCP102C, Klacht: Koeling bereikt temp niet, ...'},
    ]</pre>
    ✏ Mini-oefening

    Schrijf een few-shot prompt die de AI leert om een samenvatting in maximaal 3 zinnen te produceren.

    👁 Toon oplossing
    msgs = [ {'role': 'system', 'content': 'Vat technische klachten samen in max 3 zinnen.'}, {'role': 'user', 'content': 'Probleem: ...(lang verhaal)...'}, ]
    1.6 Odoo Integratie — AI Actie

    In Odoo 17 kan je een server action koppelen aan een knop die een LLM-aanroep triggert. De resulterende tekst wordt opgeslagen in een tekstveld van het record, zoals `ai_diagnosis`. TechniCool NV gebruikt dit om op een servicebon automatisch een eerste diagnose voor te stellen. De actie wordt geconfigureerd in de form view als een `

    1.7 Prompt Templating met Jinja2

    Jinja2 is de standaard template-engine in Python en Odoo. Je gebruikt hem om dynamische prompts te bouwen op basis van record-velden. Voor TechniCool NV renderen we een prompt-template met toestelnaam, serienummer en klachtenhistoriek. Dit houdt de Python-code overzichtelijk en maakt prompts makkelijk aanpasbaar.

    from jinja2 import Template
    
    PROMPT_TMPL = Template("""
    Je bent een expert voor TechniCool NV.
    Toestel: {{ name }} (SN: {{ serial }})
    Leeftijd: {{ age }} jaar
    Klachten: {{ complaints }}
    
    Geef een beknopt diagnose-advies.
    """)
    
    rendered = PROMPT_TMPL.render(
        name='Rational SCC61',
        serial='SN-2019-042',
        age=5,
        complaints='Stoomdruk te laag, kalkvorming'
    )
    print(rendered)</pre>
    ✏ Mini-oefening

    Maak een Jinja2-template voor een offertebrief met `klant`, `toestel` en `prijs` variabelen.

    👁 Toon oplossing
    tmpl = Template('Geachte {{ klant }}, voor toestel {{ toestel }} bedraagt de offerte EUR {{ prijs }}.') print(tmpl.render(klant='Restaurant De Keuken', toestel='Rational', prijs=1250))
    1.8 Rate Limiting & Retry

    LLM APIs leggen rate limits op: maximaal X requests per minuut of Y tokens per dag. Bij overschrijding geeft de API een 429 Too Many Requests terug. TechniCool's batch-diagnoseproces moet hiermee rekening houden via exponential backoff. De `tenacity` bibliotheek maakt retry-logica elegant en configureerbaar.

    import time
    import requests
    from tenacity import retry, stop_after_attempt, wait_exponential
    
    @retry(
        stop=stop_after_attempt(5),
        wait=wait_exponential(multiplier=1, min=2, max=60)
    )
    def call_api(prompt):
        resp = requests.post(
            'https://api.openai.com/v1/chat/completions',
            headers={'Authorization': 'Bearer sk-...'},
            json={'model': 'gpt-4o-mini', 'messages': [{'role': 'user', 'content': prompt}]}
        )
        resp.raise_for_status()
        return resp.json()['choices'][0]['message']['content']
    
    result = call_api('Diagnose Rational SCC61 fout E32')</pre>
    ✏ Mini-oefening

    Voeg een retry-decorator toe die maximaal 3 keer probeert met 5 seconden wachttijd.

    👁 Toon oplossing
    @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=5, max=30)) def safe_call(prompt): resp = requests.post(URL, headers=HEADERS, json={'model': 'gpt-4o-mini', 'messages': [{'role':'user','content':prompt}]}) resp.raise_for_status() return resp.json()['choices'][0]['message']['content']
    import openai
                  from odoo import models, fields, api
    
                  class TechnicoolAIConfig(models.AbstractModel):
                  _name = 'techniecool.ai.base'
    
                  def _get_openai_client(self):
                  api_key = self.env['ir.config_parameter'].sudo().get_param('openai.api_key')
                  return openai.OpenAI(api_key=api_key)
    
                  def _call_ai(self, prompt, system_msg=""):
                  client = self._get_openai_client()
                  response = client.chat.completions.create(
                    model="gpt-4o",
                    messages=[
                        {"role": "system", "content": system_msg},
                        {"role": "user", "content": prompt}
                    ]
                  )
                  return response.choices[0].message.content</pre>
                    
    ⚙ Praktijkopdracht

    Configureer de OpenAI API in Odoo. Maak een 'Test AI' knop op het res.users formulier die een simpele begroeting vraagt aan GPT-4 en het antwoord toont in een Odoo notificatie (sticky message).

    H2
    Vision AI — Onderdeelherkenning
    // gpt-4o vision, base64, image analysis
    Week 38 · 8u

    Een technieker weet soms niet exact welk wisselstuk hij in zijn handen heeft. We gebruiken Vision AI om foto's van onderdelen te analyseren. Je leert hoe je afbeeldingen uit Odoo (`ir.attachment`) omzet naar base64, deze doorstuurt naar het vision model en de AI vraagt om het onderdeel te identificeren op basis van onze eigen productcatalogus.

    2.1 Vision API Basis

    Vision AI-modellen kunnen afbeeldingen analyseren en beschrijven in tekst. GPT-4o en Claude 3 accepteren base64-gecodeerde afbeeldingen in de message payload. TechniCool NV gebruikt dit om foto's van defecte apparatuur automatisch te laten analyseren. Een technicus maakt een foto van een lek of beschadigde onderdeel, en de AI geeft een diagnose.

    import base64, requests
    
    with open('defect.jpg', 'rb') as f:
        img_b64 = base64.b64encode(f.read()).decode()
    
    resp = requests.post(
        'https://api.openai.com/v1/chat/completions',
        headers={'Authorization': 'Bearer sk-...'},
        json={
            'model': 'gpt-4o',
            'messages': [{
                'role': 'user',
                'content': [
                    {'type': 'text', 'text': 'Analyseer dit defect aan keukenapparatuur.'},
                    {'type': 'image_url',
                     'image_url': {'url': f'data:image/jpeg;base64,{img_b64}'}}
                ]
            }]
        }
    )
    print(resp.json()['choices'][0]['message']['content'])</pre>
    ✏ Mini-oefening

    Stuur een foto van een kapotte stekker naar GPT-4o en vraag om een veiligheidsadvies.

    👁 Toon oplossing
    # Zelfde patroon, andere prompt: content = [{'type':'text','text':'Geef veiligheidsadvies voor dit defect.'}, {'type':'image_url','image_url':{'url':f'data:image/jpeg;base64,{img_b64}'}}]
    2.2 Claude Vision

    Anthropic Claude ondersteunt vision via de `image` content block in messages. Je geeft het mediatype op (`image/jpeg`, `image/png`) en de base64-data apart mee. TechniCool NV kan hiermee systematisch foto's van onderhoudsbeurten digitaliseren. Claude is bijzonder nauwkeurig bij het lezen van serieplaten en specificatielabels.

    import anthropic, base64
    
    client = anthropic.Anthropic(api_key='sk-ant-...')
    
    with open('serieplaat.jpg', 'rb') as f:
        data = base64.standard_b64encode(f.read()).decode()
    
    msg = client.messages.create(
        model='claude-3-5-sonnet-20241022',
        max_tokens=512,
        messages=[{
            'role': 'user',
            'content': [
                {'type': 'image',
                 'source': {'type': 'base64', 'media_type': 'image/jpeg', 'data': data}},
                {'type': 'text', 'text': 'Lees het serienummer en bouwjaar van deze serieplaat.'}
            ]
        }]
    )
    print(msg.content[0].text)</pre>
    ✏ Mini-oefening

    Gebruik Claude Vision om het vermogen (kW) van een toestel te lezen van een foto van het typeplaatje.

    👁 Toon oplossing
    msg = client.messages.create(model='claude-3-5-sonnet-20241022', max_tokens=256, messages=[{'role':'user','content':[ {'type':'image','source':{'type':'base64','media_type':'image/jpeg','data':data}}, {'type':'text','text':'Wat is het elektrisch vermogen in kW van dit toestel?'} ]}]) print(msg.content[0].text)
    2.3 Foto Upload in OWL

    In de TechniCool PWA laat je de technicus een foto nemen met de camera of uploaden uit de galerij. OWL gebruikt `` voor camera-toegang. Via FileReader API zet je de afbeelding om naar base64 voor de API-aanroep. Het resultaat wordt getoond als een AI-diagnose-banner in de servicebon-view.

    // OWL component
    import { Component, useState } from '@odoo/owl';
    
    export class PhotoDiagnose extends Component {
        static template = 'techniecool.PhotoDiagnose';
        setup() { this.state = useState({ diagnosis: '', loading: false }); }
    
        async onPhotoChange(ev) {
            const file = ev.target.files[0];
            const reader = new FileReader();
            reader.onload = async (e) => {
                const b64 = e.target.result.split(',')[1];
                this.state.loading = true;
                const res = await this.env.services.rpc('/api/ai/vision', { image: b64 });
                this.state.diagnosis = res.diagnosis;
                this.state.loading = false;
            };
            reader.readAsDataURL(file);
        }
    }</pre>
    ✏ Mini-oefening

    Voeg een laadspinner toe die zichtbaar is terwijl `this.state.loading` true is.

    👁 Toon oplossing
    // In QWeb template: //
    Analyseren...
    //
    2.4 Odoo Controller voor Vision

    De OWL-component stuurt de base64-afbeelding naar een Odoo JSON-controller. De controller roept de Vision API aan en retourneert de diagnose. TechniCool NV slaat de AI-diagnose ook op in het servicebon-record voor traceerbaarheid. Zo blijft alle communicatie via de vertrouwde Odoo-backend en is de API-sleutel nooit blootgesteld aan de browser.

    from odoo import http
    from odoo.http import request
    import requests, base64
    
    class VisionController(http.Controller):
    
        @http.route('/api/ai/vision', type='json', auth='user', methods=['POST'])
        def vision_diagnose(self, image, **kw):
            api_key = request.env['ir.config_parameter'].sudo().get_param('openai.api_key')
            resp = requests.post(
                'https://api.openai.com/v1/chat/completions',
                headers={'Authorization': f'Bearer {api_key}'},
                json={'model': 'gpt-4o',
                      'messages': [{'role': 'user', 'content': [
                          {'type': 'text', 'text': 'Diagnosticeer dit apparaatdefect voor TechniCool NV.'},
                          {'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{image}'}}
                      ]}]}
            )
            diagnosis = resp.json()['choices'][0]['message']['content']
            return {'diagnosis': diagnosis}</pre>
    ✏ Mini-oefening

    Voeg toe dat de diagnose opgeslagen wordt in `techniecool.workorder` model via `request.env`.

    👁 Toon oplossing
    wo_id = kw.get('workorder_id') if wo_id: request.env['techniecool.workorder'].browse(wo_id).write({'ai_diagnosis': diagnosis})
    2.5 Afbeelding Resize voor API

    Vision APIs hebben limieten op de afbeeldingsgrootte — doorgaans 20 MB en maximaal 2000x2000 pixels. Grote foto's moeten verkleind worden voor verzending om kosten en latentie te beperken. TechniCool-technici nemen foto's met smartphones van 12 MP, dus resizing is verplicht. Python Pillow is de standaardbibliotheek voor serverside beeldbewerking.

    from PIL import Image
    import io, base64
    
    def resize_for_api(path, max_side=1024):
        img = Image.open(path)
        img.thumbnail((max_side, max_side), Image.LANCZOS)
        buf = io.BytesIO()
        img.save(buf, format='JPEG', quality=85)
        return base64.b64encode(buf.getvalue()).decode()
    
    b64 = resize_for_api('grote_foto.jpg')
    print(f'Base64 lengte: {len(b64)} tekens')</pre>
    ✏ Mini-oefening

    Pas `resize_for_api` aan zodat hij een PIL Image object accepteert in plaats van een bestandspad.

    👁 Toon oplossing
    def resize_img_obj(img_obj, max_side=1024): img_obj.thumbnail((max_side, max_side), Image.LANCZOS) buf = io.BytesIO() img_obj.save(buf, format='JPEG', quality=85) return base64.b64encode(buf.getvalue()).decode()
    2.6 Structured Output van Vision

    Je kunt de Vision API instrueren om gestructureerde JSON te retourneren via de system prompt. Dit maakt het eenvoudig om velden zoals `defect_type`, `urgentie` en `aanbevolen_actie` te extraheren. TechniCool NV gebruikt gestructureerde output om automatisch velden in het servicebon in te vullen. OpenAI ondersteunt ook officieel `response_format: {type: 'json_object'}` voor JSON-guarantee.

    import json, requests
    
    resp = requests.post(
        'https://api.openai.com/v1/chat/completions',
        headers={'Authorization': 'Bearer sk-...'},
        json={
            'model': 'gpt-4o',
            'response_format': {'type': 'json_object'},
            'messages': [
                {'role': 'system', 'content': 'Retourneer enkel JSON met velden: defect_type, urgentie (1-5), aanbevolen_actie.'},
                {'role': 'user', 'content': [{'type': 'text', 'text': 'Analyseer.'}, {'type': 'image_url', 'image_url': {'url': 'data:image/jpeg;base64,...'}}]}
            ]
        }
    )
    data = json.loads(resp.json()['choices'][0]['message']['content'])
    print(data['urgentie'])</pre>
    ✏ Mini-oefening

    Voeg een veld `geschatte_kostprijs_eur` toe aan de gevraagde JSON-structuur.

    👁 Toon oplossing
    system = 'Retourneer JSON: defect_type, urgentie (1-5), aanbevolen_actie, geschatte_kostprijs_eur.' # Gebruik response_format: {'type': 'json_object'}
    2.7 Batch Verwerking van Foto's

    Bij een preventief onderhoudsbezoek neemt een technicus tientallen foto's van een installatie. Batch-verwerking stuurt alle foto's asynchroon naar de API voor parallelle analyse. Python `asyncio` en `aiohttp` maken dit efficient zonder de server te blokkeren. TechniCool NV slaat alle resultaten op in een onderhoudsverslag-record in Odoo.

    import asyncio, aiohttp, base64
    
    async def analyse_foto(session, path, api_key):
        with open(path, 'rb') as f:
            b64 = base64.b64encode(f.read()).decode()
        async with session.post(
            'https://api.openai.com/v1/chat/completions',
            headers={'Authorization': f'Bearer {api_key}'},
            json={'model': 'gpt-4o-mini', 'messages': [{'role': 'user', 'content': [
                {'type': 'text', 'text': 'Diagnosticeer kort.'},
                {'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{b64}'}}
            ]}]}
        ) as resp:
            data = await resp.json()
            return path, data['choices'][0]['message']['content']
    
    async def batch(photos, api_key):
        async with aiohttp.ClientSession() as session:
            tasks = [analyse_foto(session, p, api_key) for p in photos]
            return await asyncio.gather(*tasks)
    
    results = asyncio.run(batch(['foto1.jpg', 'foto2.jpg'], 'sk-...'))</pre>
    ✏ Mini-oefening

    Beperk het aantal gelijktijdige API-aanroepen tot 3 via `asyncio.Semaphore`.

    👁 Toon oplossing
    sem = asyncio.Semaphore(3) async def rate_limited(session, path, key): async with sem: return await analyse_foto(session, path, key)
    2.8 Privacy & GDPR bij Vision AI

    Foto's van klantlocaties kunnen persoonsgegevens bevatten (gezichten, namen op borden). GDPR vereist dat je deze data niet zonder toestemming naar externe API-providers stuurt. TechniCool NV voorziet een dataverwerkingsovereenkomst (DPA) met de AI-provider en anonimiseert indien nodig. Alternatief: gebruik een lokaal gehoste Vision AI zoals LLaVA via Ollama om data op eigen servers te houden.

    # Lokale Vision AI via Ollama (geen externe datatransfer)
    import requests
    
    resp = requests.post(
        'http://localhost:11434/api/generate',
        json={
            'model': 'llava',
            'prompt': 'Analyseer dit apparaatdefect.',
            'images': [''],
            'stream': False
        }
    )
    print(resp.json()['response'])</pre>
    ✏ Mini-oefening

    Installeer Ollama lokaal en test de LLaVA-model met een foto van een keukenapparaat.

    👁 Toon oplossing
    # Terminalcommando: # ollama pull llava # ollama run llava # Python: resp = requests.post('http://localhost:11434/api/generate', json={'model':'llava','prompt':'Diagnose?','images':[b64],'stream':False}) print(resp.json()['response'])
    # Vision AI call voor TechniCool onderdelen
    def identify_part(self, attachment_id, equipment_context=None):
        attachment = self.env['ir.attachment'].browse(attachment_id)
        base64_image = attachment.datas.decode('utf-8')
        
        # Dynamische prompt op basis van context
        system_prompt = "Je bent een expert in professionele keukentoestellen (Rational, MKN)."
        prompt = """
        Analyseer deze foto van een wisselstuk. 
        Kijk naar de vorm, kleur en eventuele nummers.
        Geef je antwoord terug als een JSON object:
        {
          "product_code": "...",
          "description": "...",
          "confidence": 0.95
        }
        """
        
        if equipment_context:
            prompt += f"\nExtra context: Technieker werkt aan een {equipment_context}."
    
        client = self._get_openai_client()
        try:
            response = client.chat.completions.create(
                model="gpt-4o",
                messages=[{
                    "role": "user",
                    "content": [
                        {"type": "text", "text": prompt},
                        {"type": "image_url", "image_url": {
                            "url": f"data:image/jpeg;base64,{base64_image}",
                            "detail": "high"
                        }}
                    ]
                }],
                response_format={"type": "json_object"}
            )
            return json.loads(response.choices[0].message.content)
        except Exception as e:
            return {"error": str(e)}</pre>
                    
    def identify_part(self, attachment_id): attachment = self.env['ir.attachment'].browse(attachment_id) base64_image = attachment.datas.decode('utf-8') prompt = "Welk TechniCool onderdeel is dit? Geef enkel de product_code terug." client = self._get_openai_client() response = client.chat.completions.create( model="gpt-4o", messages=[{ "role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": { "url": f"data:image/jpeg;base64,{base64_image}" }} ] }] ) return response.choices[0].message.content
    ⚙ Praktijkopdracht

    Bouw een wizard "Onderdeel Scannen". De technieker uploadt een foto, de wizard stuurt deze naar Vision AI en toont het resultaat ("Dit is waarschijnlijk een Rational Deurrubber 40.00.091"). Voeg een knop toe "Toevoegen aan werkbon" die de matching bevestigt.

    H3
    Embeddings & Semantisch Zoeken
    // vector database, text-embedding-3, similarity
    Week 39 · 8u

    Oude interventieverslagen zijn een goudmijn aan informatie. We leren hoe we tienduizenden oude TechniCool rapporten omzetten naar 'Vectors' (embeddings). Je leert hoe semantisch zoeken werkt: een technieker typt "oven wordt niet warm" en het systeem vindt verslagen over defecte verwarmingselementen, zelfs als die exacte woorden niet in de tekst staan.

    3.1 Wat zijn Embeddings?

    Embeddings zijn vectorrepresentaties van tekst — een lijst van drijvende getallen (bijv. 1536 dimensies). Gelijkaardige teksten hebben embeddings die dicht bij elkaar liggen in de vectorruimte. TechniCool NV kan hiermee technische handleidingen semantisch doorzoekbaar maken: typ 'stoom probleem' en vind documenten over 'dampgenerator defect' zonder exacte woordovereenkomst. OpenAI biedt `text-embedding-3-small` aan voor 0,00002 USD per 1000 tokens.

    import openai
    
    client = openai.OpenAI(api_key='sk-...')
    
    response = client.embeddings.create(
        model='text-embedding-3-small',
        input='De stoomgenerator van de Rational SCC61 geeft onvoldoende druk.'
    )
    
    vector = response.data[0].embedding
    print(f'Dimensies: {len(vector)}')
    print(f'Eerste 5 waarden: {vector[:5]}')</pre>
    ✏ Mini-oefening

    Genereer embeddings voor 3 technische zinnen en vergelijk de vectorlengtes.

    👁 Toon oplossing
    texts = ['Kalkvorming in boiler', 'Compressor defect', 'Deur sluit niet'] for t in texts: r = client.embeddings.create(model='text-embedding-3-small', input=t) print(f'{t}: {len(r.data[0].embedding)} dims')
    3.2 Cosinus Similariteit

    Cosinus similariteit meet de hoek tussen twee vectoren — 1.0 betekent identiek, 0 betekent ongerelateerd. Het is de standaardmethode om twee embeddings te vergelijken. TechniCool NV gebruikt dit om de meest relevante handleidingparagraaf te vinden bij een klacht. NumPy maakt de berekening efficiënt en leesbaar.

    import numpy as np
    
    def cosine_similarity(a, b):
        a, b = np.array(a), np.array(b)
        return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
    
    # Vergelijk query met documenten
    query_vec = [0.1, 0.9, 0.3]  # vereenvoudigd voorbeeld
    doc_vecs = [
        [0.1, 0.85, 0.35],  # gerelateerd
        [0.9, 0.1, 0.0],    # niet gerelateerd
    ]
    for i, dv in enumerate(doc_vecs):
        print(f'Doc {i}: similariteit = {cosine_similarity(query_vec, dv):.4f}')</pre>
    ✏ Mini-oefening

    Zoek in een lijst van 5 vectoren de twee meest gelijkaardige paren.

    👁 Toon oplossing
    pairs = [(i,j, cosine_similarity(vecs[i], vecs[j])) for i in range(len(vecs)) for j in range(i+1, len(vecs))] best = sorted(pairs, key=lambda x: x[2], reverse=True)[0] print(f'Meest gelijkaardig: doc {best[0]} en doc {best[1]}, score {best[2]:.4f}')
    3.3 Vector Database met ChromaDB

    ChromaDB is een open-source vector database die embeddings opslaat en snel doorzoekt. Je indexeert documenten eenmalig en bevraagt ze daarna snel op semantische gelijkenis. TechniCool NV indexeert alle servicebonnenteksten voor intelligente zoekfunctionaliteit. ChromaDB draait in-process of als server en heeft geen externe cloud-afhankelijkheid.

    import chromadb
    
    client = chromadb.Client()
    collection = client.create_collection('techniecool_docs')
    
    # Documenten indexeren
    collection.add(
        documents=[
            'Kalkaanslag stoomgenerator Rational SCC61 verwijderen met citroenzuuroplossing.',
            'Compressor Electrolux blast chiller vervangen na 8000 draaiuren.',
            'Deur Rational XS niet sluiten: controleer pakking en scharnier.'
        ],
        ids=['doc1', 'doc2', 'doc3']
    )
    
    # Semantisch zoeken
    results = collection.query(
        query_texts=['stoom kalk probleem'],
        n_results=2
    )
    print(results['documents'])</pre>
    ✏ Mini-oefening

    Voeg 5 eigen technische zinnen toe aan de collectie en zoek op 'koeling defect'.

    👁 Toon oplossing
    collection.add(documents=['Koeling verliest gas.','Verdamper bevroren.','Thermostaatfout koeling.','Condensor vervuild.','Koelmiddellekkage.'], ids=[f'd{i}' for i in range(5)]) print(collection.query(query_texts=['koeling defect'], n_results=2)['documents'])
    3.4 RAG — Retrieval Augmented Generation

    RAG combineert semantisch zoeken met een LLM: zoek relevante documenten, geef ze mee als context. Dit is veel goedkoper dan het model fine-tunen en houdt de kennis up-to-date. TechniCool NV bouwt een RAG-systeem over de technische documentatie van Rational en Electrolux. De technicus stelt een vraag in natuurlijke taal en krijgt een antwoord gebaseerd op de echte manualen.

    import chromadb, openai
    
    chroma = chromadb.Client()
    col = chroma.get_collection('techniecool_docs')
    oai = openai.OpenAI(api_key='sk-...')
    
    def rag_answer(question):
        results = col.query(query_texts=[question], n_results=3)
        context = '\n'.join(results['documents'][0])
        messages = [
            {'role': 'system', 'content': f'Gebruik enkel deze context:\n{context}'},
            {'role': 'user', 'content': question}
        ]
        resp = oai.chat.completions.create(model='gpt-4o-mini', messages=messages)
        return resp.choices[0].message.content
    
    print(rag_answer('Hoe verwijder ik kalkaanslag uit een Rational combi-steamer?'))</pre>
    ✏ Mini-oefening

    Breid het RAG-systeem uit zodat het ook de bron-document-ID's toont in het antwoord.

    👁 Toon oplossing
    def rag_with_sources(q): res = col.query(query_texts=[q], n_results=3) ctx = '\n'.join(res['documents'][0]) ids = res['ids'][0] answer = rag_answer(q) return answer, ids
    3.5 Embeddings in Odoo Opslaan

    Odoo heeft geen ingebouwde vector-opslag, maar je kunt embeddings opslaan als JSON in een tekstveld. Voor kleine datasets (< 10.000 documenten) werkt dit prima met Python-gebaseerde zoeklogica. TechniCool NV slaat embeddings op per servicebon zodat gerelateerde bonnen voorgesteld worden. Voor grote schaal gebruik je een externe vector DB (ChromaDB, Qdrant, Pinecone) via een microservice.

    import json
    from odoo import models, fields
    
    class WorkOrder(models.Model):
        _inherit = 'techniecool.workorder'
    
        embedding = fields.Text('Embedding (JSON)')
    
        def compute_embedding(self):
            import openai
            client = openai.OpenAI(api_key=self.env['ir.config_parameter'].sudo().get_param('openai.api_key'))
            text = f'{self.name} {self.description or ""}'
            resp = client.embeddings.create(model='text-embedding-3-small', input=text)
            self.embedding = json.dumps(resp.data[0].embedding)</pre>
    ✏ Mini-oefening

    Schrijf een methode `find_similar_workorders` die de 5 meest gelijkaardige werkorders retourneert.

    👁 Toon oplossing
    def find_similar_workorders(self): import numpy as np, json my_vec = np.array(json.loads(self.embedding)) others = self.search([('id','!=',self.id),('embedding','!=',False)]) scored = sorted(others, key=lambda o: float(np.dot(my_vec, np.array(json.loads(o.embedding)))), reverse=True) return scored[:5]
    3.6 Sentence Transformers (Lokaal)

    Sentence Transformers is een open-source bibliotheek voor lokale embedding-generatie zonder API-kosten. Modellen zoals `all-MiniLM-L6-v2` zijn klein (80 MB) en draaien snel op CPU. TechniCool NV gebruikt dit voor interne tools zonder GDPR-bezwaren over externe API's. De vectorkwaliteit is iets lager dan OpenAI maar voor technische domein-specifieke teksten volstaat het.

    from sentence_transformers import SentenceTransformer
    import numpy as np
    
    model = SentenceTransformer('all-MiniLM-L6-v2')
    
    sentences = [
        'Stoomgenerator kalkaanslag verwijderen.',
        'Compressor vervangen na slijtage.',
        'Koeling verliest koudemiddel.',
    ]
    vectors = model.encode(sentences)
    
    query = model.encode(['probleem met stoom'])
    sims = np.dot(vectors, query.T).flatten()
    beste = sentences[np.argmax(sims)]
    print(f'Meest relevant: {beste}')</pre>
    ✏ Mini-oefening

    Encode 10 technische handleidingzinnen en zoek de meest relevante bij 'deur sluit niet'.

    👁 Toon oplossing
    zinnen = ['Deurpakking versleten','Scharnier kapot','Deur uitgelijn','Magneetslot defect','Afdichting scheuren','Deursensor fout','Frame vervormd','Scharnierpen los','Slotmechanisme','Klikverbinding'] vecs = model.encode(zinnen) q = model.encode(['deur sluit niet']) best = zinnen[np.argmax(np.dot(vecs, q.T))]
    3.7 Hybrid Search — Keyword + Semantic

    Puur semantisch zoeken mist soms exacte treffers (serienummers, modelnamen). Hybrid search combineert BM25 (keyword) en embedding similariteit voor het beste van beide. TechniCool NV gebruikt dit zodat technici zowel op 'SCC61' als op 'stoom probleem' kunnen zoeken. Een gewogen som (bijv. 0.7 * semantic + 0.3 * keyword) combineert de scores.

    from rank_bm25 import BM25Okapi
    import numpy as np
    
    docs = [
        'Rational SCC61 stoomgenerator kalkaanslag',
        'Electrolux AOCP compressor slijtage',
        'Rational XS deursluiting pakking defect'
    ]
    
    # BM25 keyword zoeken
    tokenized = [d.split() for d in docs]
    bm25 = BM25Okapi(tokenized)
    bm25_scores = bm25.get_scores(['stoom', 'probleem'])
    
    # Combineer met semantic scores (gesimuleerd)
    semantic_scores = np.array([0.9, 0.3, 0.4])
    hybrid = 0.7 * semantic_scores + 0.3 * (bm25_scores / bm25_scores.max())
    print(f'Best: {docs[np.argmax(hybrid)]}')</pre>
    ✏ Mini-oefening

    Pas de gewichten aan naar 50/50 en vergelijk de rangschikking.

    👁 Toon oplossing
    hybrid50 = 0.5 * semantic_scores + 0.5 * (bm25_scores / bm25_scores.max()) print(f'50/50 Best: {docs[np.argmax(hybrid50)]}')
    3.8 Embedding Fine-tuning Concept

    Generieke embedding-modellen kennen domein-specifieke termen (Rational, SelfCookingCenter) niet goed. Fine-tuning op een dataset van TechniCool-specifieke tekst-paren verbetert de kwaliteit significant. Je hebt posieve paren nodig (gerelateerde teksten) en negatieve paren (niet-gerelateerde teksten). OpenAI biedt fine-tuning via API aan; Sentence Transformers heeft een open-source trainingslus.

    from sentence_transformers import SentenceTransformer, InputExample, losses
    from torch.utils.data import DataLoader
    
    model = SentenceTransformer('all-MiniLM-L6-v2')
    
    # Trainingsdata: positieve paren
    train_examples = [
        InputExample(texts=['SCC61 stoom defect', 'stoomgenerator probleem Rational'], label=1.0),
        InputExample(texts=['compressor slijtage', 'koelmiddel lekkage'], label=0.8),
        InputExample(texts=['deur defect', 'compressor vervangen'], label=0.1),
    ]
    
    train_dl = DataLoader(train_examples, shuffle=True, batch_size=8)
    loss = losses.CosineSimilarityLoss(model)
    model.fit(train_objectives=[(train_dl, loss)], epochs=1)
    model.save('techniecool_embeddings')</pre>
    ✏ Mini-oefening

    Voeg 5 extra trainingsvoorbeelden toe met TechniCool-specifieke termen.

    👁 Toon oplossing
    extra = [InputExample(texts=['Rational SelfCookingCenter', 'SCC oven combi-steamer'], label=1.0), InputExample(texts=['Electrolux blast chiller', 'schokkoeler koeling'], label=0.9)] train_examples.extend(extra)
    # Genereer embedding voor een TechniCool verslag
                  def generate_embedding(self, text):
                  client = self._get_openai_client()
                  response = client.embeddings.create(
                  input=text,
                  model="text-embedding-3-small"
                  )
                  return response.data[0].embedding
    
                  # Zoek meest relevante oude orders
                  def find_similar_cases(self, current_issue_text):
                  query_vector = self.generate_embedding(current_issue_text)
                  # Hier zou de logica komen om te vergelijken met opgeslagen vectoren
                  # bv. via een SQL extensie of externe service
                  pass</pre>
                    
    ⚙ Praktijkopdracht

    Maak een script dat 50 oude TechniCool interventieverslagen omzet naar embeddings en deze opslaat in een nieuw Odoo model techniecool.knowledge.vector. Schrijf een zoekfunctie die op basis van een omschrijving de top 3 meest gelijkaardige oude gevallen teruggeeft.

    H4
    AI Photo to Render — Vanventory 1
    // stable diffusion, image-to-image, layout generation
    Week 40 · 8u

    Nu wordt het visueel. We gebruiken AI om een 'clean' render te maken van de rommelige binnenkant van een bestelwagen. Je leert hoe je Image-to-Image modellen gebruikt (via Replicate of eigen GPU server) om de foto van de technieker om te zetten naar een gestileerde 2D layout. Dit helpt de dispatcher om de inrichting beter te visualiseren en te optimaliseren.

    4.1 Projectconcept Photo-to-Render

    Photo-to-Render is een pipeline die een foto van een professionele keuken omzet in een stijlvolle render. TechniCool NV gebruikt dit om klanten een visualisatie te geven van hoe hun keuken eruitziet na plaatsing van nieuwe Rational en Electrolux apparatuur. De pipeline bestaat uit: fotoverwerking, AI-segmentatie, render-generatie en post-processing. Dit hoofdstuk behandelt de eerste twee stappen: opname en preprocessing.

    # Pipeline overzicht (pseudocode)
    def photo_to_render(input_path):
        img = load_and_validate(input_path)    # stap 1: laden
        img = correct_perspective(img)          # stap 2: perspectief
        img = enhance_lighting(img)             # stap 3: belichting
        mask = segment_equipment(img)           # stap 4: segmentatie
        render = generate_render(img, mask)     # stap 5: render
        return post_process(render)             # stap 6: post
    
    # Afbeelding laden met validatie
    from PIL import Image
    def load_and_validate(path):
        img = Image.open(path)
        if min(img.size) < 512:
            raise ValueError('Afbeelding te klein, minimaal 512px')
        return img.convert('RGB')</pre>
    ✏ Mini-oefening

    Breid `load_and_validate` uit met een controle op maximale bestandsgrootte (10 MB).

    👁 Toon oplossing
    import os def load_and_validate(path): if os.path.getsize(path) > 10 * 1024 * 1024: raise ValueError('Bestand te groot (max 10 MB)') img = Image.open(path) if min(img.size) < 512: raise ValueError('Te klein') return img.convert('RGB')
    4.2 Perspectief Correctie

    Foto's van keukens zijn zelden perfect rechthoekig opgenomen — er is altijd perspectiefdistortie. OpenCV biedt `getPerspectiveTransform` en `warpPerspective` voor correctie via 4 hoekpunten. TechniCool NV vraagt de gebruiker om de vier hoeken van de vloer aan te klikken in de PWA. Na correctie is de keuken rechthoekig weergegeven, wat AI-segmentatie sterk verbetert.

    import cv2, numpy as np
    
    def correct_perspective(img, src_points, target_size=(1024, 768)):
        """
        src_points: 4 punten [[tl, tr, br, bl]] van de originele afbeelding
        """
        w, h = target_size
        dst = np.array([[0,0],[w,0],[w,h],[0,h]], dtype=np.float32)
        src = np.array(src_points, dtype=np.float32)
        M = cv2.getPerspectiveTransform(src, dst)
        return cv2.warpPerspective(img, M, (w, h))
    
    # Gebruik:
    orig = cv2.imread('keuken.jpg')
    pts = [[100,80],[900,90],[880,650],[120,640]]
    corrected = correct_perspective(orig, pts)</pre>
    ✏ Mini-oefening

    Toon de originele en gecorrigeerde afbeelding naast elkaar met matplotlib.

    👁 Toon oplossing
    import matplotlib.pyplot as plt fig, (a1, a2) = plt.subplots(1,2) a1.imshow(cv2.cvtColor(orig, cv2.COLOR_BGR2RGB)) a1.set_title('Origineel') a2.imshow(cv2.cvtColor(corrected, cv2.COLOR_BGR2RGB)) a2.set_title('Gecorrigeerd') plt.show()
    4.3 Belichting Normalisatie

    Slechte belichting (te donker, te helder, ongelijke schaduwen) bemoeilijkt AI-segmentatie. CLAHE (Contrast Limited Adaptive Histogram Equalization) verbetert lokale belichting. TechniCool NV past dit toe op keukenfoto's die soms met TL-licht en daglicht tegelijk verlicht zijn. CLAHE werkt in de LAB kleurruimte zodat alleen de luminantie aangepast wordt.

    import cv2
    
    def enhance_lighting(img_bgr):
        lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB)
        l, a, b = cv2.split(lab)
        clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
        l_enhanced = clahe.apply(l)
        enhanced_lab = cv2.merge([l_enhanced, a, b])
        return cv2.cvtColor(enhanced_lab, cv2.COLOR_LAB2BGR)
    
    img = cv2.imread('keuken_donker.jpg')
    result = enhance_lighting(img)
    cv2.imwrite('keuken_verbeterd.jpg', result)</pre>
    ✏ Mini-oefening

    Pas `clipLimit` aan van 2.0 naar 4.0 en vergelijk het visuele resultaat.

    👁 Toon oplossing
    clahe4 = cv2.createCLAHE(clipLimit=4.0, tileGridSize=(8,8)) l4 = clahe4.apply(l) # Sla beide versies op en vergelijk
    4.4 Achtergrond Segmentatie met SAM

    Segment Anything Model (SAM) van Meta segmenteert objecten in een foto op basis van punten of boxen. TechniCool NV gebruikt SAM om bestaande apparatuur in de keuken te segmenteren zodat ze vervangen kunnen worden door 3D-renders van nieuwe toestellen. SAM draait lokaal via de `segment-anything` bibliotheek of via een API.

    # SAM via Hugging Face Transformers
    from transformers import SamModel, SamProcessor
    import torch
    from PIL import Image
    
    processor = SamProcessor.from_pretrained('facebook/sam-vit-base')
    model = SamModel.from_pretrained('facebook/sam-vit-base')
    
    image = Image.open('keuken.jpg')
    # Input point op het midden van de oven
    input_points = [[[512, 300]]]  # [[[x, y]]]
    inputs = processor(image, input_points=input_points, return_tensors='pt')
    with torch.no_grad():
        outputs = model(**inputs)
    masks = processor.image_processor.post_process_masks(
        outputs.pred_masks, inputs['original_sizes'], inputs['reshaped_input_sizes']
    )
    print(f'Masker vorm: {masks[0].shape}')</pre>
    ✏ Mini-oefening

    Visualiseer het gegenereerde masker als rode overlay op de originele afbeelding.

    👁 Toon oplossing
    import numpy as np, matplotlib.pyplot as plt mask_np = masks[0][0][0].numpy() overlay = np.array(image.copy()) overlay[mask_np] = [255, 0, 0] plt.imshow(overlay) plt.show()
    4.5 Metadata Extractie voor Render

    Goede renders vereisen kennis van het perspectief, de kamergrootte en de lichtrichting. EXIF-data in JPEG-bestanden bevat soms GPS en camera-instellingen. TechniCool NV vraagt de gebruiker ook handmatig de vloeroppervlakte en plafondhoogte in te voeren. Deze metadata wordt meegestuurd naar de render-generator voor realistische resultaten.

    from PIL import Image
    from PIL.ExifTags import TAGS
    
    def extract_metadata(path):
        img = Image.open(path)
        exif_data = img._getexif() or {}
        metadata = {}
        for tag_id, value in exif_data.items():
            tag = TAGS.get(tag_id, tag_id)
            metadata[tag] = value
        return {
            'breedte': img.width,
            'hoogte': img.height,
            'camera': metadata.get('Make', 'onbekend'),
            'brandpuntsafstand': metadata.get('FocalLength', None),
        }
    
    print(extract_metadata('keuken.jpg'))</pre>
    ✏ Mini-oefening

    Voeg `datetime` toe aan de geretourneerde metadata (uit EXIF 'DateTimeOriginal').

    👁 Toon oplossing
    metadata['opnamedatum'] = metadata.get('DateTimeOriginal', 'onbekend')
    4.6 Kleurkalibratie

    Kleuren op foto's variëren sterk afhankelijk van camera, witbalans en lichtomstandigheden. Voor renders die naadloos in de foto passen, moeten kleuren genormaliseerd worden. TechniCool NV past een witbalanscorrectie toe gebaseerd op een neutraal referentiepunt in de foto. Gray World algoritme schat de witbalans automatisch op basis van de gemiddelde kleur.

    import cv2, numpy as np
    
    def gray_world_balance(img_bgr):
        """
        Gray World Assumption: gemiddelde van alle kanalen zou gelijk moeten zijn
        """
        img_float = img_bgr.astype(np.float32)
        avg = img_float.mean(axis=(0,1))  # [B_avg, G_avg, R_avg]
        overall_avg = avg.mean()
        scale = overall_avg / avg
        balanced = np.clip(img_float * scale, 0, 255).astype(np.uint8)
        return balanced
    
    img = cv2.imread('keuken.jpg')
    balanced = gray_world_balance(img)
    cv2.imwrite('keuken_gebalanceerd.jpg', balanced)</pre>
    ✏ Mini-oefening

    Bereken de gemiddelde kleur voor en na balancering en toon het verschil.

    👁 Toon oplossing
    print('Voor:', cv2.imread('keuken.jpg').mean(axis=(0,1))) print('Na:', balanced.mean(axis=(0,1)))
    4.7 Kwaliteitscontrole Pipeline

    Automatische kwaliteitscontrole detecteert ongeschikte foto's vroeg in de pipeline. Controles: scherpte (Laplacian variance), overbelichting (% witte pixels), wazig (motion blur). TechniCool NV weigert foto's die de kwaliteitsdrempel niet halen met een gebruiksvriendelijke foutmelding. Dit voorkomt slechte renders die klanten verwarren over de werkelijke kwaliteit van de apparatuur.

    import cv2, numpy as np
    
    def check_quality(img_bgr):
        issues = []
        # Scherpte: Laplacian variance
        gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
        sharpness = cv2.Laplacian(gray, cv2.CV_64F).var()
        if sharpness < 100:
            issues.append(f'Te wazig (scherpte: {sharpness:.1f})')
        # Overbelichting
        overexposed = (img_bgr > 250).all(axis=2).mean()
        if overexposed > 0.1:
            issues.append(f'Overbelicht ({overexposed*100:.1f}% wit)')
        return issues
    
    problems = check_quality(cv2.imread('foto.jpg'))
    if problems:
        print('Kwaliteitsproblemen:', problems)
    else:
        print('Foto goedgekeurd')</pre>
    ✏ Mini-oefening

    Voeg een controle toe die foto's afwijst als meer dan 20% van de pixels te donker is (< 30 helderheid).

    👁 Toon oplossing
    dark = (cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) < 30).mean() if dark > 0.2: issues.append(f'Te donker ({dark*100:.1f}% donkere pixels)')
    4.8 Bestandsbeheer & Staging

    De preprocessing-pipeline genereert meerdere tussenliggende bestanden (gecorrigeerd, gesegmenteerd, metadata). Een goede mapstructuur en naamgeving zijn essentieel voor traceerbaarheid in de TechniCool workflow. Elk project krijgt een UUID als mapnaam met daarin `original/`, `processed/`, `masks/` en `renders/`. Odoo slaat de projectreferentie op en linkt alle bestanden via `ir.attachment`.

    import os, uuid, shutil
    from pathlib import Path
    
    def create_project_structure(base_dir='/var/renders'):
        project_id = str(uuid.uuid4())
        project_path = Path(base_dir) / project_id
        for subdir in ['original', 'processed', 'masks', 'renders']:
            (project_path / subdir).mkdir(parents=True, exist_ok=True)
        return project_path, project_id
    
    # Gebruik:
    path, pid = create_project_structure()
    print(f'Project: {pid}')
    shutil.copy('keuken.jpg', path / 'original' / 'keuken.jpg')</pre>
    ✏ Mini-oefening

    Schrijf een functie die alle bestanden in een projectmap oplijst per submap.

    👁 Toon oplossing
    def list_project_files(project_path): return {d: list(Path(project_path, d).iterdir()) for d in ['original','processed','masks','renders']}
    # Replicate API call voor Image-to-Image render
                  import replicate
    
                  def generate_clean_render(self, original_image_url):
                  output = replicate.run(
                  "stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1339d2af963d58213ddef18e8b2347391930",
                  input={
                    "image": original_image_url,
                    "prompt": "clean technical 3d render of van interior shelving, TechniCool orange style",
                    "controlnet_model": "canny"
                  }
                  )
                  return output[0]</pre>
                    
    ⚙ Praktijkopdracht

    Stel een workflow op die een foto van een bestelwagen-rek neemt en via een AI prompt een 'opgeruimde' versie van datzelfde rek genereert. Toon beide afbeeldingen naast elkaar in een Odoo dashboard.

    H5
    AI Generative Render — Vanventory 2
    // architectural prompting, 3D visualization, auto-labels
    Week 41 · 8u

    We gaan een stap verder: de AI genereert nu labels en iconen over de render heen. Je leert hoe je 'Auto-labels' implementeert. De AI herkent niet alleen de rekken, maar 'hallucineert' (op een gecontroleerde manier) waar labels zouden moeten hangen voor een optimale TechniCool workflow. We combineren de AI render met onze SVG mapper uit Vak 2.

    5.1 Stable Diffusion Inpainting

    Stable Diffusion Inpainting vervangt een gemaskerd gebied in een foto met AI-gegenereerde inhoud. TechniCool NV vervangt het oude keukentoestellenmasker met een render van de nieuwe Rational oven. Het model combineert de omgevingskleur, belichting en perspectief van de originele foto. Diffusers bibliotheek van Hugging Face maakt lokale Stable Diffusion eenvoudig te gebruiken.

    from diffusers import StableDiffusionInpaintPipeline
    import torch
    from PIL import Image
    
    pipe = StableDiffusionInpaintPipeline.from_pretrained(
        'runwayml/stable-diffusion-inpainting',
        torch_dtype=torch.float16
    ).to('cuda')
    
    image = Image.open('keuken_processed.jpg').resize((512, 512))
    mask = Image.open('mask.png').resize((512, 512))  # wit = te vervangen
    
    result = pipe(
        prompt='Professional Rational SelfCookingCenter combi-steamer, stainless steel, kitchen',
        image=image,
        mask_image=mask,
        num_inference_steps=50,
        guidance_scale=7.5
    ).images[0]
    result.save('render_output.jpg')</pre>
    ✏ Mini-oefening

    Pas de prompt aan om een Electrolux blast chiller te genereren in plaats van een Rational oven.

    👁 Toon oplossing
    result = pipe(prompt='Electrolux blast chiller, stainless steel, professional kitchen equipment', image=image, mask_image=mask, num_inference_steps=50).images[0]
    5.2 ControlNet voor Perspectief

    ControlNet stuurt Stable Diffusion via extra conditionering zoals edges, diepte of poses. Met ControlNet Canny blijft de ruimtelijke structuur van de keuken behouden in de render. TechniCool NV gebruikt dit om te verzekeren dat de nieuwe apparatuur correct in het perspectief past. De edges van de originele foto dienen als skelet voor de gegenereerde render.

    from diffusers import StableDiffusionControlNetPipeline, ControlNetModel
    import cv2, numpy as np, torch
    from PIL import Image
    
    controlnet = ControlNetModel.from_pretrained('lllyasviel/sd-controlnet-canny', torch_dtype=torch.float16)
    pipe = StableDiffusionControlNetPipeline.from_pretrained(
        'runwayml/stable-diffusion-v1-5',
        controlnet=controlnet,
        torch_dtype=torch.float16
    ).to('cuda')
    
    # Canny edge detectie
    img = cv2.imread('keuken.jpg')
    edges = cv2.Canny(img, 100, 200)
    canny_img = Image.fromarray(edges)
    
    result = pipe('Rational combi-steamer, professional kitchen', image=canny_img).images[0]</pre>
    ✏ Mini-oefening

    Vervang Canny edges door dieptekaart conditionering met ControlNet depth.

    👁 Toon oplossing
    controlnet = ControlNetModel.from_pretrained('lllyasviel/sd-controlnet-depth', torch_dtype=torch.float16) # Genereer dieptekaart via MiDaS of DPT voor betere ruimtelijke controle
    5.3 DALL-E 3 Inpainting via API

    OpenAI DALL-E 3 biedt een eenvoudiger cloudgebaseerde inpainting API zonder eigen GPU. Je stuurt de originele afbeelding, het masker en een prompt als multipart form data. TechniCool NV kan DALL-E gebruiken voor snelle demo-renders zonder infrastructuur. De kost is circa 0,04 USD per 1024x1024 render — geschikt voor klantendemonstaties.

    import openai
    from pathlib import Path
    
    client = openai.OpenAI(api_key='sk-...')
    
    response = client.images.edit(
        model='dall-e-2',
        image=open('keuken_processed.png', 'rb'),
        mask=open('mask.png', 'rb'),
        prompt='Professional Rational SelfCookingCenter combi-steamer, stainless steel finish, matching kitchen lighting',
        n=1,
        size='1024x1024'
    )
    image_url = response.data[0].url
    print(f'Render URL: {image_url}')</pre>
    ✏ Mini-oefening

    Download de render-URL automatisch en sla hem op als lokaal bestand via `requests`.

    👁 Toon oplossing
    import requests img_data = requests.get(image_url).content with open('render.png', 'wb') as f: f.write(img_data)
    5.4 Prompt Engineering voor Keukerapparatuur

    De kwaliteit van de render hangt sterk af van de prompt. Effectieve prompts voor keukenapparatuur bevatten: materiaal (RVS), merk, stijl, belichting. TechniCool NV heeft een bibliotheek van bewezen prompts per toesteltype opgebouwd. Negatieve prompts (wat het model NIET mag genereren) zijn even belangrijk als positieve.

    PROMPT_LIBRARY = {
        'rational_scc': (
            'Rational SelfCookingCenter XS combi-steamer, brushed stainless steel, '
            'professional commercial kitchen, soft even lighting, photorealistic, '
            'product photography style, 8k, highly detailed',
            # Negatieve prompt:
            'cartoon, blurry, plastic, consumer grade, home kitchen, text, watermark'
        ),
        'electrolux_blast': (
            'Electrolux blast chiller, stainless steel, commercial kitchen, '
            'front view, clean reflections, product render, photorealistic',
            'low quality, amateur, home appliance, distorted'
        )
    }
    
    pos, neg = PROMPT_LIBRARY['rational_scc']
    print('Positief:', pos[:50], '...')
    print('Negatief:', neg)</pre>
    ✏ Mini-oefening

    Voeg een prompt toe voor een Electrolux warme kast (`electrolux_warmkast`).

    👁 Toon oplossing
    PROMPT_LIBRARY['electrolux_warmkast'] = ('Electrolux holding cabinet, stainless steel, commercial kitchen, photorealistic', 'blurry, cartoon')
    5.5 Post-processing — Kleurharmonisatie

    De AI-render heeft soms een andere kleurtemperatuur dan de rest van de foto. Kleurharmonisatie past de render aan zodat hij visueel aansluit bij de omgeving. TechniCool NV gebruikt histogram matching om de kleurdistributie van de render te aligneren. Dit geeft een professioneel resultaat zonder harde grenzen tussen render en foto.

    import cv2, numpy as np
    
    def histogram_match(source, reference):
        """
        Past de kleurverdeling van 'source' aan naar die van 'reference'
        """
        result = source.copy()
        for ch in range(3):  # B, G, R
            src_hist, _ = np.histogram(source[:,:,ch].flatten(), 256, [0,256])
            ref_hist, _ = np.histogram(reference[:,:,ch].flatten(), 256, [0,256])
            src_cdf = src_hist.cumsum() / src_hist.sum()
            ref_cdf = ref_hist.cumsum() / ref_hist.sum()
            lut = np.interp(src_cdf, ref_cdf, np.arange(256)).astype(np.uint8)
            result[:,:,ch] = cv2.LUT(source[:,:,ch], lut)
        return result
    
    render = cv2.imread('render_output.jpg')
    keuken = cv2.imread('keuken_processed.jpg')
    harmonized = histogram_match(render, keuken)</pre>
    ✏ Mini-oefening

    Sla `harmonized` op en vergelijk visueel met de originele render via matplotlib.

    👁 Toon oplossing
    import matplotlib.pyplot as plt fig,(a1,a2)=plt.subplots(1,2) a1.imshow(cv2.cvtColor(render,cv2.COLOR_BGR2RGB));a1.set_title('Origineel') a2.imshow(cv2.cvtColor(harmonized,cv2.COLOR_BGR2RGB));a2.set_title('Geharmoniseerd') plt.show()
    5.6 Render Compositing

    Compositing plakt de render terug in de originele foto op de exacte positie van het masker. Zachte maskerranden (feathering) verbergen de grens tussen foto en render. TechniCool NV past Gaussian blur toe op het masker voor een vloeiende overgang. Alpha compositing via OpenCV geeft professionele resultaten in milliseconden.

    import cv2, numpy as np
    
    def composite(background, render, mask_gray):
        # Zachte maskerranden
        mask_blur = cv2.GaussianBlur(mask_gray, (21, 21), 0)
        alpha = mask_blur / 255.0
        alpha3 = np.stack([alpha]*3, axis=2)
        # Alpha blend
        result = (render * alpha3 + background * (1 - alpha3)).astype(np.uint8)
        return result
    
    bg = cv2.imread('keuken_processed.jpg')
    rend = cv2.imread('render_harmonized.jpg')
    mask = cv2.imread('mask.png', cv2.IMREAD_GRAYSCALE)
    output = composite(bg, rend, mask)
    cv2.imwrite('final_render.jpg', output)</pre>
    ✏ Mini-oefening

    Pas de Gaussian blur kernel aan van 21x21 naar 51x51 en beschrijf het visuele effect.

    👁 Toon oplossing
    mask_blur = cv2.GaussianBlur(mask_gray, (51, 51), 0) # Grotere kernel = zachtere, bredere overgang aan de maskerrand
    5.7 Odoo Integratie — Render Workflow

    De render-pipeline wordt geactiveerd vanuit een Odoo-offerte of klantendossier. Een wizard laat de gebruiker foto uploaden, masker tekenen en render starten. Het eindresultaat wordt opgeslagen als `ir.attachment` gelinkt aan de offerte. TechniCool NV stuurt de render automatisch mee in de PDF-offerte via een QWeb-template.

    from odoo import models, fields
    
    class RenderWizard(models.TransientModel):
        _name = 'techniecool.render.wizard'
        _description = 'Photo-to-Render Wizard'
    
        sale_order_id = fields.Many2one('sale.order', 'Offerte')
        photo = fields.Binary('Keukenfoto')
        photo_filename = fields.Char()
        prompt_template = fields.Selection([
            ('rational_scc', 'Rational SelfCookingCenter'),
            ('electrolux_blast', 'Electrolux Blast Chiller'),
        ], 'Toesteltype')
        render_result = fields.Binary('Render Resultaat')
    
        def action_generate_render(self):
            # Trigger AI pipeline (async via queue_job)
            self.env['techniecool.render.job'].sudo().create({
                'wizard_id': self.id,
                'state': 'queued'
            })</pre>
    ✏ Mini-oefening

    Voeg een statusveld `state` toe aan de wizard met waarden: `draft`, `processing`, `done`, `error`.

    👁 Toon oplossing
    state = fields.Selection([('draft','Concept'),('processing','Verwerken'),('done','Klaar'),('error','Fout')], default='draft')
    5.8 Kwaliteitsbeoordeling van de Render

    Niet elke AI-render is bruikbaar — het model kan artefacten, vervormingen of onrealistische kleuren genereren. Een automatische kwaliteitscheck berekent de SSIM (Structural Similarity Index) tussen render en omgeving. TechniCool NV vraagt de klant ook om een duimpje-omhoog/omlaag te geven via de portal. Feedback wordt opgeslagen voor toekomstige prompt-optimalisatie.

    from skimage.metrics import structural_similarity as ssim
    import cv2
    
    def assess_render_quality(original, render, mask):
        """
        Bereken SSIM in de niet-gemaskeerde regio (achtergrond moet ongewijzigd blijven)
        """
        orig_gray = cv2.cvtColor(original, cv2.COLOR_BGR2GRAY)
        rend_gray = cv2.cvtColor(render, cv2.COLOR_BGR2GRAY)
        inv_mask = cv2.bitwise_not(mask)
        orig_bg = cv2.bitwise_and(orig_gray, orig_gray, mask=inv_mask)
        rend_bg = cv2.bitwise_and(rend_gray, rend_gray, mask=inv_mask)
        score, _ = ssim(orig_bg, rend_bg, full=True)
        return score
    
    orig = cv2.imread('keuken.jpg')
    rend = cv2.imread('final_render.jpg')
    mask = cv2.imread('mask.png', cv2.IMREAD_GRAYSCALE)
    print(f'SSIM achtergrondkwaliteit: {assess_render_quality(orig, rend, mask):.3f}')</pre>
    ✏ Mini-oefening

    Een SSIM van boven 0.85 beschouw je als goed. Schrijf een functie die 'Goedgekeurd' of 'Afgewezen' retourneert.

    👁 Toon oplossing
    def render_verdict(orig, rend, mask): score = assess_render_quality(orig, rend, mask) return 'Goedgekeurd' if score > 0.85 else 'Afgewezen'
    ⚙ Praktijkopdracht

    Breid de render-tool uit zodat de AI ook kleine oranje cirkels tekent op de plaatsen waar wisselstukken liggen. De coördinaten van deze cirkels moeten als JSON data terugkomen en door je OWL component worden ingeladen als klikbare SVG punten.

    H6
    AI Parts Recognition & Counting
    // object counting, stock level detection, vision prompts
    Week 42 · 8u

    Automatische inventarisatie! We leren de AI om onderdelen op een schap te tellen. "Hoeveel filters liggen er nog in rek B?". Je leert specifieke Vision prompts schrijven voor 'Counting' taken. We koppelen de getelde aantallen direct aan de Odoo stock quants om automatische 'Inventory Adjustments' voor te stellen aan de technieker van TechniCool NV.

    6.1 Object Detection Basis

    Object detection identificeert en lokaliseert objecten in afbeeldingen met bounding boxes. YOLO (You Only Look Once) is de standaard voor real-time object detection. TechniCool NV traint een YOLO-model op onderdelen van Rational en Electrolux apparatuur. Een technicus richt de camera op een onderdeel en de app toont direct het artikelnummer.

    from ultralytics import YOLO
    import cv2
    
    # Laad pretrained model (of eigen getraind model)
    model = YOLO('yolov8n.pt')  # nano-model voor snelheid
    
    results = model.predict(
        source='keuken_onderdelen.jpg',
        conf=0.5,  # minimale betrouwbaarheid
        save=True
    )
    
    for r in results:
        for box in r.boxes:
            cls = r.names[int(box.cls)]
            conf = float(box.conf)
            print(f'Gevonden: {cls} (betrouwbaarheid: {conf:.2f})')</pre>
    ✏ Mini-oefening

    Filter de resultaten zodat alleen objecten met betrouwbaarheid > 0.75 getoond worden.

    👁 Toon oplossing
    hoge_conf = [b for b in results[0].boxes if float(b.conf) > 0.75] for b in hoge_conf: print(results[0].names[int(b.cls)], float(b.conf))
    6.2 YOLO Finetuning op Eigen Data

    Een voorgetraind YOLO-model kent standaard objecten maar geen specifieke keukensonderdelen. Fine-tuning op TechniCool-specifieke labeldata leert het model compressoren, boilers en filters herkennen. Je hebt minimum 100 gelabelde foto's per klasse nodig voor redelijke resultaten. LabelImg of Roboflow zijn populaire tools om bounding boxes manueel te annoteren.

    from ultralytics import YOLO
    
    model = YOLO('yolov8n.pt')  # start van pretrained weights
    
    results = model.train(
        data='techniecool_parts.yaml',  # dataset configuratie
        epochs=100,
        imgsz=640,
        batch=16,
        name='techniecool_parts_v1'
    )
    
    # techniecool_parts.yaml voorbeeld:
    # path: /data/techniecool_parts
    # train: images/train
    # val: images/val
    # names:
    #   0: boiler
    #   1: compressor
    #   2: filter
    #   3: pomp</pre>
    ✏ Mini-oefening

    Voeg een klasse `ventilator` toe aan de YAML-dataset configuratie.

    👁 Toon oplossing
    # In techniecool_parts.yaml, voeg toe: # 4: ventilator # Zorg ook voor gelabelde foto's van ventilatoren in images/train
    6.3 Real-time Camera Herkenning in PWA

    De TechniCool PWA toont een live camera-feed met real-time object detection overlay. YOLO-modellen zijn geconverteerd naar ONNX-formaat voor gebruik in de browser via ONNX Runtime Web. De technicus richt de camera op een onderdeel en ziet direct een bounding box met onderdeelnummer. Dit versnelt de onderdelenidentificatie enorm en vermindert fouten bij het bestellen.

    // JavaScript: ONNX Runtime Web in browser
    import * as ort from 'onnxruntime-web';
    
    async function loadModel() {
        return await ort.InferenceSession.create('techniecool_parts.onnx');
    }
    
    async function detectParts(session, videoElement) {
        const canvas = document.createElement('canvas');
        canvas.width = 640;
        canvas.height = 640;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(videoElement, 0, 0, 640, 640);
        const imageData = ctx.getImageData(0, 0, 640, 640);
        // Normaliseer naar [0,1]
        const tensor = new ort.Tensor('float32',
            Float32Array.from(imageData.data.filter((_,i) => i%4!==3)).map(v => v/255),
            [1, 3, 640, 640]
        );
        const results = await session.run({'images': tensor});
        return results;
    }</pre>
    ✏ Mini-oefening

    Toon de FPS (frames per seconde) van de detectie in de camera-overlay.

    👁 Toon oplossing
    let lastTime = performance.now(); async function loop() { const now = performance.now(); const fps = 1000 / (now - lastTime); lastTime = now; document.getElementById('fps').textContent = fps.toFixed(1) + ' FPS'; await detectParts(session, video); requestAnimationFrame(loop); }
    6.4 Onderdeel Opzoeken in Odoo

    Na herkenning van een onderdeel zoekt de app het corresponderende product op in Odoo. Een mapping-tabel koppelt YOLO-klasse-namen aan Odoo-productreferenties. TechniCool NV beheert deze mapping in een apart Odoo-model `techniecool.parts.mapping`. De technicus kan vanuit de app direct een interne bestelling aanmaken.

    from odoo import models, fields, api
    
    class PartsMapping(models.Model):
        _name = 'techniecool.parts.mapping'
        _description = 'AI Onderdelenherkenning Mapping'
    
        yolo_class = fields.Char('YOLO Klassenaam', required=True)
        product_id = fields.Many2one('product.product', 'Odoo Product', required=True)
        equipment_brand = fields.Selection([
            ('rational', 'Rational'),
            ('electrolux', 'Electrolux')
        ], 'Merk')
        confidence_threshold = fields.Float('Min. Betrouwbaarheid', default=0.7)
    
        _sql_constraints = [
            ('unique_class_brand', 'UNIQUE(yolo_class, equipment_brand)',
             'Elke klasse/merk combinatie moet uniek zijn.')
        ]</pre>
    ✏ Mini-oefening

    Voeg een methode `find_product` toe die op basis van een YOLO-klasse het Odoo-product retourneert.

    👁 Toon oplossing
    def find_product(self, yolo_class, brand=None): domain = [('yolo_class','=',yolo_class)] if brand: domain.append(('equipment_brand','=',brand)) mapping = self.search(domain, limit=1) return mapping.product_id if mapping else False
    6.5 Confidence Scoring & Fallback

    Wanneer de AI niet zeker genoeg is (betrouwbaarheid < drempel), moet er een fallback zijn. TechniCool NV toont dan een handmatige zoekbalk waarmee de technicus het onderdeel kan opzoeken. De resultaten van lage-confidence detecties worden gelogd voor dataset-verbetering. Een actieve feedback-loop verbetert het model incrementeel met echte veldfoto's.

    def handle_detection(detection_result, min_confidence=0.7):
        """
        Verwerk detectieresultaat met fallback voor lage betrouwbaarheid
        """
        best = max(detection_result, key=lambda d: d['confidence'])
        if best['confidence'] >= min_confidence:
            return {
                'status': 'detected',
                'class': best['class'],
                'confidence': best['confidence'],
                'show_manual': False
            }
        else:
            # Log voor dataset verbetering
            log_low_confidence(best)
            return {
                'status': 'uncertain',
                'class': best['class'],
                'confidence': best['confidence'],
                'show_manual': True
            }</pre>
    ✏ Mini-oefening

    Implementeer `log_low_confidence` zodat het de detectie opslaat in een CSV-bestand.

    👁 Toon oplossing
    import csv, datetime def log_low_confidence(det): with open('low_conf_log.csv', 'a', newline='') as f: csv.writer(f).writerow([datetime.datetime.now(), det['class'], det['confidence']])
    6.6 Dataset Aanmaken met Roboflow

    Roboflow is een platform voor het annoteren, augmenteren en exporteren van object detection datasets. TechniCool NV uploadt foto's van onderdelen, annoteert ze in de web-interface en exporteert in YOLO-formaat. Data augmentatie (rotatie, helderheid, ruis) verdrievoudigt de effectieve datasetgrootte. Roboflow genereert automatisch train/val/test splits en een correcte YAML-configuratie.

    # Na export vanuit Roboflow:
    # Dataset structuur:
    # techniecool_parts/
    #   data.yaml
    #   train/
    #     images/  (bijv. 800 foto's)
    #     labels/  (YOLO format .txt bestanden)
    #   valid/
    #     images/  (bijv. 200 foto's)
    #     labels/
    #
    # data.yaml inhoud:
    # nc: 4  # aantal klassen
    # names: ['boiler', 'compressor', 'filter', 'pomp']
    
    from ultralytics import YOLO
    model = YOLO('yolov8s.pt')  # small model voor betere nauwkeurigheid
    results = model.train(data='techniecool_parts/data.yaml', epochs=150, imgsz=640)</pre>
    ✏ Mini-oefening

    Voeg `patience=20` toe aan de training om early stopping in te schakelen.

    👁 Toon oplossing
    results = model.train(data='techniecool_parts/data.yaml', epochs=150, imgsz=640, patience=20)
    6.7 Model Evaluatie & mAP

    mAP (mean Average Precision) is de standaardmethode om object detection modellen te evalueren. mAP@0.5 meet precisie bij 50% IoU-overlap; mAP@0.5:0.95 meet over meerdere drempelwaarden. TechniCool NV streeft naar mAP@0.5 > 0.85 voor alle onderdeel-klassen. Ultralytics YOLO genereert automatisch evaluatierapporten met confusion matrices.

    from ultralytics import YOLO
    
    model = YOLO('runs/detect/techniecool_parts_v1/weights/best.pt')
    
    # Evalueer op validatieset
    metrics = model.val(data='techniecool_parts/data.yaml')
    
    print(f'mAP50: {metrics.box.map50:.3f}')
    print(f'mAP50-95: {metrics.box.map:.3f}')
    for i, name in enumerate(metrics.names.values()):
        print(f'  {name}: precision={metrics.box.p[i]:.3f}, recall={metrics.box.r[i]:.3f}')</pre>
    ✏ Mini-oefening

    Sla de evaluatiemetrics op in een JSON-bestand voor versietracking.

    👁 Toon oplossing
    import json with open('eval_v1.json','w') as f: json.dump({'map50': metrics.box.map50, 'map': metrics.box.map}, f, indent=2)
    6.8 Integratie met Servicebon

    Na herkenning en productkoppeling kan de technicus onderdelen direct toevoegen aan een servicebon. De OWL-component communiceert met een Odoo JSON controller die de stocklijn aanmaakt. TechniCool NV toont ook de huidige stockstatus van het herkende onderdeel in de app. Zo weet de technicus ter plaatse of hij het onderdeel op de vrachtwagen heeft of moet bestellen.

    @http.route('/api/parts/add-to-workorder', type='json', auth='user')
    def add_part_to_workorder(self, workorder_id, product_id, quantity=1, **kw):
        wo = request.env['techniecool.workorder'].browse(workorder_id)
        product = request.env['product.product'].browse(product_id)
        # Controleer stock
        qty_available = product.with_context(location=wo.technician_stock_location).qty_available
        # Voeg toe aan werkorder
        wo.parts_line_ids.create({
            'workorder_id': workorder_id,
            'product_id': product_id,
            'quantity': quantity,
        })
        return {
            'success': True,
            'qty_in_stock': qty_available,
            'needs_order': qty_available < quantity
        }</pre>
    ✏ Mini-oefening

    Voeg een notificatie toe die een interne Odoo-berichten stuurt als `needs_order` True is.

    👁 Toon oplossing
    if qty_available < quantity: wo.message_post(body=f'Onderdeel {product.name} niet op stock — bestelling nodig.')
    # Prompt voor stock-telling
                  prompt = """
                  Analyseer dit rek in de TechniCool bestelwagen.
                  Tel het aantal zichtbare 'Rational Air Filters' (blauwe rand).
                  Geef het resultaat terug in dit JSON formaat:
                  {"product_code": "RAT-102", "detected_count": X, "confidence": 0.95}
                  """</pre>
                    
    ⚙ Praktijkopdracht

    Maak een 'Snelle Inventaris' knop in de PWA. Maak een foto van een schap met minstens 5 identieke onderdelen. Laat de AI ze tellen en vergelijk het resultaat met de huidige stock in Odoo. Toon het verschil in een rode of groene badge.

    H7
    LLM Chatbot voor Technici
    // RAG, function calling, technician assistant
    Week 43 · 8u

    Elke technieker krijgt een AI-mentor. We bouwen een chatbot die toegang heeft tot de volledige TechniCool database. Je leert 'Function Calling' gebruiken: de technieker vraagt "Wat is de foutcode E15 op deze Rational?" en de chatbot zoekt zelf in de handleidingen én kijkt in de Odoo historiek of dit toestel dat probleem al eerder had.

    7.1 Chatbot Architectuur

    Een technische chatbot voor TechniCool NV helpt technici in het veld bij diagnose en procedures. De architectuur bestaat uit: OWL chat-interface, Odoo JSON controller, RAG-pipeline en LLM API. De chatbot heeft toegang tot de RAG-index van alle TechniCool technische documentatie. Conversatiegeschiedenis wordt bijgehouden per servicebon voor context en traceerbaarheid.

    # Architectuuroverzicht (pseudocode)
    def handle_chat(user_message, conversation_history, workorder_id):
        # 1. Haal relevante docs op via RAG
        context_docs = rag_search(user_message)
        # 2. Bouw de gespreksgeschiedenis op
        messages = build_messages(conversation_history, context_docs, user_message)
        # 3. Roep LLM aan
        response = call_llm(messages)
        # 4. Sla op in Odoo
        save_chat_message(workorder_id, user_message, response)
        return response</pre>
    ✏ Mini-oefening

    Teken een sequentiediagram van de chatbot-architectuur (verbaal beschrijven is ook OK).

    👁 Toon oplossing
    # Sequentie: Technicus -> OWL -> Controller -> RAG -> LLM -> Controller -> OWL -> Technicus
    7.2 Conversatiegeheugen

    Een LLM heeft geen ingebouwd geheugen tussen API-aanroepen — je moet de gespreksgeschiedenis doorgeven. De OpenAI messages-array bevat afwisselend `user` en `assistant` berichten. TechniCool NV slaat de chatgeschiedenis per servicebon op in een `techniecool.chat.message` model. Het aantal berichten in context is begrensd door de token-limiet van het model.

    from odoo import models, fields
    
    class ChatMessage(models.Model):
        _name = 'techniecool.chat.message'
        _description = 'Chatbot Bericht'
        _order = 'create_date asc'
    
        workorder_id = fields.Many2one('techniecool.workorder', 'Werkorder')
        role = fields.Selection([('user','Technicus'),('assistant','AI')], 'Rol')
        content = fields.Text('Inhoud')
    
    def get_conversation_history(workorder_id, env, max_messages=10):
        msgs = env['techniecool.chat.message'].search(
            [('workorder_id', '=', workorder_id)],
            order='create_date asc',
            limit=max_messages
        )
        return [{'role': m.role, 'content': m.content} for m in msgs]</pre>
    ✏ Mini-oefening

    Voeg een `token_count` veld toe dat het geschatte aantal tokens per bericht bijhoudt.

    👁 Toon oplossing
    token_count = fields.Integer('Geschat Token Aantal') # Bij aanmaken: # record.token_count = len(content.split()) * 1.3 # ruwe schatting
    7.3 Technische Kennisbank Integratie

    De chatbot is alleen nuttig als hij toegang heeft tot TechniCool-specifieke kennis. Alle servicemanualen, onderhoudsschema's en toestelspecificaties worden geïndexeerd in ChromaDB. Bij elke chaturvraag worden de 3 meest relevante documenten opgehaald en meegegeven aan het LLM. De bronnen worden getoond in de chat zodat de technicus de informatie kan verifiëren.

    @http.route('/api/chat', type='json', auth='user', methods=['POST'])
    def chat(self, workorder_id, message, **kw):
        import chromadb, openai
        # RAG: zoek relevante documenten
        chroma = chromadb.HttpClient(host='localhost', port=8000)
        col = chroma.get_collection('techniecool_manuals')
        docs = col.query(query_texts=[message], n_results=3)
        context = '\n---\n'.join(docs['documents'][0])
        sources = docs['ids'][0]
        # LLM aanroep
        history = get_conversation_history(workorder_id, request.env)
        messages = [
            {'role': 'system', 'content': f'TechniCool NV technische assistent. Gebruik deze context:\n{context}'},
            *history,
            {'role': 'user', 'content': message}
        ]
        oai = openai.OpenAI(api_key=request.env['ir.config_parameter'].sudo().get_param('openai.api_key'))
        resp = oai.chat.completions.create(model='gpt-4o', messages=messages)
        answer = resp.choices[0].message.content
        return {'answer': answer, 'sources': sources}</pre>
    ✏ Mini-oefening

    Voeg bronvermelding toe aan het chatantwoord door de document-IDs te tonen in de OWL-interface.

    👁 Toon oplossing
    // In OWL template: //
    // Bronnen: //
    7.4 OWL Chat Interface

    De chat-interface is een OWL-component met een berichtenlijst en een invoerveld. Berichten worden gestreamd via de controller voor een responsieve gebruikerservaring. TechniCool NV toont een 'AI typt...' animatie tijdens het wachten op het antwoord. Berichten van de technicus en de AI worden visueel onderscheiden door kleur en uitlijning.

    import { Component, useState, useRef } from '@odoo/owl';
    
    export class TechnicoolChat extends Component {
        static template = 'techniecool.Chat';
    
        setup() {
            this.state = useState({
                messages: [],
                input: '',
                loading: false
            });
            this.bottomRef = useRef('bottom');
        }
    
        async sendMessage() {
            const text = this.state.input.trim();
            if (!text) return;
            this.state.messages.push({ role: 'user', content: text });
            this.state.input = '';
            this.state.loading = true;
            const res = await this.env.services.rpc('/api/chat', {
                workorder_id: this.props.workorderId,
                message: text
            });
            this.state.messages.push({ role: 'assistant', content: res.answer });
            this.state.loading = false;
            this.bottomRef.el?.scrollIntoView({ behavior: 'smooth' });
        }
    }</pre>
    ✏ Mini-oefening

    Voeg keyboard shortcut toe: Enter stuurt het bericht, Shift+Enter maakt een nieuwe regel.

    👁 Toon oplossing
    // In de template input: //